@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.
package/mcp.mjs CHANGED
@@ -1,16 +1,72 @@
1
1
  // mcp.mjs — MCP config generation for Claude Code, Cursor, and Codex (global ~/ configs)
2
2
  // Uses native Streamable HTTP — no bridge process needed.
3
3
 
4
- import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
4
+ import { existsSync, writeFileSync, readFileSync, mkdirSync, renameSync } from 'node:fs';
5
5
  import { execSync } from 'node:child_process';
6
6
  import { createInterface } from 'node:readline';
7
- import { join } from 'node:path';
7
+ import { dirname, join } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
+ import { randomBytes } from 'node:crypto';
9
10
  import * as p from '@clack/prompts';
10
11
  import * as fmt from './fmt.mjs';
11
12
 
12
13
  const HOME = homedir();
13
14
  const DEFAULT_MCP_URL = 'https://services.leadconnectorhq.com/agentic-workspace/mcp';
15
+ const MCP_PREFS_FILENAME = 'mcp-preferences.json';
16
+ const ENABLED_MODE = 'enabled';
17
+ const DISABLED_MODE = 'disabled';
18
+ const DISABLE_MCP_ENV = 'AW_DISABLE_MCP';
19
+ const MCP_SERVER_NAME = 'ghl-ai';
20
+
21
+ function mcpPrefsPath(homeDir = HOME) {
22
+ return join(homeDir, '.aw', MCP_PREFS_FILENAME);
23
+ }
24
+
25
+ function readJson(filePath, fallback = {}) {
26
+ if (!existsSync(filePath)) return fallback;
27
+ try {
28
+ return JSON.parse(readFileSync(filePath, 'utf8'));
29
+ } catch {
30
+ // Malformed or unreadable JSON should behave like an absent optional config.
31
+ return fallback;
32
+ }
33
+ }
34
+
35
+ function writeJson(filePath, value) {
36
+ const dir = dirname(filePath);
37
+ mkdirSync(dir, { recursive: true });
38
+ const tmpPath = join(dir, `.${randomBytes(8).toString('hex')}.tmp`);
39
+ writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`);
40
+ renameSync(tmpPath, filePath);
41
+ }
42
+
43
+ function envDisablesMcp(env = process.env) {
44
+ const value = String(env[DISABLE_MCP_ENV] || '').trim().toLowerCase();
45
+ return ['1', 'true', 'yes', 'on'].includes(value);
46
+ }
47
+
48
+ export function loadMcpPreferences(homeDir = HOME) {
49
+ const prefs = readJson(mcpPrefsPath(homeDir), {});
50
+ return {
51
+ mode: prefs.mode === DISABLED_MODE ? DISABLED_MODE : ENABLED_MODE,
52
+ updatedAt: typeof prefs.updatedAt === 'string' ? prefs.updatedAt : null,
53
+ };
54
+ }
55
+
56
+ export function saveMcpPreferences(mode, homeDir = HOME) {
57
+ const normalizedMode = mode === DISABLED_MODE ? DISABLED_MODE : ENABLED_MODE;
58
+ const next = {
59
+ mode: normalizedMode,
60
+ updatedAt: new Date().toISOString(),
61
+ };
62
+ writeJson(mcpPrefsPath(homeDir), next);
63
+ return next;
64
+ }
65
+
66
+ export function isMcpEnabled(homeDir = HOME, env = process.env) {
67
+ if (envDisablesMcp(env)) return false;
68
+ return loadMcpPreferences(homeDir).mode !== DISABLED_MODE;
69
+ }
14
70
 
15
71
  /**
16
72
  * Auto-detect MCP server paths.
@@ -250,11 +306,28 @@ async function resolveClickUpToken(silent = false, cwd = process.cwd()) {
250
306
  }
251
307
 
252
308
  /**
253
- * Setup MCP configs globally for Claude Code and Cursor.
309
+ * Setup MCP configs globally for Claude Code, Cursor, and Codex.
254
310
  * Merges ghl-ai server into existing configs without overwriting other servers.
255
311
  * Returns list of file paths that were created or updated.
256
312
  */
257
313
  export async function setupMcp(cwd, namespace, { silent = false } = {}) {
314
+ const prefs = loadMcpPreferences(HOME);
315
+ if (prefs.mode === DISABLED_MODE) {
316
+ const removed = removeMcpConfig();
317
+ if (!silent) {
318
+ const reason = `${fmt.chalk.dim(mcpPrefsPath(HOME).replace(HOME, '~'))} is disabled`;
319
+ fmt.logInfo(`MCP disabled (${reason}); removed ${removed} AW-managed MCP config file${removed === 1 ? '' : 's'}`);
320
+ }
321
+ return [];
322
+ }
323
+
324
+ if (envDisablesMcp()) {
325
+ if (!silent) {
326
+ fmt.logInfo(`MCP disabled (${DISABLE_MCP_ENV}=1); skipping MCP config updates`);
327
+ }
328
+ return [];
329
+ }
330
+
258
331
  const paths = detectPaths();
259
332
  const updatedFiles = [];
260
333
 
@@ -286,13 +359,13 @@ export async function setupMcp(cwd, namespace, { silent = false } = {}) {
286
359
 
287
360
  // ── Claude Code: ~/.claude.json (global) ──
288
361
  const claudeJsonPath = join(HOME, '.claude.json');
289
- if (mergeJsonMcpServer(claudeJsonPath, 'ghl-ai', ghlAiServerLocal)) {
362
+ if (mergeJsonMcpServer(claudeJsonPath, MCP_SERVER_NAME, ghlAiServerLocal)) {
290
363
  updatedFiles.push(claudeJsonPath);
291
364
  }
292
365
 
293
366
  // ── Cursor: ~/.cursor/mcp.json (global) ──
294
367
  const cursorMcpPath = join(HOME, '.cursor', 'mcp.json');
295
- if (mergeJsonMcpServer(cursorMcpPath, 'ghl-ai', ghlAiServerLocal)) {
368
+ if (mergeJsonMcpServer(cursorMcpPath, MCP_SERVER_NAME, ghlAiServerLocal)) {
296
369
  updatedFiles.push(cursorMcpPath);
297
370
  }
298
371
 
@@ -302,11 +375,11 @@ export async function setupMcp(cwd, namespace, { silent = false } = {}) {
302
375
  // survives — without this, each re-init overwrites ~/.codex/config.toml
303
376
  // from the ECC source which doesn't have the ghl-ai block.
304
377
  const codexTomlPath = join(HOME, '.codex', 'config.toml');
305
- if (mergeTomlMcpServer(codexTomlPath, 'ghl-ai', ghlAiServerLocal)) {
378
+ if (mergeTomlMcpServer(codexTomlPath, MCP_SERVER_NAME, ghlAiServerLocal)) {
306
379
  updatedFiles.push(codexTomlPath);
307
380
  }
308
381
  const eccCodexTomlPath = join(HOME, '.aw-ecc', '.codex', 'config.toml');
309
- mergeTomlMcpServer(eccCodexTomlPath, 'ghl-ai', ghlAiServerLocal);
382
+ mergeTomlMcpServer(eccCodexTomlPath, MCP_SERVER_NAME, ghlAiServerLocal);
310
383
 
311
384
  // Deduplicate
312
385
  const unique = [...new Set(updatedFiles)];
@@ -326,12 +399,6 @@ export async function setupMcp(cwd, namespace, { silent = false } = {}) {
326
399
  * Called by `aw nuke`. Returns number of files modified.
327
400
  */
328
401
  export function removeMcpConfig() {
329
- const targets = [
330
- join(HOME, '.claude.json'),
331
- join(HOME, '.cursor', 'mcp.json'),
332
- ];
333
-
334
- // Also clean legacy locations that older versions wrote to
335
402
  const jsonTargets = [
336
403
  join(HOME, '.claude.json'),
337
404
  join(HOME, '.cursor', 'mcp.json'),
@@ -345,8 +412,8 @@ export function removeMcpConfig() {
345
412
  if (!existsSync(filePath)) continue;
346
413
  try {
347
414
  const config = JSON.parse(readFileSync(filePath, 'utf8'));
348
- if (!config.mcpServers?.['ghl-ai']) continue;
349
- delete config.mcpServers['ghl-ai'];
415
+ if (!config.mcpServers?.[MCP_SERVER_NAME]) continue;
416
+ delete config.mcpServers[MCP_SERVER_NAME];
350
417
  if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
351
418
  writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
352
419
  removed++;
@@ -354,13 +421,62 @@ export function removeMcpConfig() {
354
421
  }
355
422
 
356
423
  // Codex: ~/.codex/config.toml (TOML format)
357
- if (removeTomlMcpServer(join(HOME, '.codex', 'config.toml'), 'ghl-ai')) {
424
+ if (removeTomlMcpServer(join(HOME, '.codex', 'config.toml'), MCP_SERVER_NAME)) {
425
+ removed++;
426
+ }
427
+ if (removeTomlMcpServer(join(HOME, '.aw-ecc', '.codex', 'config.toml'), MCP_SERVER_NAME)) {
358
428
  removed++;
359
429
  }
360
430
 
361
431
  return removed;
362
432
  }
363
433
 
434
+ function jsonMcpHealth(filePath) {
435
+ const server = readJson(filePath, {})?.mcpServers?.[MCP_SERVER_NAME];
436
+ const authorization = typeof server?.headers?.Authorization === 'string'
437
+ && server.headers.Authorization.trim().length > 0;
438
+ return {
439
+ path: filePath,
440
+ present: !!server,
441
+ url: typeof server?.url === 'string' && /^https?:\/\//.test(server.url),
442
+ authorization,
443
+ };
444
+ }
445
+
446
+ function tomlMcpHealth(filePath) {
447
+ if (!existsSync(filePath)) {
448
+ return { path: filePath, present: false, url: false, authorization: false };
449
+ }
450
+
451
+ try {
452
+ const content = readFileSync(filePath, 'utf8');
453
+ return {
454
+ path: filePath,
455
+ present: new RegExp(`\\[mcp_servers\\.${MCP_SERVER_NAME}\\]`).test(content),
456
+ url: new RegExp(`\\[mcp_servers\\.${MCP_SERVER_NAME}\\][\\s\\S]*?url\\s*=\\s*"https?:\\/\\/[^"]+"`).test(content),
457
+ authorization: new RegExp(`\\[mcp_servers\\.${MCP_SERVER_NAME}\\.headers\\][\\s\\S]*?Authorization\\s*=\\s*".+"`).test(content),
458
+ };
459
+ } catch {
460
+ // Unreadable TOML should behave like an absent optional MCP config.
461
+ return { path: filePath, present: false, url: false, authorization: false };
462
+ }
463
+ }
464
+
465
+ export function getMcpStatus(homeDir = HOME, env = process.env) {
466
+ const prefs = loadMcpPreferences(homeDir);
467
+ return {
468
+ ...prefs,
469
+ effectiveMode: isMcpEnabled(homeDir, env) ? ENABLED_MODE : DISABLED_MODE,
470
+ envDisableMcp: envDisablesMcp(env),
471
+ envDisableMcpName: DISABLE_MCP_ENV,
472
+ preferencesPath: mcpPrefsPath(homeDir),
473
+ claude: jsonMcpHealth(join(homeDir, '.claude.json')),
474
+ cursor: jsonMcpHealth(join(homeDir, '.cursor', 'mcp.json')),
475
+ codex: tomlMcpHealth(join(homeDir, '.codex', 'config.toml')),
476
+ eccCodex: tomlMcpHealth(join(homeDir, '.aw-ecc', '.codex', 'config.toml')),
477
+ };
478
+ }
479
+
364
480
  // ── TOML helpers for Codex ~/.codex/config.toml ───────────────────────────
