@@ -59,6 +59,22 @@ function quote(s: string): string {
5959 return `"${ s . replace ( / " / g, '\\"' ) } "` ;
6060}
6161
62+ /**
63+ * True when a hooks.json entry was written by us. Matches BOTH command
64+ * shapes we have ever emitted:
65+ * - POSIX: "<ext-dir>/bin/axme-code" hook <name> --ide cursor
66+ * - Windows: "%USERPROFILE%\.cursor\axme-hook.cmd" hook <name> --ide cursor
67+ * The old filter matched only "axme-code" — the Windows wrapper path does
68+ * not contain that substring, so every activation APPENDED three fresh
69+ * entries instead of replacing them (N restarts → N× hook fan-out), and
70+ * uninstall could never remove them (after uninstall they pointed at a
71+ * deleted axme-hook.cmd, failing forever).
72+ */
73+ function isAxmeHookEntry ( command : unknown ) : boolean {
74+ const c = String ( command ?? "" ) ;
75+ return c . includes ( "axme-code" ) || c . includes ( "axme-hook.cmd" ) ;
76+ }
77+
6278/**
6379 * Path to the Windows wrapper script. Lives next to hooks.json so a
6480 * single uninstall sweep deletes both. The wrapper is a one-liner .cmd
@@ -81,7 +97,7 @@ function windowsHookWrapperPath(): string {
8197 * worked in theory but proved fragile in practice — Cursor's spawn
8298 * behaviour around that env var is inconsistent, and any Cursor update
8399 * could change it. Now the wrapper points at the Node.exe we ship
84- * ourselves (extension/bin/node-windows-x64 .exe), which is a plain
100+ * ourselves (extension/bin/node-runtime/node .exe), which is a plain
85101 * Node interpreter that just works.
86102 */
87103function writeWindowsHookWrapper ( binary : string ) : string {
@@ -90,7 +106,7 @@ function writeWindowsHookWrapper(binary: string): string {
90106 if ( ! bundledNode ) {
91107 throw new Error (
92108 "AXME Code: cannot install Cursor hooks — bundled Node.exe not " +
93- "found at extension/bin/node-windows-x64 .exe. The .vsix may be " +
109+ "found at extension/bin/node-runtime/node .exe. The .vsix may be " +
94110 "incomplete; please reinstall the extension." ,
95111 ) ;
96112 }
@@ -151,13 +167,21 @@ export function installUserHooks(ide: IdeKind, binary: string): boolean {
151167 const path = userCursorHooksPath ( ) ;
152168 let cfg : CursorHooksFile = { version : 1 , hooks : { } } ;
153169 if ( existsSync ( path ) ) {
154- try {
155- const raw = readFileSync ( path , "utf-8" ) ;
156- const parsed = JSON . parse ( raw ) ;
157- if ( parsed && typeof parsed === "object" ) cfg = parsed as CursorHooksFile ;
158- } catch ( err ) {
159- logError ( `Hooks: existing ${ path } is malformed; will overwrite` , err ) ;
160- cfg = { version : 1 , hooks : { } } ;
170+ const raw = readFileSync ( path , "utf-8" ) ;
171+ if ( raw . trim ( ) ) {
172+ try {
173+ const parsed = JSON . parse ( raw ) ;
174+ if ( parsed && typeof parsed === "object" ) cfg = parsed as CursorHooksFile ;
175+ } catch ( err ) {
176+ // Refuse-don't-clobber: this file can contain the user's OWN hooks.
177+ // Overwriting on a parse error (the old behavior) silently destroyed
178+ // them. Throw instead — runStep() surfaces the message as a visible
179+ // warning with recovery instructions.
180+ throw new Error (
181+ `existing ${ path } is not valid JSON (${ ( err as Error ) . message } ). ` +
182+ `Refusing to overwrite it — fix or remove the file, then reload the window to install AXME hooks.` ,
183+ ) ;
184+ }
161185 }
162186 }
163187 if ( ! cfg . version ) cfg . version = 1 ;
@@ -178,9 +202,7 @@ export function installUserHooks(ide: IdeKind, binary: string): boolean {
178202
179203 for ( const kind of [ "preToolUse" , "postToolUse" , "sessionEnd" ] as HookKind [ ] ) {
180204 const existing = cfg . hooks [ kind ] ?? [ ] ;
181- const preserved = existing . filter (
182- ( e ) => ! String ( e . command ?? "" ) . includes ( "axme-code" ) ,
183- ) ;
205+ const preserved = existing . filter ( ( e ) => ! isAxmeHookEntry ( e . command ) ) ;
184206 const fresh : CursorHookEntry = {
185207 command : buildHookCommand ( binary , cliNames [ kind ] , wrapper ) ,
186208 type : "command" ,
@@ -209,7 +231,7 @@ export function uninstallUserHooks(): void {
209231 for ( const kind of [ "preToolUse" , "postToolUse" , "sessionEnd" ] as HookKind [ ] ) {
210232 const arr = cfg . hooks [ kind ] ;
211233 if ( ! arr ) continue ;
212- const preserved = arr . filter ( ( e ) => ! String ( e . command ?? "" ) . includes ( "axme-code" ) ) ;
234+ const preserved = arr . filter ( ( e ) => ! isAxmeHookEntry ( e . command ) ) ;
213235 if ( preserved . length !== arr . length ) {
214236 cfg . hooks [ kind ] = preserved ;
215237 touched = true ;
0 commit comments