@@ -27,6 +27,47 @@ import type {
2727import { typedHandle } from './typedHandle'
2828import { typedSend } from './typedSend'
2929
30+ type AgentPathRemovalResult =
31+ | { success : true }
32+ | { success : false ; error : string ; code ?: string }
33+
34+ /**
35+ * Remove an agent symlink path after the caller has already validated and
36+ * lstat'd it. Directories are refused here so destructive local-folder removal
37+ * remains isolated to the single-unlink confirmation path.
38+ * @param linkPath - Validated path inside an agent skills directory
39+ * @param stats - `lstat` result for linkPath
40+ * @returns Structured IPC result for renderer toast handling
41+ * @example
42+ * removeLinkPathByKind('/Users/me/.cursor/skills/task', stats)
43+ */
44+ async function removeLinkPathByKind (
45+ linkPath : AbsolutePath ,
46+ stats : Stats ,
47+ ) : Promise < AgentPathRemovalResult > {
48+ return match ( {
49+ isSymlink : stats . isSymbolicLink ( ) ,
50+ isDirectory : stats . isDirectory ( ) ,
51+ } )
52+ . with ( { isSymlink : true } , async ( ) => {
53+ await fs . unlink ( linkPath )
54+ return { success : true } as const
55+ } )
56+ . with (
57+ { isSymlink : false , isDirectory : true } ,
58+ async ( ) =>
59+ ( {
60+ success : false as const ,
61+ error :
62+ 'Cannot unlink a local skill. Use Delete to move it to trash instead.' ,
63+ } ) satisfies AgentPathRemovalResult ,
64+ )
65+ . otherwise ( async ( ) => ( {
66+ success : false as const ,
67+ error : 'Cannot remove: path is neither a symlink nor a directory' ,
68+ } ) )
69+ }
70+
3071/**
3172 * Unlink or remove a single link-path inside an agent directory. Shared by the
3273 * single-unlink handler and the batch-unlink handler so both behave identically.
@@ -72,27 +113,7 @@ async function removeFromAgent(
72113 }
73114
74115 try {
75- // Discriminate on file-stat kind: symlink → unlink, local directory →
76- // refuse (bulk unlink is non-destructive; user must go through bulk
77- // Delete), everything else (files, sockets, etc.) → refuse as unsupported.
78- const kindResult = await match ( {
79- isSymlink : stats . isSymbolicLink ( ) ,
80- isDirectory : stats . isDirectory ( ) ,
81- } )
82- . with ( { isSymlink : true } , async ( ) => {
83- await fs . unlink ( linkPath )
84- return { success : true } as const
85- } )
86- . with ( { isSymlink : false , isDirectory : true } , async ( ) => ( {
87- success : false as const ,
88- error :
89- 'Cannot unlink a local skill. Use Delete to move it to trash instead.' ,
90- } ) )
91- . otherwise ( async ( ) => ( {
92- success : false as const ,
93- error : 'Cannot remove: path is neither a symlink nor a directory' ,
94- } ) )
95- return kindResult
116+ return await removeLinkPathByKind ( linkPath , stats )
96117 } catch ( error ) {
97118 return {
98119 success : false ,
@@ -116,36 +137,42 @@ export function registerSkillsHandlers(): void {
116137 * @returns UnlinkResult with success status and optional error
117138 */
118139 typedHandle ( IPC_CHANNELS . SKILLS_UNLINK_FROM_AGENT , async ( _ , options ) => {
119- const { linkPath } = options
140+ const { linkPath, confirmedLocalDirectoryDelete = false } = options
120141
121142 try {
122143 // Allow agent dirs (for local skills) AND SOURCE_DIR (for symlinked skills).
123144 // validatePath calls realpathSync, which follows the symlink to its source
124145 // in ~/.agents/skills/. Without SOURCE_DIR in the allowed bases, every
125146 // symlinked-skill unlink fails with "Path traversal attempt detected".
126147 validatePath ( linkPath , getAllowedBases ( ) )
127- const stats = await fs . lstat ( linkPath )
128- // Discriminate on file-stat kind: symlink → unlink, directory → rm -rf
129- // (destructive is OK here because the single-unlink handler has its own
130- // confirmation UX in the renderer), else → refuse.
131- const unlinkResult = await match ( {
132- isSymlink : stats . isSymbolicLink ( ) ,
133- isDirectory : stats . isDirectory ( ) ,
134- } )
135- . with ( { isSymlink : true } , async ( ) => {
136- await fs . unlink ( linkPath )
137- return { success : true } as const
138- } )
139- . with ( { isSymlink : false , isDirectory : true } , async ( ) => {
140- await fs . rm ( linkPath , { recursive : true , force : true } )
141- return { success : true } as const
142- } )
143- . otherwise ( async ( ) => ( {
144- success : false as const ,
145- error : 'Cannot remove: path is neither a symlink nor a directory' ,
146- } ) )
148+ let stats : Stats
149+ try {
150+ stats = await fs . lstat ( linkPath )
151+ } catch ( error ) {
152+ if ( errorCode ( error ) === 'ENOENT' ) {
153+ // Already gone — match bulk unlink's idempotent no-op behavior.
154+ return { success : true }
155+ }
156+ throw error
157+ }
158+
159+ if ( stats . isDirectory ( ) && ! stats . isSymbolicLink ( ) ) {
160+ if ( ! confirmedLocalDirectoryDelete ) {
161+ return {
162+ success : false ,
163+ error :
164+ 'Refusing to delete a local skill without explicit confirmation.' ,
165+ }
166+ }
167+
168+ // Local skill deletion arrives from UnlinkDialog's destructive confirm
169+ // action. Move to OS Trash instead of recursively deleting bytes so a
170+ // mistaken confirmation is still recoverable at the filesystem level.
171+ await shell . trashItem ( linkPath )
172+ return { success : true }
173+ }
147174
148- return unlinkResult
175+ return await removeLinkPathByKind ( linkPath , stats )
149176 } catch ( error ) {
150177 return { success : false , error : extractErrorMessage ( error ) }
151178 }
0 commit comments