365
481
 
366
482
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "hooks/",
36
36
  "startup.mjs",
37
37
  "ecc.mjs",
38
+ "integrations.mjs",
38
39
  "render-rules.mjs",
39
40
  "telemetry.mjs"
40
41
  ],
@@ -52,9 +53,9 @@
52
53
  "license": "MIT",
53
54
  "scripts": {
54
55
  "test": "yarn test:vitest && yarn test:node",
55
- "test:vitest": "vitest run --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4",
56
+ "test:vitest": "vitest run --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4 tests/integrations-graphify.test.mjs",
56
57
  "test:node": "node tests/run-node-tests.mjs",
57
- "test:watch": "vitest --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4",
58
+ "test:watch": "vitest --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4 tests/integrations-graphify.test.mjs",
58
59
  "preuninstall": "node bin.js nuke 2>/dev/null || true"
59
60
  },
60
61
  "publishConfig": {
package/render-rules.mjs CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import * as fmt from './fmt.mjs';
7
7
  import { RULES_RUNTIME_DIR } from './constants.mjs';
8
+ import { isDefaultRoutingEnabled } from './startup.mjs';
8
9
 
9
10
  // The marker is placed AFTER the YAML frontmatter so Cursor/Markdown parsers
10
11
  // see the frontmatter at byte 0. Putting an HTML comment before --- breaks
@@ -212,6 +213,24 @@ function pruneStaleGeneratedRules(outputDir, expectedFilenames) {
212
213
  }
213
214
  }
