@ghl-ai/aw 0.1.51 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { accessSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { accessSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { constants as fsConstants } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { delimiter, dirname, join } from 'node:path';
@@ -7,6 +7,14 @@ import TOML from '@iarna/toml';
7
7
 
8
8
  const INTEGRATION_NAME = 'context-mode';
9
9
  const BINARY_NAME = 'context-mode';
10
+ const INSTALL_STATE_RELATIVE_PATH = ['.aw', 'context-mode-install-state.json'];
11
+ const INSTALL_STATE_VERSION = 1;
12
+ const CONTEXT_MODE_MCP_SERVER = { command: BINARY_NAME };
13
+ const MCP_TARGETS = {
14
+ codex: 'codex-config-toml',
15
+ cursor: 'cursor-mcp-json',
16
+ claude: 'claude-json',
17
+ };
10
18
 
11
19
  const CODEX_HOOK_COMMANDS = {
12
20
  PreToolUse: 'context-mode hook codex pretooluse',
@@ -17,12 +25,41 @@ const CODEX_HOOK_COMMANDS = {
17
25
  Stop: 'context-mode hook codex stop',
18
26
  };
19
27
 
20
- const CURSOR_HOOK_COMMANDS = {
21
- preToolUse: 'context-mode hook cursor pretooluse',
22
- postToolUse: 'context-mode hook cursor posttooluse',
23
- stop: 'context-mode hook cursor stop',
28
+ const CODEX_PRE_TOOL_MATCHER = [
29
+ 'local_shell',
30
+ 'shell',
31
+ 'shell_command',
32
+ 'exec_command',
33
+ 'Bash',
34
+ 'Shell',
35
+ 'apply_patch',
36
+ 'Edit',
37
+ 'Write',
38
+ 'grep_files',
39
+ 'ctx_execute',
40
+ 'ctx_execute_file',
41
+ 'ctx_batch_execute',
42
+ 'ctx_fetch_and_index',
43
+ 'ctx_search',
44
+ 'ctx_index',
45
+ 'mcp__',
46
+ ].join('|');
47
+
48
+ const CODEX_HOOK_MATCHERS = {
49
+ PreToolUse: CODEX_PRE_TOOL_MATCHER,
50
+ PostToolUse: '*',
51
+ SessionStart: 'startup|resume',
24
52
  };
25
53
 
54
+ const CURSOR_HOOK_COMMANDS = [
55
+ { event: 'beforeShellExecution', command: 'context-mode hook cursor pretooluse' },
56
+ { event: 'beforeMCPExecution', command: 'context-mode hook cursor pretooluse' },
57
+ { event: 'afterShellExecution', command: 'context-mode hook cursor posttooluse' },
58
+ { event: 'afterFileEdit', command: 'context-mode hook cursor posttooluse' },
59
+ { event: 'afterMCPExecution', command: 'context-mode hook cursor posttooluse' },
60
+ { event: 'stop', command: 'context-mode hook cursor stop' },
61
+ ];
62
+
26
63
  function truthy(value) {
27
64
  return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
28
65
  }
@@ -55,6 +92,60 @@ function cloneJson(value) {
55
92
  return JSON.parse(JSON.stringify(value ?? {}));
56
93
  }
57
94
 
95
+ function contextModeInstallStatePath(home) {
96
+ return join(home, ...INSTALL_STATE_RELATIVE_PATH);
97
+ }
98
+
99
+ function emptyInstallState() {
100
+ return { version: INSTALL_STATE_VERSION, mcpServers: [] };
101
+ }
102
+
103
+ function normalizeInstallState(value) {
104
+ const state = value && typeof value === 'object' && !Array.isArray(value)
105
+ ? value
106
+ : {};
107
+ return {
108
+ version: INSTALL_STATE_VERSION,
109
+ mcpServers: Array.isArray(state.mcpServers)
110
+ ? unique(state.mcpServers.filter(item => typeof item === 'string')).sort()
111
+ : [],
112
+ };
113
+ }
114
+
115
+ function readInstallState(home) {
116
+ const filePath = contextModeInstallStatePath(home);
117
+ if (!existsSync(filePath)) return emptyInstallState();
118
+ return normalizeInstallState(readJson(filePath, emptyInstallState()));
119
+ }
120
+
121
+ function writeInstallStateIfChanged(home, state, dryRun = false) {
122
+ const filePath = contextModeInstallStatePath(home);
123
+ const nextState = normalizeInstallState(state);
124
+ if (nextState.mcpServers.length === 0) {
125
+ if (!existsSync(filePath)) return false;
126
+ if (!dryRun) rmSync(filePath, { force: true });
127
+ return true;
128
+ }
129
+
130
+ return writeJsonIfChanged(filePath, nextState, dryRun);
131
+ }
132
+
133
+ function ownsMcpTarget(installState, target) {
134
+ return installState.mcpServers.includes(target);
135
+ }
136
+
137
+ function markMcpTargetOwned(installState, target) {
138
+ installState.mcpServers = unique([...installState.mcpServers, target]).sort();
139
+ }
140
+
141
+ function unmarkMcpTargetOwned(installState, target) {
142
+ installState.mcpServers = installState.mcpServers.filter(item => item !== target);
143
+ }
144
+
145
+ function isDesiredMcpServer(value) {
146
+ return JSON.stringify(value) === JSON.stringify(CONTEXT_MODE_MCP_SERVER);
147
+ }
148
+
58
149
  function parseTomlConfig(filePath, warnings) {
59
150
  if (!existsSync(filePath)) return {};
60
151
  try {
@@ -102,6 +193,31 @@ function isContextModeCommand(command) {
102
193
  return String(command || '').startsWith('context-mode hook ');
103
194
  }
104
195
 
196
+ function hasHookCommand(entry, command) {
197
+ return Array.isArray(entry?.hooks)
198
+ && entry.hooks.some(hook => hook?.command === command);
199
+ }
200
+
201
+ function hasCanonicalCodexMatcher(entry, phase) {
202
+ const expectedMatcher = CODEX_HOOK_MATCHERS[phase];
203
+ return expectedMatcher === undefined
204
+ ? entry?.matcher === undefined
205
+ : entry?.matcher === expectedMatcher;
206
+ }
207
+
208
+ function isCanonicalCodexHookEntry(entry, phase, command) {
209
+ return hasCanonicalCodexMatcher(entry, phase) && hasHookCommand(entry, command);
210
+ }
211
+
212
+ function buildCodexHookEntry(phase, command) {
213
+ const entry = {
214
+ hooks: [{ type: 'command', command }],
215
+ };
216
+ const matcher = CODEX_HOOK_MATCHERS[phase];
217
+ if (matcher !== undefined) entry.matcher = matcher;
218
+ return entry;
219
+ }
220
+
105
221
  function ensureObject(parent, key) {
106
222
  if (!parent[key] || typeof parent[key] !== 'object' || Array.isArray(parent[key])) {
107
223
  parent[key] = {};
@@ -109,7 +225,7 @@ function ensureObject(parent, key) {
109
225
  return parent[key];
110
226
  }
111
227
 
112
- function mergeJsonMcpServer(filePath, dryRun = false) {
228
+ function mergeJsonMcpServer(filePath, installState, target, dryRun = false) {
113
229
  const config = readJson(filePath, {});
114
230
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
115
231
  throw new Error(`Expected JSON object in ${filePath}`);
@@ -117,26 +233,47 @@ function mergeJsonMcpServer(filePath, dryRun = false) {
117
233
 
118
234
  const mcpServers = ensureObject(config, 'mcpServers');
119
235
  const existing = mcpServers[INTEGRATION_NAME];
120
- const desired = { command: BINARY_NAME };
121
- if (JSON.stringify(existing) === JSON.stringify(desired)) return false;
236
+ if (existing && !isDesiredMcpServer(existing)) {
237
+ throw new Error('Refusing to overwrite existing user-owned context-mode MCP config');
238
+ }
239
+ if (existing) return false;
122
240
 
123
- mcpServers[INTEGRATION_NAME] = desired;
241
+ mcpServers[INTEGRATION_NAME] = cloneJson(CONTEXT_MODE_MCP_SERVER);
242
+ markMcpTargetOwned(installState, target);
124
243
  return writeJsonIfChanged(filePath, config, dryRun);
125
244
  }
126
245
 
127
- function removeJsonMcpServer(filePath, dryRun = false) {
128
- if (!existsSync(filePath)) return false;
246
+ function removeJsonMcpServer(filePath, installState, target, warnings, dryRun = false) {
247
+ if (!ownsMcpTarget(installState, target)) return false;
248
+ if (!existsSync(filePath)) {
249
+ unmarkMcpTargetOwned(installState, target);
250
+ return false;
251
+ }
129
252
  const config = readJson(filePath, {});
130
- if (!config?.mcpServers?.[INTEGRATION_NAME]) return false;
253
+ const existing = config?.mcpServers?.[INTEGRATION_NAME];
254
+ if (!existing) {
255
+ unmarkMcpTargetOwned(installState, target);
256
+ return false;
257
+ }
258
+ if (!isDesiredMcpServer(existing)) {
259
+ warnings.push(`Preserved user-modified context-mode MCP config at ${filePath}`);
260
+ unmarkMcpTargetOwned(installState, target);
261
+ return false;
262
+ }
131
263
 
132
264
  delete config.mcpServers[INTEGRATION_NAME];
133
265
  if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
266
+ unmarkMcpTargetOwned(installState, target);
134
267
  return writeJsonIfChanged(filePath, config, dryRun);
135
268
  }
136
269
 
137
- function mergeCodexToml(filePath, warnings, dryRun = false) {
270
+ function mergeCodexToml(filePath, warnings, installState, target, dryRun = false) {
138
271
  const config = parseTomlConfig(filePath, warnings);
139
272
  if (config === null) return false;
273
+ const existing = config.mcp_servers?.[INTEGRATION_NAME];
274
+ if (existing && !isDesiredMcpServer(existing)) {
275
+ throw new Error('Refusing to overwrite existing user-owned context-mode MCP config');
276
+ }
140
277
 
141
278
  const before = JSON.stringify(config);
142
279
  config.features = config.features && typeof config.features === 'object' && !Array.isArray(config.features)
@@ -148,20 +285,37 @@ function mergeCodexToml(filePath, warnings, dryRun = false) {
148
285
  config.mcp_servers = config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)
149
286
  ? config.mcp_servers
150
287
  : {};
151
- config.mcp_servers[INTEGRATION_NAME] = { command: BINARY_NAME };
288
+ if (!existing) {
289
+ config.mcp_servers[INTEGRATION_NAME] = cloneJson(CONTEXT_MODE_MCP_SERVER);
290
+ markMcpTargetOwned(installState, target);
291
+ }
152
292
 
153
293
  if (JSON.stringify(config) === before) return false;
154
294
  return writeTomlIfChanged(filePath, config, dryRun);
155
295
  }
156
296
 
157
- function removeCodexToml(filePath, warnings, dryRun = false) {
158
- if (!existsSync(filePath)) return false;
297
+ function removeCodexToml(filePath, warnings, installState, target, dryRun = false) {
298
+ if (!ownsMcpTarget(installState, target)) return false;
299
+ if (!existsSync(filePath)) {
300
+ unmarkMcpTargetOwned(installState, target);
301
+ return false;
302
+ }
159
303
  const config = parseTomlConfig(filePath, warnings);
160
304
  if (config === null) return false;
161
- if (!config.mcp_servers?.[INTEGRATION_NAME]) return false;
305
+ const existing = config.mcp_servers?.[INTEGRATION_NAME];
306
+ if (!existing) {
307
+ unmarkMcpTargetOwned(installState, target);
308
+ return false;
309
+ }
310
+ if (!isDesiredMcpServer(existing)) {
311
+ warnings.push(`Preserved user-modified context-mode MCP config at ${filePath}`);
312
+ unmarkMcpTargetOwned(installState, target);
313
+ return false;
314
+ }
162
315
 
163
316
  delete config.mcp_servers[INTEGRATION_NAME];
164
317
  if (Object.keys(config.mcp_servers).length === 0) delete config.mcp_servers;
318
+ unmarkMcpTargetOwned(installState, target);
165
319
  return writeTomlIfChanged(filePath, config, dryRun);
166
320
  }
167
321
 
@@ -170,23 +324,23 @@ function ensureCodexHook(config, phase, command) {
170
324
  ? config.hooks
171
325
  : {};
172
326
  const entries = Array.isArray(config.hooks[phase]) ? config.hooks[phase] : [];
173
- if (entries.some(entry => Array.isArray(entry?.hooks) && entry.hooks.some(hook => hook?.command === command))) {
174
- config.hooks[phase] = entries;
175
- return false;
176
- }
327
+ const before = JSON.stringify(entries);
328
+ const nextEntries = [];
177
329
 
178
- const target = entries.find(entry => Array.isArray(entry?.hooks));
179
- const hook = { type: 'command', command };
180
- if (target) {
181
- target.hooks.push(hook);
182
- } else {
183
- const entry = { hooks: [hook] };
184
- if (phase === 'SessionStart') entry.matcher = 'startup|resume';
185
- if (phase === 'PreToolUse' || phase === 'PostToolUse') entry.matcher = '*';
186
- entries.push(entry);
330
+ for (const entry of entries) {
331
+ if (!Array.isArray(entry?.hooks)) {
332
+ nextEntries.push(entry);
333
+ continue;
334
+ }
335
+
336
+ const nextHooks = entry.hooks.filter(hook => hook?.command !== command);
337
+ if (nextHooks.length === 0) continue;
338
+ nextEntries.push(nextHooks.length === entry.hooks.length ? entry : { ...entry, hooks: nextHooks });
187
339
  }
188
- config.hooks[phase] = entries;
189
- return true;
340
+
341
+ nextEntries.push(buildCodexHookEntry(phase, command));
342
+ config.hooks[phase] = nextEntries;
343
+ return JSON.stringify(nextEntries) !== before;
190
344
  }
191
345
 
192
346
  function mergeCodexHooks(filePath, dryRun = false) {
@@ -229,18 +383,13 @@ function removeCodexHooks(filePath, dryRun = false) {
229
383
  return writeJsonIfChanged(filePath, config, dryRun);
230
384
  }
231
385
 
232
- function ensureCursorHook(config, phase, command) {
386
+ function ensureCursorHook(config, event, command) {
233
387
  config.hooks = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks)
234
388
  ? config.hooks
235
389
  : {};
236
- const entries = Array.isArray(config.hooks[phase]) ? config.hooks[phase] : [];
237
- if (entries.some(entry => entry?.command === command)) {
238
- config.hooks[phase] = entries;
239
- return false;
240
- }
241
- entries.push({ command });
242
- config.hooks[phase] = entries;
243
- return true;
390
+ const entries = Array.isArray(config.hooks[event]) ? config.hooks[event] : [];
391
+ entries.push({ command, event });
392
+ config.hooks[event] = entries;
244
393
  }
245
394
 
246
395
  function mergeCursorHooks(filePath, dryRun = false) {
@@ -252,12 +401,24 @@ function mergeCursorHooks(filePath, dryRun = false) {
252
401
  let changed = false;
253
402
  if (config.version === undefined) {
254
403
  config.version = 1;
255
- changed = true;
256
404
  }
257
- for (const [phase, command] of Object.entries(CURSOR_HOOK_COMMANDS)) {
258
- changed = ensureCursorHook(config, phase, command) || changed;
405
+ config.hooks = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks)
406
+ ? config.hooks
407
+ : {};
408
+
409
+ for (const phase of Object.keys(config.hooks)) {
410
+ if (!Array.isArray(config.hooks[phase])) continue;
411
+ const nextEntries = config.hooks[phase].filter(entry => !isContextModeCommand(entry?.command));
412
+ if (nextEntries.length > 0) config.hooks[phase] = nextEntries;
413
+ else delete config.hooks[phase];
259
414
  }
260
- return changed && writeJsonIfChanged(filePath, config, dryRun);
415
+
416
+ for (const { event, command } of CURSOR_HOOK_COMMANDS) {
417
+ ensureCursorHook(config, event, command);
418
+ }
419
+
420
+ changed = writeJsonIfChanged(filePath, config, dryRun);
421
+ return changed;
261
422
  }
262
423
 
263
424
  function runConfigMutation(label, filePath, mutate, warnings) {
@@ -313,9 +474,7 @@ function hasCodexHookCoverage(filePath) {
313
474
  const config = readJson(filePath, {});
314
475
  return Object.entries(CODEX_HOOK_COMMANDS).every(([phase, command]) =>
315
476
  Array.isArray(config.hooks?.[phase])
316
- && config.hooks[phase].some(entry =>
317
- Array.isArray(entry?.hooks) && entry.hooks.some(hook => hook?.command === command)
318
- )
477
+ && config.hooks[phase].some(entry => isCanonicalCodexHookEntry(entry, phase, command))
319
478
  );
320
479
  } catch {
321
480
  return false;
@@ -326,9 +485,9 @@ function hasCursorHookCoverage(filePath) {
326
485
  if (!existsSync(filePath)) return false;
327
486
  try {
328
487
  const config = readJson(filePath, {});
329
- return Object.entries(CURSOR_HOOK_COMMANDS).every(([phase, command]) =>
330
- Array.isArray(config.hooks?.[phase])
331
- && config.hooks[phase].some(entry => entry?.command === command)
488
+ return CURSOR_HOOK_COMMANDS.every(({ event, command }) =>
489
+ Array.isArray(config.hooks?.[event])
490
+ && config.hooks[event].some(entry => entry?.command === command && entry?.event === event)
332
491
  );
333
492
  } catch {
334
493
  return false;
@@ -384,6 +543,17 @@ export function detectContextModeBinary(options = {}) {
384
543
  });
385
544
  const versionOutput = `${versionResult.stdout || ''}${versionResult.stderr || ''}`.trim();
386
545
 
546
+ if (versionResult.error || versionResult.status !== 0 || versionResult.signal) {
547
+ return {
548
+ present: false,
549
+ path: binaryPath,
550
+ version: null,
551
+ reason: versionOutput
552
+ || versionResult.error?.message
553
+ || (versionResult.signal ? `--version terminated by ${versionResult.signal}` : `--version exited with status ${versionResult.status ?? 'unknown'}`),
554
+ };
555
+ }
556
+
387
557
  return {
388
558
  present: true,
389
559
  path: binaryPath,
@@ -398,6 +568,7 @@ export function ensureContextModeIntegration(home, options = {}) {
398
568
  const warnings = [];
399
569
  const changedFiles = [];
400
570
  const configuredHarnesses = [];
571
+ const installState = readInstallState(home);
401
572
  const binary = detectContextModeBinary({ env });
402
573
 
403
574
  if (!binary.present) {
@@ -411,11 +582,11 @@ export function ensureContextModeIntegration(home, options = {}) {
411
582
  }
412
583
 
413
584
  const updates = [
414
- ['Codex MCP', join(home, '.codex', 'config.toml'), () => mergeCodexToml(join(home, '.codex', 'config.toml'), warnings, dryRun)],
585
+ ['Codex MCP', join(home, '.codex', 'config.toml'), () => mergeCodexToml(join(home, '.codex', 'config.toml'), warnings, installState, MCP_TARGETS.codex, dryRun)],
415
586
  ['Codex hooks', join(home, '.codex', 'hooks.json'), () => mergeCodexHooks(join(home, '.codex', 'hooks.json'), dryRun)],
416
- ['Cursor MCP', join(home, '.cursor', 'mcp.json'), () => mergeJsonMcpServer(join(home, '.cursor', 'mcp.json'), dryRun)],
587
+ ['Cursor MCP', join(home, '.cursor', 'mcp.json'), () => mergeJsonMcpServer(join(home, '.cursor', 'mcp.json'), installState, MCP_TARGETS.cursor, dryRun)],
417
588
  ['Cursor hooks', join(home, '.cursor', 'hooks.json'), () => mergeCursorHooks(join(home, '.cursor', 'hooks.json'), dryRun)],
418
- ['Claude MCP', join(home, '.claude.json'), () => mergeJsonMcpServer(join(home, '.claude.json'), dryRun)],
589
+ ['Claude MCP', join(home, '.claude.json'), () => mergeJsonMcpServer(join(home, '.claude.json'), installState, MCP_TARGETS.claude, dryRun)],
419
590
  ];
420
591
 
421
592
  for (const [label, filePath, update] of updates) {
@@ -424,6 +595,10 @@ export function ensureContextModeIntegration(home, options = {}) {
424
595
  configuredHarnesses.push(label);
425
596
  }
426
597
  }
598
+ const statePath = contextModeInstallStatePath(home);
599
+ if (writeInstallStateIfChanged(home, installState, dryRun)) {
600
+ changedFiles.push(statePath);
601
+ }
427
602
 
428
603
  return {
429
604
  changedFiles: unique(changedFiles),
@@ -438,18 +613,23 @@ export function removeContextModeIntegration(home, options = {}) {
438
613
  const dryRun = options.dryRun === true;
439
614
  const warnings = [];
440
615
  const changedFiles = [];
616
+ const installState = readInstallState(home);
441
617
 
442
618
  const removals = [
443
- ['Codex MCP', join(home, '.codex', 'config.toml'), () => removeCodexToml(join(home, '.codex', 'config.toml'), warnings, dryRun)],
619
+ ['Codex MCP', join(home, '.codex', 'config.toml'), () => removeCodexToml(join(home, '.codex', 'config.toml'), warnings, installState, MCP_TARGETS.codex, dryRun)],
444
620
  ['Codex hooks', join(home, '.codex', 'hooks.json'), () => removeCodexHooks(join(home, '.codex', 'hooks.json'), dryRun)],
445
- ['Cursor MCP', join(home, '.cursor', 'mcp.json'), () => removeJsonMcpServer(join(home, '.cursor', 'mcp.json'), dryRun)],
621
+ ['Cursor MCP', join(home, '.cursor', 'mcp.json'), () => removeJsonMcpServer(join(home, '.cursor', 'mcp.json'), installState, MCP_TARGETS.cursor, warnings, dryRun)],
446
622
  ['Cursor hooks', join(home, '.cursor', 'hooks.json'), () => removeCursorHooks(join(home, '.cursor', 'hooks.json'), dryRun)],
447
- ['Claude MCP', join(home, '.claude.json'), () => removeJsonMcpServer(join(home, '.claude.json'), dryRun)],
623
+ ['Claude MCP', join(home, '.claude.json'), () => removeJsonMcpServer(join(home, '.claude.json'), installState, MCP_TARGETS.claude, warnings, dryRun)],
448
624
  ];
449
625
 
450
626
  for (const [label, filePath, remove] of removals) {
451
627
  if (runConfigMutation(label, filePath, remove, warnings)) changedFiles.push(filePath);
452
628
  }
629
+ const statePath = contextModeInstallStatePath(home);
630
+ if (writeInstallStateIfChanged(home, installState, dryRun)) {
631
+ changedFiles.push(statePath);
632
+ }
453
633
 
454
634
  return {
455
635
  changedFiles: unique(changedFiles),