@ghl-ai/aw 0.1.56 → 0.1.57

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,64 +1,67 @@
1
- import { accessSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { accessSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { constants as fsConstants } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { delimiter, dirname, join } from 'node:path';
4
+ import { delimiter, dirname, isAbsolute, join } from 'node:path';
5
5
 
6
6
  import TOML from '@iarna/toml';
7
+ import {
8
+ buildGlobalPackageInstall,
9
+ formatCommand,
10
+ getNpmPrefixBin,
11
+ installGlobalPackage,
12
+ } from '../package-manager.mjs';
7
13
 
8
14
  const INTEGRATION_NAME = 'context-mode';
9
15
  const BINARY_NAME = 'context-mode';
16
+ const PACKAGE_SPEC = 'context-mode@latest';
10
17
  const INSTALL_STATE_RELATIVE_PATH = ['.aw', 'context-mode-install-state.json'];
11
18
  const INSTALL_STATE_VERSION = 1;
12
- const CONTEXT_MODE_MCP_SERVER = { command: BINARY_NAME };
13
19
  const MCP_TARGETS = {
14
20
  codex: 'codex-config-toml',
15
21
  cursor: 'cursor-mcp-json',
16
22
  claude: 'claude-json',
17
23
  };
18
24
 
19
- const CODEX_HOOK_COMMANDS = {
20
- PreToolUse: 'context-mode hook codex pretooluse',
21
- PostToolUse: 'context-mode hook codex posttooluse',
22
- SessionStart: 'context-mode hook codex sessionstart',
23
- PreCompact: 'context-mode hook codex precompact',
24
- UserPromptSubmit: 'context-mode hook codex userpromptsubmit',
25
- Stop: 'context-mode hook codex stop',
26
- };
25
+ function shellQuote(value) {
26
+ const text = String(value);
27
+ if (!isAbsolute(text)) return text;
28
+ if (process.platform === 'win32') return `"${text.replace(/"/g, '\\"')}"`;
29
+ return `'${text.replace(/'/g, `'\\''`)}'`;
30
+ }
27
31
 
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',
52
- };
32
+ function hookCommand(commandTarget, harness, phase) {
33
+ return `${shellQuote(commandTarget)} hook ${harness} ${phase}`;
34
+ }
53
35
 
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
- ];
36
+ function codexHookCommands(commandTarget = BINARY_NAME) {
37
+ return {
38
+ PreToolUse: hookCommand(commandTarget, 'codex', 'pretooluse'),
39
+ PostToolUse: hookCommand(commandTarget, 'codex', 'posttooluse'),
40
+ SessionStart: hookCommand(commandTarget, 'codex', 'sessionstart'),
41
+ PreCompact: hookCommand(commandTarget, 'codex', 'precompact'),
42
+ UserPromptSubmit: hookCommand(commandTarget, 'codex', 'userpromptsubmit'),
43
+ Stop: hookCommand(commandTarget, 'codex', 'stop'),
44
+ };
45
+ }
46
+
47
+ function codexHookMatcherForPhase(phase) {
48
+ if (phase === 'SessionStart') return 'startup|resume';
49
+ if (phase === 'PreToolUse' || phase === 'PostToolUse') return '*';
50
+ return undefined;
51
+ }
52
+
53
+ function cursorHookCommands(commandTarget = BINARY_NAME) {
54
+ const preToolUse = hookCommand(commandTarget, 'cursor', 'pretooluse');
55
+ const postToolUse = hookCommand(commandTarget, 'cursor', 'posttooluse');
56
+ return [
57
+ { event: 'beforeShellExecution', command: preToolUse },
58
+ { event: 'beforeMCPExecution', command: preToolUse },
59
+ { event: 'afterShellExecution', command: postToolUse },
60
+ { event: 'afterFileEdit', command: postToolUse },
61
+ { event: 'afterMCPExecution', command: postToolUse },
62
+ { event: 'stop', command: hookCommand(commandTarget, 'cursor', 'stop') },
63
+ ];
64
+ }
62
65
 
63
66
  function truthy(value) {
64
67
  return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
@@ -142,8 +145,16 @@ function unmarkMcpTargetOwned(installState, target) {
142
145
  installState.mcpServers = installState.mcpServers.filter(item => item !== target);
143
146
  }
144
147
 
148
+ function mcpServerConfig(commandTarget = BINARY_NAME) {
149
+ return { command: commandTarget };
150
+ }
151
+
145
152
  function isDesiredMcpServer(value) {
146
- return JSON.stringify(value) === JSON.stringify(CONTEXT_MODE_MCP_SERVER);
153
+ return value
154
+ && typeof value === 'object'
155
+ && !Array.isArray(value)
156
+ && Object.keys(value).length === 1
157
+ && isContextModeMcpCommand(value.command);
147
158
  }
148
159
 
149
160
  function parseTomlConfig(filePath, warnings) {
@@ -189,8 +200,108 @@ function findExecutable(binaryName, env = process.env) {
189
200
  return null;
190
201
  }
191
202
 
203
+ function findExecutableInDirs(binaryName, dirs = []) {
204
+ for (const dir of unique(dirs.filter(Boolean))) {
205
+ for (const candidate of executableCandidates(binaryName)) {
206
+ const fullPath = join(dir, candidate);
207
+ try {
208
+ accessSync(fullPath, fsConstants.X_OK);
209
+ return fullPath;
210
+ } catch {
211
+ // Keep scanning known package-manager directories.
212
+ }
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function childDirs(parent) {
219
+ try {
220
+ return readdirSync(parent, { withFileTypes: true })
221
+ .filter(entry => entry.isDirectory())
222
+ .map(entry => join(parent, entry.name));
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ function readManifestBinaryPath(home) {
229
+ const manifestPath = join(home, '.aw_registry', '.integration-manifest.json');
230
+ if (!existsSync(manifestPath)) return null;
231
+ try {
232
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
233
+ return manifest.installed?.[INTEGRATION_NAME]?.binaryPath || null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function knownExecutableDirs(home, env = process.env, options = {}) {
240
+ const dirs = [];
241
+ const skipSystemPrefixes = env.AW_CONTEXT_MODE_SKIP_SYSTEM_PREFIXES === '1';
242
+
243
+ const manifestPath = readManifestBinaryPath(home);
244
+ if (manifestPath) dirs.push(dirname(manifestPath));
245
+
246
+ if (Array.isArray(options.extraSearchDirs)) dirs.push(...options.extraSearchDirs);
247
+
248
+ const npmPrefixBin = options.npmPrefixBin ?? getNpmPrefixBin({ env });
249
+ if (npmPrefixBin) dirs.push(npmPrefixBin);
250
+
251
+ dirs.push(
252
+ join(home, '.volta', 'bin'),
253
+ join(home, '.local', 'share', 'pnpm'),
254
+ );
255
+
256
+ const voltaPackages = join(home, '.volta', 'tools', 'image', 'packages');
257
+ dirs.push(
258
+ join(voltaPackages, 'context-mode', 'bin'),
259
+ join(voltaPackages, 'context-mode', 'lib', 'node_modules', 'context-mode'),
260
+ );
261
+
262
+ for (const nodeDir of childDirs(join(home, '.volta', 'tools', 'image', 'node'))) {
263
+ dirs.push(join(nodeDir, 'bin'));
264
+ }
265
+
266
+ for (const nodeDir of childDirs(join(home, '.local', 'share', 'mise', 'installs', 'node'))) {
267
+ dirs.push(join(nodeDir, 'bin'));
268
+ }
269
+
270
+ if (env.PNPM_HOME) dirs.push(env.PNPM_HOME);
271
+
272
+ if (process.platform === 'darwin' && !skipSystemPrefixes) {
273
+ dirs.push('/opt/homebrew/bin', '/usr/local/bin');
274
+ }
275
+
276
+ return unique(dirs);
277
+ }
278
+
279
+ function commandTargetForBinary(binary) {
280
+ return binary.source === 'path' ? BINARY_NAME : binary.path;
281
+ }
282
+
192
283
  function isContextModeCommand(command) {
193
- return String(command || '').startsWith('context-mode hook ');
284
+ const text = String(command || '').trim();
285
+ if (text.startsWith('context-mode hook ')) return true;
286
+ return /(^|[/\\'" ])context-mode(?:\.cmd|\.exe)?['"]? hook /.test(text);
287
+ }
288
+
289
+ function isContextModeMcpCommand(command) {
290
+ const text = String(command || '').trim();
291
+ if (text === BINARY_NAME) return true;
292
+ const commandName = text.replace(/^['"]|['"]$/g, '').split(/[/\\]/).pop().toLowerCase();
293
+ return commandName === BINARY_NAME
294
+ || commandName === `${BINARY_NAME}.cmd`
295
+ || commandName === `${BINARY_NAME}.exe`;
296
+ }
297
+
298
+ function normalizeCommandTarget(command) {
299
+ return String(command || '').trim().replace(/^['"]|['"]$/g, '');
300
+ }
301
+
302
+ function isExpectedMcpServer(value, expectedCommand) {
303
+ return isDesiredMcpServer(value)
304
+ && normalizeCommandTarget(value.command) === normalizeCommandTarget(expectedCommand);
194
305
  }
195
306
 
196
307
  function hasHookCommand(entry, command) {
@@ -199,7 +310,7 @@ function hasHookCommand(entry, command) {
199
310
  }
200
311
 
201
312
  function hasCanonicalCodexMatcher(entry, phase) {
202
- const expectedMatcher = CODEX_HOOK_MATCHERS[phase];
313
+ const expectedMatcher = codexHookMatcherForPhase(phase);
203
314
  return expectedMatcher === undefined
204
315
  ? entry?.matcher === undefined
205
316
  : entry?.matcher === expectedMatcher;
@@ -213,7 +324,7 @@ function buildCodexHookEntry(phase, command) {
213
324
  const entry = {
214
325
  hooks: [{ type: 'command', command }],
215
326
  };
216
- const matcher = CODEX_HOOK_MATCHERS[phase];
327
+ const matcher = codexHookMatcherForPhase(phase);
217
328
  if (matcher !== undefined) entry.matcher = matcher;
218
329
  return entry;
219
330
  }
@@ -225,7 +336,7 @@ function ensureObject(parent, key) {
225
336
  return parent[key];
226
337
  }
227
338
 
228
- function mergeJsonMcpServer(filePath, installState, target, dryRun = false) {
339
+ function mergeJsonMcpServer(filePath, installState, target, commandTarget = BINARY_NAME, dryRun = false) {
229
340
  const config = readJson(filePath, {});
230
341
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
231
342
  throw new Error(`Expected JSON object in ${filePath}`);
@@ -233,12 +344,16 @@ function mergeJsonMcpServer(filePath, installState, target, dryRun = false) {
233
344
 
234
345
  const mcpServers = ensureObject(config, 'mcpServers');
235
346
  const existing = mcpServers[INTEGRATION_NAME];
347
+ const desired = mcpServerConfig(commandTarget);
236
348
  if (existing && !isDesiredMcpServer(existing)) {
237
349
  throw new Error('Refusing to overwrite existing user-owned context-mode MCP config');
238
350
  }
239
- if (existing) return false;
351
+ if (existing && JSON.stringify(existing) === JSON.stringify(desired)) {
352
+ markMcpTargetOwned(installState, target);
353
+ return false;
354
+ }
240
355
 
241
- mcpServers[INTEGRATION_NAME] = cloneJson(CONTEXT_MODE_MCP_SERVER);
356
+ mcpServers[INTEGRATION_NAME] = desired;
242
357
  markMcpTargetOwned(installState, target);
243
358
  return writeJsonIfChanged(filePath, config, dryRun);
244
359
  }
@@ -267,7 +382,7 @@ function removeJsonMcpServer(filePath, installState, target, warnings, dryRun =
267
382
  return writeJsonIfChanged(filePath, config, dryRun);
268
383
  }
269
384
 
270
- function mergeCodexToml(filePath, warnings, installState, target, dryRun = false) {
385
+ function mergeCodexToml(filePath, warnings, installState, target, commandTarget = BINARY_NAME, dryRun = false) {
271
386
  const config = parseTomlConfig(filePath, warnings);
272
387
  if (config === null) return false;
273
388
  const existing = config.mcp_servers?.[INTEGRATION_NAME];
@@ -285,10 +400,8 @@ function mergeCodexToml(filePath, warnings, installState, target, dryRun = false
285
400
  config.mcp_servers = config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)
286
401
  ? config.mcp_servers
287
402
  : {};
288
- if (!existing) {
289
- config.mcp_servers[INTEGRATION_NAME] = cloneJson(CONTEXT_MODE_MCP_SERVER);
290
- markMcpTargetOwned(installState, target);
291
- }
403
+ config.mcp_servers[INTEGRATION_NAME] = mcpServerConfig(commandTarget);
404
+ markMcpTargetOwned(installState, target);
292
405
 
293
406
  if (JSON.stringify(config) === before) return false;
294
407
  return writeTomlIfChanged(filePath, config, dryRun);
@@ -333,7 +446,7 @@ function ensureCodexHook(config, phase, command) {
333
446
  continue;
334
447
  }
335
448
 
336
- const nextHooks = entry.hooks.filter(hook => hook?.command !== command);
449
+ const nextHooks = entry.hooks.filter(hook => !isContextModeCommand(hook?.command));
337
450
  if (nextHooks.length === 0) continue;
338
451
  nextEntries.push(nextHooks.length === entry.hooks.length ? entry : { ...entry, hooks: nextHooks });
339
452
  }
@@ -343,14 +456,14 @@ function ensureCodexHook(config, phase, command) {
343
456
  return JSON.stringify(nextEntries) !== before;
344
457
  }
345
458
 
346
- function mergeCodexHooks(filePath, dryRun = false) {
459
+ function mergeCodexHooks(filePath, commandTarget = BINARY_NAME, dryRun = false) {
347
460
  const config = readJson(filePath, {});
348
461
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
349
462
  throw new Error(`Expected JSON object in ${filePath}`);
350
463
  }
351
464
 
352
465
  let changed = false;
353
- for (const [phase, command] of Object.entries(CODEX_HOOK_COMMANDS)) {
466
+ for (const [phase, command] of Object.entries(codexHookCommands(commandTarget))) {
354
467
  changed = ensureCodexHook(config, phase, command) || changed;
355
468
  }
356
469
  return changed && writeJsonIfChanged(filePath, config, dryRun);
@@ -392,7 +505,7 @@ function ensureCursorHook(config, event, command) {
392
505
  config.hooks[event] = entries;
393
506
  }
394
507
 
395
- function mergeCursorHooks(filePath, dryRun = false) {
508
+ function mergeCursorHooks(filePath, commandTarget = BINARY_NAME, dryRun = false) {
396
509
  const config = readJson(filePath, {});
397
510
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
398
511
  throw new Error(`Expected JSON object in ${filePath}`);
@@ -413,7 +526,7 @@ function mergeCursorHooks(filePath, dryRun = false) {
413
526
  else delete config.hooks[phase];
414
527
  }
415
528
 
416
- for (const { event, command } of CURSOR_HOOK_COMMANDS) {
529
+ for (const { event, command } of cursorHookCommands(commandTarget)) {
417
530
  ensureCursorHook(config, event, command);
418
531
  }
419
532
 
@@ -449,45 +562,61 @@ function removeCursorHooks(filePath, dryRun = false) {
449
562
  return writeJsonIfChanged(filePath, config, dryRun);
450
563
  }
451
564
 
452
- function hasJsonMcpServer(filePath) {
565
+ function hasJsonMcpServer(filePath, expectedCommand = null) {
453
566
  if (!existsSync(filePath)) return false;
454
567
  try {
455
- return readJson(filePath, {}).mcpServers?.[INTEGRATION_NAME]?.command === BINARY_NAME;
568
+ const server = readJson(filePath, {}).mcpServers?.[INTEGRATION_NAME];
569
+ return expectedCommand
570
+ ? isExpectedMcpServer(server, expectedCommand)
571
+ : isContextModeMcpCommand(server?.command);
456
572
  } catch {
457
573
  return false;
458
574
  }
459
575
  }
460
576
 
461
- function hasCodexMcpServer(filePath) {
577
+ function hasCodexMcpServer(filePath, expectedCommand = null) {
462
578
  if (!existsSync(filePath)) return false;
463
579
  try {
464
580
  const config = TOML.parse(readFileSync(filePath, 'utf8'));
465
- return config.mcp_servers?.[INTEGRATION_NAME]?.command === BINARY_NAME;
581
+ const server = config.mcp_servers?.[INTEGRATION_NAME];
582
+ return expectedCommand
583
+ ? isExpectedMcpServer(server, expectedCommand)
584
+ : isContextModeMcpCommand(server?.command);
466
585
  } catch {
467
586
  return false;
468
587
  }
469
588
  }
470
589
 
471
- function hasCodexHookCoverage(filePath) {
590
+ function hasCodexHookCoverage(filePath, commandTarget = null) {
472
591
  if (!existsSync(filePath)) return false;
473
592
  try {
474
593
  const config = readJson(filePath, {});
475
- return Object.entries(CODEX_HOOK_COMMANDS).every(([phase, command]) =>
594
+ const expectedCommands = commandTarget ? codexHookCommands(commandTarget) : null;
595
+ return Object.keys(codexHookCommands()).every(phase =>
476
596
  Array.isArray(config.hooks?.[phase])
477
- && config.hooks[phase].some(entry => isCanonicalCodexHookEntry(entry, phase, command))
597
+ && config.hooks[phase].some(entry =>
598
+ hasCanonicalCodexMatcher(entry, phase)
599
+ && Array.isArray(entry?.hooks)
600
+ && entry.hooks.some(hook => expectedCommands
601
+ ? hook?.command === expectedCommands[phase]
602
+ : isContextModeCommand(hook?.command))
603
+ )
478
604
  );
479
605
  } catch {
480
606
  return false;
481
607
  }
482
608
  }
483
609
 
484
- function hasCursorHookCoverage(filePath) {
610
+ function hasCursorHookCoverage(filePath, commandTarget = null) {
485
611
  if (!existsSync(filePath)) return false;
486
612
  try {
487
613
  const config = readJson(filePath, {});
488
- return CURSOR_HOOK_COMMANDS.every(({ event, command }) =>
614
+ const expectedCommands = commandTarget ? cursorHookCommands(commandTarget) : null;
615
+ return cursorHookCommands().every(({ event }) =>
489
616
  Array.isArray(config.hooks?.[event])
490
- && config.hooks[event].some(entry => entry?.command === command && entry?.event === event)
617
+ && config.hooks[event].some(entry => entry?.event === event && (expectedCommands
618
+ ? entry?.command === expectedCommands.find(command => command.event === event)?.command
619
+ : isContextModeCommand(entry?.command)))
491
620
  );
492
621
  } catch {
493
622
  return false;
@@ -502,12 +631,12 @@ function hasAnyContextModeConfig(home) {
502
631
  || hasJsonMcpServer(join(home, '.claude.json'));
503
632
  }
504
633
 
505
- function hasCompleteContextModeConfig(home) {
506
- return hasCodexMcpServer(join(home, '.codex', 'config.toml'))
507
- && hasCodexHookCoverage(join(home, '.codex', 'hooks.json'))
508
- && hasJsonMcpServer(join(home, '.cursor', 'mcp.json'))
509
- && hasCursorHookCoverage(join(home, '.cursor', 'hooks.json'))
510
- && hasJsonMcpServer(join(home, '.claude.json'));
634
+ function hasCompleteContextModeConfig(home, commandTarget = BINARY_NAME) {
635
+ return hasCodexMcpServer(join(home, '.codex', 'config.toml'), commandTarget)
636
+ && hasCodexHookCoverage(join(home, '.codex', 'hooks.json'), commandTarget)
637
+ && hasJsonMcpServer(join(home, '.cursor', 'mcp.json'), commandTarget)
638
+ && hasCursorHookCoverage(join(home, '.cursor', 'hooks.json'), commandTarget)
639
+ && hasJsonMcpServer(join(home, '.claude.json'), commandTarget);
511
640
  }
512
641
 
513
642
  export function isContextModeRequested(args = {}, env = process.env) {
@@ -531,6 +660,10 @@ export function detectContextModeBinary(options = {}) {
531
660
  };
532
661
  }
533
662
 
663
+ return describeContextModeBinary(binaryPath, env, 'path');
664
+ }
665
+
666
+ function describeContextModeBinary(binaryPath, env = process.env, source = 'path') {
534
667
  const versionEnv = {
535
668
  ...process.env,
536
669
  ...env,
@@ -557,11 +690,85 @@ export function detectContextModeBinary(options = {}) {
557
690
  return {
558
691
  present: true,
559
692
  path: binaryPath,
693
+ source,
694
+ commandTarget: source === 'path' ? BINARY_NAME : binaryPath,
560
695
  version: versionOutput || 'unknown',
561
696
  reason: null,
562
697
  };
563
698
  }
564
699
 
700
+ export function resolveContextModeExecutable(options = {}) {
701
+ const env = options.env || process.env;
702
+ const home = options.home || env.HOME || process.env.HOME;
703
+ const pathBinary = findExecutable(BINARY_NAME, env);
704
+ if (pathBinary) return describeContextModeBinary(pathBinary, env, 'path');
705
+
706
+ const knownBinary = findExecutableInDirs(BINARY_NAME, knownExecutableDirs(home, env, options));
707
+ if (knownBinary) return describeContextModeBinary(knownBinary, env, 'known-prefix');
708
+
709
+ return {
710
+ present: false,
711
+ path: null,
712
+ source: null,
713
+ commandTarget: null,
714
+ version: null,
715
+ reason: `${BINARY_NAME} binary not found on PATH or known package-manager prefixes`,
716
+ };
717
+ }
718
+
719
+ export function ensureContextModeBinary(options = {}) {
720
+ const env = options.env || process.env;
721
+ const dryRun = options.dryRun === true;
722
+ const home = options.home || env.HOME || process.env.HOME;
723
+ const warnings = [];
724
+ const resolved = resolveContextModeExecutable({ ...options, env, home });
725
+ if (resolved.present) {
726
+ return { installed: false, plannedInstall: false, binary: resolved, warnings, install: null };
727
+ }
728
+
729
+ const installPlan = buildGlobalPackageInstall(PACKAGE_SPEC, options.packageManager ? { packageManager: options.packageManager } : {});
730
+ if (dryRun) {
731
+ return {
732
+ installed: false,
733
+ plannedInstall: true,
734
+ binary: resolved,
735
+ warnings,
736
+ install: { ...installPlan, commandLine: formatCommand(installPlan) },
737
+ };
738
+ }
739
+
740
+ if (env.AW_CONTEXT_MODE_DISABLE_PACKAGE_INSTALL === '1') {
741
+ warnings.push(`context-mode package install disabled; planned command: ${formatCommand(installPlan)}`);
742
+ return {
743
+ installed: false,
744
+ plannedInstall: false,
745
+ binary: resolved,
746
+ warnings,
747
+ install: { ...installPlan, ok: false, commandLine: formatCommand(installPlan) },
748
+ };
749
+ }
750
+
751
+ const installer = options.installPackage || installGlobalPackage;
752
+ const install = installer(PACKAGE_SPEC, {
753
+ env,
754
+ cwd: options.cwd,
755
+ packageManager: options.packageManager,
756
+ stdio: options.stdio,
757
+ });
758
+ if (!install?.ok) {
759
+ warnings.push(`context-mode package install failed: ${install?.error?.message || install?.stderr || install?.commandLine || 'unknown error'}`);
760
+ return { installed: false, plannedInstall: false, binary: resolved, warnings, install };
761
+ }
762
+
763
+ const afterInstall = resolveContextModeExecutable({ ...options, env, home });
764
+ if (!afterInstall.present) {
765
+ warnings.push(`context-mode installed but binary could not be resolved: ${afterInstall.reason}`);
766
+ return { installed: true, plannedInstall: false, binary: afterInstall, warnings, install };
767
+ }
768
+
769
+ return { installed: true, plannedInstall: false, binary: afterInstall, warnings, install };
770
+ }
771
+
565
772
  export function ensureContextModeIntegration(home, options = {}) {
566
773
  const env = options.env || process.env;
567
774
  const dryRun = options.dryRun === true;
@@ -569,24 +776,31 @@ export function ensureContextModeIntegration(home, options = {}) {
569
776
  const changedFiles = [];
570
777
  const configuredHarnesses = [];
571
778
  const installState = readInstallState(home);
572
- const binary = detectContextModeBinary({ env });
779
+ const binaryResult = ensureContextModeBinary({ ...options, env, home, dryRun });
780
+ warnings.push(...binaryResult.warnings);
781
+ const binary = binaryResult.binary;
573
782
 
574
783
  if (!binary.present) {
575
784
  return {
576
785
  changedFiles,
577
- warnings: [`context-mode binary is required before config mutation: ${binary.reason}`],
786
+ warnings: warnings.length > 0 ? warnings : [`context-mode binary is required before config mutation: ${binary.reason}`],
578
787
  configuredHarnesses,
788
+ complete: false,
579
789
  binary,
580
790
  dryRun,
791
+ installedPackage: binaryResult.installed,
792
+ plannedInstall: binaryResult.plannedInstall,
793
+ install: binaryResult.install,
581
794
  };
582
795
  }
583
796
 
797
+ const commandTarget = commandTargetForBinary(binary);
584
798
  const updates = [
585
- ['Codex MCP', join(home, '.codex', 'config.toml'), () => mergeCodexToml(join(home, '.codex', 'config.toml'), warnings, installState, MCP_TARGETS.codex, dryRun)],
586
- ['Codex hooks', join(home, '.codex', 'hooks.json'), () => mergeCodexHooks(join(home, '.codex', 'hooks.json'), dryRun)],
587
- ['Cursor MCP', join(home, '.cursor', 'mcp.json'), () => mergeJsonMcpServer(join(home, '.cursor', 'mcp.json'), installState, MCP_TARGETS.cursor, dryRun)],
588
- ['Cursor hooks', join(home, '.cursor', 'hooks.json'), () => mergeCursorHooks(join(home, '.cursor', 'hooks.json'), dryRun)],
589
- ['Claude MCP', join(home, '.claude.json'), () => mergeJsonMcpServer(join(home, '.claude.json'), installState, MCP_TARGETS.claude, dryRun)],
799
+ ['Codex MCP', join(home, '.codex', 'config.toml'), () => mergeCodexToml(join(home, '.codex', 'config.toml'), warnings, installState, MCP_TARGETS.codex, commandTarget, dryRun)],
800
+ ['Codex hooks', join(home, '.codex', 'hooks.json'), () => mergeCodexHooks(join(home, '.codex', 'hooks.json'), commandTarget, dryRun)],
801
+ ['Cursor MCP', join(home, '.cursor', 'mcp.json'), () => mergeJsonMcpServer(join(home, '.cursor', 'mcp.json'), installState, MCP_TARGETS.cursor, commandTarget, dryRun)],
802
+ ['Cursor hooks', join(home, '.cursor', 'hooks.json'), () => mergeCursorHooks(join(home, '.cursor', 'hooks.json'), commandTarget, dryRun)],
803
+ ['Claude MCP', join(home, '.claude.json'), () => mergeJsonMcpServer(join(home, '.claude.json'), installState, MCP_TARGETS.claude, commandTarget, dryRun)],
590
804
  ];
591
805
 
592
806
  for (const [label, filePath, update] of updates) {
@@ -600,12 +814,21 @@ export function ensureContextModeIntegration(home, options = {}) {
600
814
  changedFiles.push(statePath);
601
815
  }
602
816
 
817
+ const complete = hasCompleteContextModeConfig(home, commandTarget);
818
+ if (!dryRun && !complete) {
819
+ warnings.push(`context-mode config is partially configured; fix the warnings above and rerun aw integrations add ${INTEGRATION_NAME}`);
820
+ }
821
+
603
822
  return {
604
823
  changedFiles: unique(changedFiles),
605
824
  warnings,
606
825
  configuredHarnesses,
826
+ complete,
607
827
  binary,
608
828
  dryRun,
829
+ installedPackage: binaryResult.installed,
830
+ plannedInstall: binaryResult.plannedInstall,
831
+ install: binaryResult.install,
609
832
  };
610
833
  }
611
834
 
@@ -640,15 +863,15 @@ export function removeContextModeIntegration(home, options = {}) {
640
863
 
641
864
  export function getContextModeIntegrationSummary(home, options = {}) {
642
865
  const env = options.env || process.env;
643
- const binary = detectContextModeBinary({ env });
866
+ const binary = resolveContextModeExecutable({ ...options, env, home });
644
867
  const hasAnyConfig = hasAnyContextModeConfig(home);
645
- const configured = binary.present && hasCompleteContextModeConfig(home);
868
+ const configured = binary.present && hasCompleteContextModeConfig(home, commandTargetForBinary(binary));
646
869
  const state = configured ? 'configured' : hasAnyConfig ? 'broken' : 'absent';
647
870
  const summary = configured
648
871
  ? `context-mode configured (${binary.version || 'version unknown'})`
649
872
  : hasAnyConfig
650
- ? `context-mode partially configured; run aw integration add ${INTEGRATION_NAME}`
651
- : `context-mode not configured${binary.present ? '' : '; binary not installed'}`;
873
+ ? `context-mode partially configured; run aw integrations add ${INTEGRATION_NAME}`
874
+ : `context-mode not configured${binary.present ? `; binary discoverable via ${binary.source}` : '; binary not installed'}`;
652
875
 
653
876
  return {
654
877
  name: INTEGRATION_NAME,
@@ -680,7 +903,7 @@ export function getContextModeDoctorStatus(home, options = {}) {
680
903
  title: 'Context Mode integration',
681
904
  status: 'warn',
682
905
  summary: summary.summary,
683
- fix: `Run \`aw integration add ${INTEGRATION_NAME}\` to repair or \`aw integration remove ${INTEGRATION_NAME}\` to clean it up.`,
906
+ fix: `Run \`aw integrations add ${INTEGRATION_NAME}\` to repair or \`aw integrations remove ${INTEGRATION_NAME}\` to clean it up.`,
684
907
  }];
685
908
  }
686
909