@@ -15,6 +15,7 @@ export interface FileCommand {
1515 source : "user" | "project" | "plugin"
1616 pluginName ?: string
1717 path : string
18+ content : string
1819}
1920
2021/**
@@ -57,6 +58,7 @@ async function scanCommandsDirectory(
5758 dir : string ,
5859 source : "user" | "project" | "plugin" ,
5960 prefix = "" ,
61+ basePath ?: string ,
6062) : Promise < FileCommand [ ] > {
6163 const commands : FileCommand [ ] = [ ]
6264
@@ -85,23 +87,37 @@ async function scanCommandsDirectory(
8587 fullPath ,
8688 source ,
8789 prefix ? `${ prefix } :${ entry . name } ` : entry . name ,
90+ basePath ,
8891 )
8992 commands . push ( ...nestedCommands )
9093 } else if ( isFile && entry . name . endsWith ( ".md" ) ) {
9194 const baseName = entry . name . replace ( / \. m d $ / , "" )
9295 const fallbackName = prefix ? `${ prefix } :${ baseName } ` : baseName
9396
9497 try {
95- const content = await fs . readFile ( fullPath , "utf-8" )
96- const parsed = parseCommandMd ( content )
98+ const rawContent = await fs . readFile ( fullPath , "utf-8" )
99+ const parsed = parseCommandMd ( rawContent )
100+ const { content : body } = matter ( rawContent )
97101 const commandName = parsed . name || fallbackName
98102
103+ // Format display path: ~/... for user, relative for project
104+ let displayPath : string
105+ if ( source === "project" && basePath ) {
106+ displayPath = path . relative ( basePath , fullPath )
107+ } else {
108+ const homeDir = os . homedir ( )
109+ displayPath = fullPath . startsWith ( homeDir )
110+ ? "~" + fullPath . slice ( homeDir . length )
111+ : fullPath
112+ }
113+
99114 commands . push ( {
100115 name : commandName ,
101116 description : parsed . description || "" ,
102117 argumentHint : parsed . argumentHint ,
103118 source,
104- path : fullPath ,
119+ path : displayPath ,
120+ content : body . trim ( ) ,
105121 } )
106122 } catch ( err ) {
107123 console . warn ( `[commands] Failed to read ${ fullPath } :` , err )
@@ -115,6 +131,36 @@ async function scanCommandsDirectory(
115131 return commands
116132}
117133
134+ /**
135+ * Generate command .md content from name, description, and body
136+ */
137+ function generateCommandMd ( command : { name : string ; description : string ; content : string ; argumentHint ?: string } ) : string {
138+ const frontmatter : string [ ] = [ ]
139+ if ( command . description ) {
140+ frontmatter . push ( `description: ${ command . description } ` )
141+ }
142+ if ( command . argumentHint ) {
143+ frontmatter . push ( `argument-hint: ${ command . argumentHint } ` )
144+ }
145+ if ( frontmatter . length === 0 ) {
146+ return command . content
147+ }
148+ return `---\n${ frontmatter . join ( "\n" ) } \n---\n\n${ command . content } `
149+ }
150+
151+ /**
152+ * Resolve the absolute filesystem path of a command given its display path
153+ */
154+ function resolveCommandPath ( displayPath : string , projectPath ?: string ) : string {
155+ if ( displayPath . startsWith ( "~" ) ) {
156+ return path . join ( os . homedir ( ) , displayPath . slice ( 1 ) )
157+ }
158+ if ( projectPath && ! displayPath . startsWith ( "/" ) ) {
159+ return path . join ( projectPath , displayPath )
160+ }
161+ return displayPath
162+ }
163+
118164export const commandsRouter = router ( {
119165 /**
120166 * List all commands from filesystem
@@ -143,6 +189,8 @@ export const commandsRouter = router({
143189 projectCommandsPromise = scanCommandsDirectory (
144190 projectCommandsDir ,
145191 "project" ,
192+ "" ,
193+ input . projectPath ,
146194 )
147195 }
148196
@@ -181,20 +229,129 @@ export const commandsRouter = router({
181229 * Get content of a specific command file (without frontmatter)
182230 */
183231 getContent : publicProcedure
184- . input ( z . object ( { path : z . string ( ) } ) )
232+ . input ( z . object ( { path : z . string ( ) , projectPath : z . string ( ) . optional ( ) } ) )
185233 . query ( async ( { input } ) => {
186234 // Security: prevent path traversal
187235 if ( input . path . includes ( ".." ) ) {
188236 throw new Error ( "Invalid path" )
189237 }
190238
191239 try {
192- const content = await fs . readFile ( input . path , "utf-8" )
240+ const absolutePath = resolveCommandPath ( input . path , input . projectPath )
241+ const content = await fs . readFile ( absolutePath , "utf-8" )
193242 const { content : body } = matter ( content )
194243 return { content : body . trim ( ) }
195244 } catch ( err ) {
196245 console . error ( `[commands] Failed to read command content:` , err )
197246 return { content : "" }
198247 }
199248 } ) ,
249+
250+ create : publicProcedure
251+ . input (
252+ z . object ( {
253+ name : z . string ( ) ,
254+ description : z . string ( ) ,
255+ content : z . string ( ) ,
256+ argumentHint : z . string ( ) . optional ( ) ,
257+ source : z . enum ( [ "user" , "project" ] ) ,
258+ projectPath : z . string ( ) . optional ( ) ,
259+ } )
260+ )
261+ . mutation ( async ( { input } ) => {
262+ const safeName = input . name . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 - ] / g, "-" ) . replace ( / - + / g, "-" ) . replace ( / ^ - | - $ / g, "" )
263+ if ( ! safeName ) {
264+ throw new Error ( "Command name must contain at least one alphanumeric character" )
265+ }
266+
267+ let targetDir : string
268+ if ( input . source === "project" ) {
269+ if ( ! input . projectPath ) {
270+ throw new Error ( "Project path required for project commands" )
271+ }
272+ targetDir = path . join ( input . projectPath , ".claude" , "commands" )
273+ } else {
274+ targetDir = path . join ( os . homedir ( ) , ".claude" , "commands" )
275+ }
276+
277+ const commandPath = path . join ( targetDir , `${ safeName } .md` )
278+
279+ // Check if already exists
280+ try {
281+ await fs . access ( commandPath )
282+ throw new Error ( `Command "${ safeName } " already exists` )
283+ } catch ( err ) {
284+ if ( ( err as NodeJS . ErrnoException ) . code !== "ENOENT" ) {
285+ throw err
286+ }
287+ }
288+
289+ // Create directory and write command file
290+ await fs . mkdir ( targetDir , { recursive : true } )
291+ const fileContent = generateCommandMd ( {
292+ name : safeName ,
293+ description : input . description ,
294+ content : input . content ,
295+ argumentHint : input . argumentHint ,
296+ } )
297+ await fs . writeFile ( commandPath , fileContent , "utf-8" )
298+
299+ return {
300+ name : safeName ,
301+ path : commandPath ,
302+ source : input . source ,
303+ }
304+ } ) ,
305+
306+ update : publicProcedure
307+ . input (
308+ z . object ( {
309+ path : z . string ( ) ,
310+ name : z . string ( ) ,
311+ description : z . string ( ) ,
312+ content : z . string ( ) ,
313+ argumentHint : z . string ( ) . optional ( ) ,
314+ projectPath : z . string ( ) . optional ( ) ,
315+ } )
316+ )
317+ . mutation ( async ( { input } ) => {
318+ // Security: prevent path traversal
319+ if ( input . path . includes ( ".." ) ) {
320+ throw new Error ( "Invalid path" )
321+ }
322+
323+ const absolutePath = resolveCommandPath ( input . path , input . projectPath )
324+
325+ // Verify file exists before writing
326+ await fs . access ( absolutePath )
327+
328+ const fileContent = generateCommandMd ( {
329+ name : input . name ,
330+ description : input . description ,
331+ content : input . content ,
332+ argumentHint : input . argumentHint ,
333+ } )
334+ await fs . writeFile ( absolutePath , fileContent , "utf-8" )
335+
336+ return { success : true }
337+ } ) ,
338+
339+ delete : publicProcedure
340+ . input (
341+ z . object ( {
342+ path : z . string ( ) ,
343+ projectPath : z . string ( ) . optional ( ) ,
344+ } )
345+ )
346+ . mutation ( async ( { input } ) => {
347+ if ( input . path . includes ( ".." ) ) {
348+ throw new Error ( "Invalid path" )
349+ }
350+
351+ const absolutePath = resolveCommandPath ( input . path , input . projectPath )
352+ await fs . access ( absolutePath )
353+ await fs . unlink ( absolutePath )
354+
355+ return { success : true }
356+ } ) ,
200357} )
0 commit comments