214
215
 
216
+ function defaultRoutingEnabled(options = {}) {
217
+ if (typeof options.defaultRoutingEnabled === 'boolean') {
218
+ return options.defaultRoutingEnabled;
219
+ }
220
+ return isDefaultRoutingEnabled(options.homeDir || homedir());
221
+ }
222
+
223
+ function cleanupRenderedRules(cwd, options = {}) {
224
+ pruneStaleGeneratedRules(join(cwd, '.cursor', 'rules'), new Set());
225
+ pruneStaleGeneratedRules(join(cwd, '.claude', 'rules', 'platform'), new Set());
226
+
227
+ const HOME = options.homeDir || homedir();
228
+ if (cwd !== HOME) {
229
+ pruneStaleGeneratedRules(join(HOME, '.cursor', 'rules'), new Set());
230
+ pruneStaleGeneratedRules(join(HOME, '.claude', 'rules', 'platform'), new Set());
231
+ }
232
+ }
233
+
215
234
  function stackOverlaysEnabled(options = {}) {
216
235
  if (typeof options.enableStackOverlays === 'boolean') {
217
236
  return options.enableStackOverlays;
@@ -701,8 +720,13 @@ export function generateAgentsMdRulesSection(rulesDir, options = {}) {
701
720
  * 2. Returns sections for CLAUDE.md and AGENTS.md injection
702
721
  */
703
722
  export function renderRules(cwd, options = {}) {
723
+ if (!defaultRoutingEnabled(options)) {
724
+ cleanupRenderedRules(cwd, options);
725
+ return { cursorCount: 0, claudeCount: 0, claudeSection: '', agentsSection: '' };
726
+ }
727
+
704
728
  const rulesDir = resolveRulesSourceDir(cwd, options);
705
- if (!rulesDir) return { cursorCount: 0, claudeSection: '', agentsSection: '' };
729
+ if (!rulesDir) return { cursorCount: 0, claudeCount: 0, claudeSection: '', agentsSection: '' };
706
730
 
707
731
  // Resolve applicable scopes for AGENTS.md / CLAUDE.md MUST-rule list.
708
732
  // Order: explicit option → .aw/config.json awRuleScopes → auto-detect.
package/startup.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  const STARTUP_PREFS_FILENAME = 'startup-preferences.json';
14
14
  const ENABLED_MODE = 'enabled';
15
15
  const DISABLED_MODE = 'disabled';
16
+ const DISABLE_DEFAULT_ROUTING_ENV = 'AW_DISABLE_DEFAULT_ROUTING';
16
17
 
17
18
  const CLAUDE_DISABLE_DESCRIPTION = 'AW-managed override: disable automatic AW session routing';
18
19
  const CLAUDE_TELEMETRY_DESCRIPTION = 'AW usage telemetry';
@@ -542,9 +543,35 @@ function disableCodexStartup(homeDir = homedir()) {
542
543
  return updatedFiles;
543
544
  }
544
545
 
545
- function isManagedCursorSessionStartEntry(entry) {
546
+ function cursorSessionStartShellScriptUsesAwRouting(homeDir = homedir()) {
547
+ const scriptPath = join(homeDir, '.cursor', 'hooks', 'session-start.sh');
548
+ if (!existsSync(scriptPath)) return false;
549
+
550
+ try {
551
+ const content = readFileSync(scriptPath, 'utf8');
552
+ return content.includes('using-aw-skills/hooks/session-start.sh')
553
+ || content.includes('skills/using-aw-skills/hooks/session-start.sh');
554
+ } catch {
555
+ // Unreadable Cursor shell hook is safest to treat as non-AW managed.
556
+ return false;
557
+ }
558
+ }
559
+
560
+ function isManagedCursorSessionStartEntry(entry, homeDir = homedir()) {
546
561
  const command = String(entry?.command || '');
547
- return command === CURSOR_SESSION_START_COMMAND || command.endsWith('.cursor/hooks/session-start.js');
562
+ if (command === CURSOR_SESSION_START_COMMAND || command.endsWith('.cursor/hooks/session-start.js')) {
563
+ return true;
564
+ }
565
+
566
+ if (command.includes('using-aw-skills/hooks/session-start.sh')) {
567
+ return true;
568
+ }
569
+
570
+ if (command.includes('.cursor/hooks/session-start.sh')) {
571
+ return cursorSessionStartShellScriptUsesAwRouting(homeDir);
572
+ }
573
+
574
+ return false;
548
575
  }
549
576
 
550
577
  function hasCursorSessionStartScript(homeDir = homedir()) {
@@ -560,7 +587,7 @@ function disableCursorStartup(homeDir = homedir()) {
560
587
  return [];
561
588
  }
562
589
 
563
- const filtered = config.hooks.sessionStart.filter(entry => !isManagedCursorSessionStartEntry(entry));
590
+ const filtered = config.hooks.sessionStart.filter(entry => !isManagedCursorSessionStartEntry(entry, homeDir));
564
591
  if (filtered.length === config.hooks.sessionStart.length) {
565
592
  return [];
566
593
  }
@@ -622,6 +649,16 @@ export function loadStartupPreferences(homeDir = homedir()) {
622
649
  };
623
650
  }
624
651
 
652
+ function envDisablesDefaultRouting(env = process.env) {
653
+ const value = String(env[DISABLE_DEFAULT_ROUTING_ENV] || '').trim().toLowerCase();
654
+ return ['1', 'true', 'yes', 'on'].includes(value);
655
+ }
656
+
657
+ export function isDefaultRoutingEnabled(homeDir = homedir(), env = process.env) {
658
+ if (envDisablesDefaultRouting(env)) return false;
659
+ return loadStartupPreferences(homeDir).mode !== DISABLED_MODE;
660
+ }
661
+
625
662
  export function saveStartupPreferences(mode, homeDir = homedir()) {
626
663
  const normalizedMode = mode === DISABLED_MODE ? DISABLED_MODE : ENABLED_MODE;
627
664
  const next = {
@@ -658,11 +695,11 @@ export function applyGlobalStartupMode(mode, homeDir = homedir()) {
658
695
  return [...new Set(updatedFiles)];
659
696
  }
660
697
 
661
- export function applyStoredStartupPreferences(homeDir = homedir()) {
662
- return applyGlobalStartupMode(loadStartupPreferences(homeDir).mode, homeDir);
698
+ export function applyStoredStartupPreferences(homeDir = homedir(), env = process.env) {
699
+ return applyGlobalStartupMode(isDefaultRoutingEnabled(homeDir, env) ? ENABLED_MODE : DISABLED_MODE, homeDir);
663
700
  }
664
701
 
665
- export function getStartupStatus(homeDir = homedir()) {
702
+ export function getStartupStatus(homeDir = homedir(), env = process.env) {
666
703
  const prefs = loadStartupPreferences(homeDir);
667
704
  const claudeSettingsPath = join(homeDir, '.claude', 'settings.json');
668
705
  const codexHooksPath = join(homeDir, '.codex', 'hooks.json');
@@ -670,9 +707,15 @@ export function getStartupStatus(homeDir = homedir()) {
670
707
  const claudeSettings = readJson(claudeSettingsPath, {});
671
708
  const codexHooks = readJson(codexHooksPath, {});
672
709
  const cursorHooks = readJson(cursorHooksPath, {});
710
+ const cursorSessionStartEntries = Array.isArray(cursorHooks?.hooks?.sessionStart)
711
+ ? cursorHooks.hooks.sessionStart
712
+ : [];
673
713
 
674
714
  return {
675
715
  ...prefs,
716
+ effectiveMode: isDefaultRoutingEnabled(homeDir, env) ? ENABLED_MODE : DISABLED_MODE,
717
+ envDisableDefaultRouting: envDisablesDefaultRouting(env),
718
+ envDisableDefaultRoutingName: DISABLE_DEFAULT_ROUTING_ENV,
676
719
  preferencesPath: startupPrefsPath(homeDir),
677
720
  claudePluginEnabled: claudeSettings?.enabledPlugins?.['aw@aw-marketplace'] === true,
678
721
  claudePluginInstalled: hasClaudePluginCache(homeDir),
@@ -684,8 +727,9 @@ export function getStartupStatus(homeDir = homedir()) {
684
727
  codexSessionStartPresent: Array.isArray(codexHooks?.hooks?.SessionStart) &&
685
728
  codexHooks.hooks.SessionStart.some(isManagedCodexSessionStartEntry),
686
729
  codexSessionStartScriptInstalled: hasCodexSessionStartScript(homeDir),
687
- cursorSessionStartPresent: Array.isArray(cursorHooks?.hooks?.sessionStart) &&
688
- cursorHooks.hooks.sessionStart.length > 0,
730
+ cursorSessionStartPresent: cursorSessionStartEntries.some(entry =>
731
+ isManagedCursorSessionStartEntry(entry, homeDir)),
732
+ cursorAnySessionStartPresent: cursorSessionStartEntries.length > 0,
689
733
  cursorSessionStartScriptInstalled: hasCursorSessionStartScript(homeDir),
690
734
  };
691
735
  }