@ghl-ai/aw 0.1.45 → 0.1.47-beta.0

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/cli.mjs CHANGED
@@ -26,6 +26,7 @@ const COMMANDS = {
26
26
  nuke: () => import('./commands/nuke.mjs').then(m => m.nukeCommand),
27
27
  daemon: () => import('./commands/daemon.mjs').then(m => m.daemonCommand),
28
28
  telemetry: () => import('./commands/telemetry.mjs').then(m => m.telemetryCommand),
29
+ integrations: () => import('./commands/integrations.mjs').then(m => m.integrationsCommand),
29
30
  'slack-sim': () => import('./commands/slack-sim.mjs').then(m => m.slackSimCommand),
30
31
  c4: () => import('./commands/c4.mjs').then(m => m.c4Command),
31
32
  'init-repo': () => import('./commands/init-repo.mjs').then(m => m.initRepoCommand),
@@ -82,8 +83,9 @@ function printHelp() {
82
83
  const sec = (title) => `\n ${chalk.bold.underline(title)}`;
83
84
  const help = [
84
85
  sec('Setup'),
85
- cmd('aw init', 'Initialize workspace (platform/ only)'),
86
+ cmd('aw init', 'Initialize workspace (auto-installs suggested integrations)'),
86
87
  cmd('aw init --namespace <team/sub-team>', 'Add a team namespace (optional)'),
88
+ cmd('aw init --no-integrations', 'Skip integration setup (Codex, Caveman, Graphify, etc)'),
87
89
  ` ${chalk.dim('Teams: platform, revex, mobile, commerce, leadgen, crm, marketplace, ai')}`,
88
90
  ` ${chalk.dim('Example: aw init --namespace revex/courses')}`,
89
91
  cmd('aw init-repo', 'Scaffold cloud-bootstrap files (idempotent, --dry-run/--force/--diff)'),
@@ -109,6 +111,10 @@ function printHelp() {
109
111
  cmd('aw routing status', 'Show global AW session-routing mode for Claude/Cursor/Codex'),
110
112
  cmd('aw routing disable', 'Disable automatic AW session routing globally'),
111
113
  cmd('aw routing enable', 'Re-enable automatic AW session routing globally'),
114
+ cmd('aw integrations', 'Manage third-party integrations (Codex, Caveman, etc)'),
115
+ cmd('aw integrations add <key>', 'Install a specific tool (e.g. codex, caveman)'),
116
+ cmd('aw integrations remove <key>', 'Remove a tool'),
117
+ cmd('aw integrations bundle <name>', 'Install a preset bundle'),
112
118
  cmd('aw drop <path>', 'Stop syncing or delete local content'),
113
119
  cmd('aw nuke', 'Remove entire .aw_registry/ & start fresh'),
114
120
  cmd('aw daemon install', 'Auto-pull on a schedule (macOS launchd / Linux cron)'),
package/commands/init.mjs CHANGED
@@ -34,6 +34,7 @@ import { loadConfig as ensureTelemetryConfig } from '../telemetry.mjs';
34
34
  import { installAwEcc, AW_ECC_TAG } from '../ecc.mjs';
35
35
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
36
36
  import { readHookManifest, pruneStaleHooks, writeHookManifest } from '../hook-cleanup.mjs';
37
+ import { promptAndInstall, autoInstallIntegrations } from '../integrations.mjs';
37
38
  import {
38
39
  initPersistentClone,
39
40
  isValidClone,
@@ -212,6 +213,7 @@ export async function initCommand(args) {
212
213
  let namespace = args['--namespace'] || null;
213
214
  let user = args['--user'] || '';
214
215
  const silent = args['--silent'] === true;
216
+ const skipIntegrations = args['--no-integrations'] === true;
215
217
 
216
218
  // In silent mode, suppress ALL fmt output and show a single spinner.
217
219
  // setSilent(true) makes every fmt.* call a no-op — internal functions
@@ -414,6 +416,12 @@ export async function initCommand(args) {
414
416
  // Write hook manifest after all hook installation is complete
415
417
  try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
416
418
 
419
+ // Auto-install suggested integrations (Codex, Caveman, Graphify, etc) - unless --no-integrations
420
+ let installedIntegrations = [];
421
+ if (!silent && !skipIntegrations && !isNewSubTeam) {
422
+ installedIntegrations = await autoInstallIntegrations(freshCfg?.namespace || team, { silent });
423
+ }
424
+
417
425
  if (silent) {
418
426
  if (silentSpinner) { silentSpinner.stop('Done'); setSilent(false); }
419
427
  autoUpdate(await args._updateCheck);
@@ -427,6 +435,7 @@ export async function initCommand(args) {
427
435
  ? ` ${chalk.green('✓')} Removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}`
428
436
  : null,
429
437
  cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Project linked` : null,
438
+ installedIntegrations.length > 0 ? ` ${chalk.green('✓')} Integrations: ${installedIntegrations.join(', ')}` : null,
430
439
  ].filter(Boolean).join('\n'));
431
440
  }
432
441
  return;
@@ -574,6 +583,12 @@ export async function initCommand(args) {
574
583
  // Ensure telemetry config exists (generates machine_id on first run)
575
584
  ensureTelemetryConfig();
576
585
 
586
+ // Auto-install suggested integrations (Codex, Caveman, Graphify, etc) - unless --no-integrations
587
+ let installedIntegrations = [];
588
+ if (!silent && !skipIntegrations) {
589
+ installedIntegrations = await autoInstallIntegrations(team, { silent });
590
+ }
591
+
577
592
  // Offer to update if a newer version is available
578
593
  if (!silent) await promptUpdate(await args._updateCheck);
579
594
 
@@ -593,10 +608,12 @@ export async function initCommand(args) {
593
608
  hooksInstalled ? ` ${chalk.green('✓')} Git hooks: auto-sync on pull/clone (core.hooksPath)` : null,
594
609
  ` ${chalk.green('✓')} IDE task: auto-sync on workspace open`,
595
610
  cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Linked in current project` : null,
611
+ installedIntegrations.length > 0 ? ` ${chalk.green('✓')} Integrations: ${installedIntegrations.join(', ')}` : null,
596
612
  '',
597
613
  ` ${chalk.dim('Existing repos:')} ${chalk.bold('cd <project> && aw link')}`,
598
614
  ` ${chalk.dim('New clones:')} auto-linked via git hook`,
599
615
  ` ${chalk.dim('Update:')} ${chalk.bold('aw init')} ${chalk.dim('(or auto on pull/IDE open)')}`,
616
+ ` ${chalk.dim('Integrations:')} ${chalk.bold('aw integrations')} ${chalk.dim('(manage Codex, Caveman, etc)')}`,
600
617
  ` ${chalk.dim('Uninstall:')} ${chalk.bold('aw nuke')}`,
601
618
  ].filter(Boolean).join('\n'));
602
619
  }
@@ -0,0 +1,254 @@
1
+ // commands/integrations.mjs — CLI command: aw integrations add/remove/list/bundle
2
+
3
+ import * as p from '@clack/prompts';
4
+ import * as fmt from '../fmt.mjs';
5
+ import { chalk } from '../fmt.mjs';
6
+ import {
7
+ INTEGRATIONS,
8
+ BUNDLES,
9
+ installIntegration,
10
+ removeIntegration,
11
+ getInstalledList,
12
+ } from '../integrations.mjs';
13
+
14
+ export async function integrationsCommand(args) {
15
+ const subcommand = args._positional[0];
16
+
17
+ switch (subcommand) {
18
+ case 'add':
19
+ return cmdAdd(args);
20
+ case 'remove':
21
+ return cmdRemove(args);
22
+ case 'bundle':
23
+ return cmdBundle(args);
24
+ case 'list':
25
+ case undefined:
26
+ return cmdList();
27
+ default:
28
+ fmt.cancel(`Unknown subcommand: ${subcommand}`);
29
+ }
30
+ }
31
+
32
+ // ────────────────────────────────────────────────────────────────────────────────
33
+ // aw integrations list
34
+ // ────────────────────────────────────────────────────────────────────────────────
35
+
36
+ async function cmdList() {
37
+ fmt.banner('Integrations', {
38
+ icon: '🔗',
39
+ subtitle: ' Available tools and MCP servers',
40
+ });
41
+
42
+ const installed = getInstalledList();
43
+
44
+ // Group by type
45
+ const plugins = Object.entries(INTEGRATIONS).filter(
46
+ ([, i]) => i.type === 'plugin'
47
+ );
48
+ const remoteRcps = Object.entries(INTEGRATIONS).filter(
49
+ ([, i]) => i.type === 'remote-mcp'
50
+ );
51
+ const universalInstallers = Object.entries(INTEGRATIONS).filter(
52
+ ([, i]) => i.type === 'universal-installer'
53
+ );
54
+ const pythonClis = Object.entries(INTEGRATIONS).filter(
55
+ ([, i]) => i.type === 'python-cli'
56
+ );
57
+
58
+ const typeIcon = (type) =>
59
+ type === 'plugin' ? '🔌' : type === 'remote-mcp' ? '🌐' : type === 'universal-installer' ? '🪨' : '⚙️';
60
+
61
+ // Installed section
62
+ if (installed.length > 0) {
63
+ fmt.logMessage(`\n${chalk.bold.underline('Installed')}`);
64
+ for (const key of installed) {
65
+ const integration = INTEGRATIONS[key];
66
+ if (!integration) continue;
67
+ fmt.logSuccess(` ${typeIcon(integration.type)} ${integration.label}`);
68
+ }
69
+ }
70
+
71
+ // Available Plugins
72
+ fmt.logMessage(`\n${chalk.bold.underline('Available Plugins')}`);
73
+ for (const [key, integration] of plugins) {
74
+ if (!installed.includes(key)) {
75
+ fmt.logMessage(
76
+ ` 🔌 ${integration.label.padEnd(25)} — ${integration.description}`
77
+ );
78
+ }
79
+ }
80
+
81
+ // Available Remote MCPs
82
+ fmt.logMessage(`\n${chalk.bold.underline('Available Remote MCPs')}`);
83
+ for (const [key, integration] of remoteRcps) {
84
+ if (!installed.includes(key)) {
85
+ fmt.logMessage(
86
+ ` 🌐 ${integration.label.padEnd(25)} — ${integration.description}`
87
+ );
88
+ }
89
+ }
90
+
91
+ // Universal Installers
92
+ if (universalInstallers.length > 0) {
93
+ fmt.logMessage(`\n${chalk.bold.underline('Universal Tools')}`);
94
+ for (const [key, integration] of universalInstallers) {
95
+ if (!installed.includes(key)) {
96
+ fmt.logMessage(
97
+ ` 🪨 ${integration.label.padEnd(25)} — ${integration.description}`
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ // Python CLIs
104
+ if (pythonClis.length > 0) {
105
+ fmt.logMessage(`\n${chalk.bold.underline('Available Python Tools')}`);
106
+ for (const [key, integration] of pythonClis) {
107
+ if (!installed.includes(key)) {
108
+ fmt.logMessage(
109
+ ` 🐍 ${integration.label.padEnd(25)} — ${integration.description}`
110
+ );
111
+ }
112
+ }
113
+ }
114
+
115
+ // Bundles
116
+ fmt.logMessage(`\n${chalk.bold.underline('Bundles')}`);
117
+ for (const [bundleKey, bundle] of Object.entries(BUNDLES)) {
118
+ fmt.logMessage(
119
+ ` 📦 ${bundle.label.padEnd(25)} — ${bundle.description}`
120
+ );
121
+ fmt.logMessage(
122
+ ` Includes: ${bundle.includes.map((k) => INTEGRATIONS[k].label).join(', ')}`
123
+ );
124
+ }
125
+
126
+ fmt.logMessage(`\n${chalk.dim('Commands:')}`);
127
+ fmt.logMessage(` aw integrations add <key> Install a specific tool`);
128
+ fmt.logMessage(` aw integrations remove <key> Remove a tool`);
129
+ fmt.logMessage(` aw integrations bundle <name> Install a preset bundle`);
130
+ }
131
+
132
+ // ────────────────────────────────────────────────────────────────────────────────
133
+ // aw integrations add <key>
134
+ // ────────────────────────────────────────────────────────────────────────────────
135
+
136
+ async function cmdAdd(args) {
137
+ const key = args._positional[1];
138
+
139
+ if (!key) {
140
+ fmt.cancel('Usage: aw integrations add <key>');
141
+ }
142
+
143
+ if (!INTEGRATIONS[key]) {
144
+ // Suggest similar keys
145
+ const available = Object.keys(INTEGRATIONS);
146
+ fmt.cancel(
147
+ [
148
+ `Unknown integration: ${chalk.red(key)}`,
149
+ '',
150
+ `Available: ${available.join(', ')}`,
151
+ ].join('\n')
152
+ );
153
+ }
154
+
155
+ const integration = INTEGRATIONS[key];
156
+ fmt.intro(`Installing ${integration.label}`);
157
+
158
+ const success = await installIntegration(key, { silent: false });
159
+
160
+ if (success) {
161
+ fmt.outro(`✓ ${integration.label} installed successfully`);
162
+ } else {
163
+ fmt.cancel(`Failed to install ${integration.label}`);
164
+ }
165
+ }
166
+
167
+ // ────────────────────────────────────────────────────────────────────────────────
168
+ // aw integrations remove <key>
169
+ // ────────────────────────────────────────────────────────────────────────────────
170
+
171
+ async function cmdRemove(args) {
172
+ const key = args._positional[1];
173
+
174
+ if (!key) {
175
+ fmt.cancel('Usage: aw integrations remove <key>');
176
+ }
177
+
178
+ if (!INTEGRATIONS[key]) {
179
+ const available = Object.keys(INTEGRATIONS);
180
+ fmt.cancel(
181
+ [
182
+ `Unknown integration: ${chalk.red(key)}`,
183
+ '',
184
+ `Available: ${available.join(', ')}`,
185
+ ].join('\n')
186
+ );
187
+ }
188
+
189
+ const integration = INTEGRATIONS[key];
190
+ fmt.intro(`Removing ${integration.label}`);
191
+
192
+ const success = await removeIntegration(key, { silent: false });
193
+
194
+ if (success) {
195
+ fmt.outro(`✓ ${integration.label} removed successfully`);
196
+ } else {
197
+ fmt.cancel(`Failed to remove ${integration.label}`);
198
+ }
199
+ }
200
+
201
+ // ────────────────────────────────────────────────────────────────────────────────
202
+ // aw integrations bundle <bundleName>
203
+ // ────────────────────────────────────────────────────────────────────────────────
204
+
205
+ async function cmdBundle(args) {
206
+ const bundleName = args._positional[1];
207
+
208
+ if (!bundleName) {
209
+ fmt.cancel('Usage: aw integrations bundle <name>');
210
+ }
211
+
212
+ if (!BUNDLES[bundleName]) {
213
+ const available = Object.keys(BUNDLES);
214
+ fmt.cancel(
215
+ [
216
+ `Unknown bundle: ${chalk.red(bundleName)}`,
217
+ '',
218
+ `Available: ${available.join(', ')}`,
219
+ ].join('\n')
220
+ );
221
+ }
222
+
223
+ const bundle = BUNDLES[bundleName];
224
+ fmt.intro(`Installing bundle: ${bundle.label}`);
225
+
226
+ fmt.logMessage(`${bundle.description}\n`);
227
+ fmt.logMessage(`Includes:`);
228
+ for (const key of bundle.includes) {
229
+ const integration = INTEGRATIONS[key];
230
+ fmt.logMessage(` • ${integration.label}`);
231
+ }
232
+
233
+ const confirm = await p.default.confirm({
234
+ message: `Continue installing ${bundle.includes.length} tool(s)?`,
235
+ initialValue: true,
236
+ });
237
+
238
+ if (p.default.isCancel(confirm) || !confirm) {
239
+ fmt.cancel('Cancelled');
240
+ }
241
+
242
+ fmt.logMessage('');
243
+
244
+ // Install all
245
+ let successCount = 0;
246
+ for (const key of bundle.includes) {
247
+ const success = await installIntegration(key, { silent: false });
248
+ if (success) successCount++;
249
+ }
250
+
251
+ fmt.outro(
252
+ `✓ Bundle installation complete (${successCount}/${bundle.includes.length} installed)`
253
+ );
254
+ }
package/ecc.mjs CHANGED
@@ -12,7 +12,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
12
12
 
13
13
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
14
14
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
15
- export const AW_ECC_TAG = "v1.4.43";
15
+ export const AW_ECC_TAG = "v1.4.44";
16
16
 
17
17
  const MARKETPLACE_NAME = "aw-marketplace";
18
18
  const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
@@ -0,0 +1,954 @@
1
+ // integrations.mjs — Registry, manifest, and installation for third-party integrations.
2
+ // Handles four types:
3
+ // - plugin (claude plugin install)
4
+ // - remote-mcp (add to mcp.json / config.toml)
5
+ // - universal-installer (runs the integration's cross-IDE installer)
6
+ // - python-cli (pip-install a Python CLI tool, then run its setup commands)
7
+
8
+ import { promisify } from 'node:util';
9
+ import { exec } from 'node:child_process';
10
+ import { execSync } from 'node:child_process';
11
+ import {
12
+ existsSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ mkdirSync,
16
+ rmSync,
17
+ } from 'node:fs';
18
+ import { join, basename } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import * as fmt from './fmt.mjs';
21
+ import { chalk } from './fmt.mjs';
22
+
23
+ const execAsync = promisify(exec);
24
+ const HOME = homedir();
25
+ const MANIFEST_PATH = join(HOME, '.aw_registry', '.integration-manifest.json');
26
+
27
+ // ────────────────────────────────────────────────────────────────────────────────
28
+ // INTEGRATION REGISTRY
29
+ // ────────────────────────────────────────────────────────────────────────────────
30
+
31
+ export const INTEGRATIONS = {
32
+ // PLUGINS (installed via: claude plugin install)
33
+ 'codex': {
34
+ type: 'plugin',
35
+ label: 'OpenAI Codex',
36
+ installCmd: 'codex@openai-codex',
37
+ marketplaceSource: 'openai/codex-plugin-cc',
38
+ description: '/codex:review, /codex:adversarial-review, /codex:rescue — delegate tasks or get code reviews from Codex',
39
+ teams: [], // universal
40
+ requiresAuth: true,
41
+ authNote: 'Run /codex:setup to verify Codex is ready. If not logged in yet, run: !codex login',
42
+ },
43
+
44
+ 'lean-ctx': {
45
+ type: 'universal-installer',
46
+ label: 'LeanCTX',
47
+ description: 'Context OS for AI — compresses file reads + shell output + memory (60-99% fewer input tokens)',
48
+ scripts: {
49
+ win32: null, // no PS1 — uses npm fallback (lean-ctx-bin)
50
+ posix: 'https://leanctx.com/install.sh',
51
+ },
52
+ npmPackage: 'lean-ctx-bin', // Windows fallback via npm
53
+ postInstall: ['lean-ctx setup'], // auto-detects + configures all IDEs in one shot
54
+ teams: [], // universal
55
+ requiresAuth: false,
56
+ authNote: 'Restart your IDE after install — lean-ctx setup auto-configures Claude Code, Cursor, Codex, Gemini, and more',
57
+ },
58
+
59
+ rtk: {
60
+ type: 'universal-installer',
61
+ label: 'RTK (Rust Token Killer)',
62
+ description: 'CLI proxy that filters/compresses shell output (git, tests, logs) by 60-90%',
63
+ scripts: {
64
+ win32: null, // use WSL fallback
65
+ posix: 'https://raw.githubusercontent.com/rtk-ai/rtk/main/install.sh',
66
+ },
67
+ postInstall: [
68
+ 'rtk init -g', // Claude Code
69
+ 'rtk init -g --gemini', // Gemini CLI
70
+ 'rtk init -g --codex', // Codex
71
+ 'rtk init -g --agent cursor', // Cursor
72
+ ],
73
+ teams: [], // universal — every team benefits
74
+ requiresAuth: false,
75
+ authNote: 'Restart Claude Code after install for the auto-rewrite hook to take effect',
76
+ },
77
+
78
+ caveman: {
79
+ type: 'plugin',
80
+ label: 'Caveman',
81
+ installCmd: 'caveman@caveman',
82
+ marketplaceSource: 'JuliusBrussee/caveman',
83
+ description: 'Token-efficient responses — ~75% fewer output tokens (lite / full / ultra / wenyan modes)',
84
+ teams: [], // universal — every team benefits
85
+ requiresAuth: false,
86
+ authNote: 'Activate with /caveman in any session (lite / full / ultra modes)',
87
+ },
88
+
89
+ skills: {
90
+ type: 'universal-installer',
91
+ label: 'Agent Skills (Matt Pocock)',
92
+ description: 'Slash commands for real engineering: /tdd, /diagnose, /grill-me, /grill-with-docs, /triage, /to-prd, /zoom-out',
93
+ scripts: {
94
+ win32: null,
95
+ posix: null, // no binary — installed entirely via npx below
96
+ },
97
+ postInstall: [
98
+ 'npx skills@latest add mattpocock/skills',
99
+ ],
100
+ teams: [],
101
+ requiresAuth: false,
102
+ authNote: 'Interactive install — pick which skills + agents you want. Then run /setup-matt-pocock-skills once per repo.',
103
+ },
104
+
105
+ // PYTHON CLIs (installed via uv / pipx / pip, then runs post-install hooks)
106
+ graphify: {
107
+ type: 'python-cli',
108
+ label: 'Graphify (Knowledge Graph)',
109
+ description: 'Builds a queryable knowledge graph of your codebase + docs',
110
+ pipPackage: 'graphifyy',
111
+ cliCommand: 'graphify',
112
+ minPython: { major: 3, minor: 10 },
113
+ postInstall: [
114
+ ['install'], // global: registers the /graphify skill in ~/.claude/skills/
115
+ ],
116
+ perProjectInstall: [
117
+ // IDE wiring — only runs if that IDE's config dir exists on this machine.
118
+ // Each command writes the IDE-specific CLAUDE.md/AGENTS.md section + hook.
119
+ { args: ['claude', 'install'], requiresGit: false, requiresIde: '.claude' },
120
+ { args: ['codex', 'install'], requiresGit: false, requiresIde: '.codex' },
121
+ { args: ['cursor', 'install'], requiresGit: false, requiresIde: '.cursor' },
122
+ { args: ['gemini', 'install'], requiresGit: false, requiresIde: '.gemini' },
123
+ // Git hooks — post-commit AST rebuild + post-checkout sync + merge driver
124
+ { args: ['hook', 'install'], requiresGit: true },
125
+ // If a graph already exists, register it into the global graph immediately.
126
+ // appendCwdBasename appends the project folder name as the --as tag.
127
+ { args: ['global', 'add', 'graphify-out/graph.json', '--as'], appendCwdBasename: true, requiresFile: 'graphify-out/graph.json' },
128
+ ],
129
+ teams: [], // universal — every team benefits from a knowledge graph
130
+ requiresAuth: false,
131
+ authNote: 'run /graphify . inside your IDE to build the graph whenever you need it',
132
+ },
133
+ };
134
+
135
+ // ────────────────────────────────────────────────────────────────────────────────
136
+ // BUNDLES (preset groups for common use cases)
137
+ // ────────────────────────────────────────────────────────────────────────────────
138
+
139
+ export const BUNDLES = {};
140
+
141
+ // ────────────────────────────────────────────────────────────────────────────────
142
+ // MANIFEST MANAGEMENT
143
+ // ────────────────────────────────────────────────────────────────────────────────
144
+
145
+ export function readManifest() {
146
+ if (!existsSync(MANIFEST_PATH)) {
147
+ return { version: 1, installed: {} };
148
+ }
149
+
150
+ try {
151
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
152
+ } catch {
153
+ return { version: 1, installed: {} };
154
+ }
155
+ }
156
+
157
+ export function writeManifest(manifest) {
158
+ mkdirSync(join(HOME, '.aw_registry'), { recursive: true });
159
+ writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
160
+ }
161
+
162
+ export function isInstalled(key) {
163
+ const manifest = readManifest();
164
+ const entry = manifest.installed[key];
165
+ if (!entry) return false;
166
+ // Backward compat: pre-existing entries have no `status` field — treat as installed.
167
+ // Skipped entries (e.g. python-cli skipped because Python missing) return false so
168
+ // autoInstallIntegrations retries them on the next `aw init` run.
169
+ return entry.status !== 'skipped';
170
+ }
171
+
172
+ function recordInstalled(key, type) {
173
+ const manifest = readManifest();
174
+ manifest.installed[key] = {
175
+ type,
176
+ status: 'installed',
177
+ installedAt: new Date().toISOString(),
178
+ };
179
+ writeManifest(manifest);
180
+ }
181
+
182
+ function recordSkipped(key, type, reason) {
183
+ const manifest = readManifest();
184
+ manifest.installed[key] = {
185
+ type,
186
+ status: 'skipped',
187
+ reason,
188
+ installedAt: new Date().toISOString(),
189
+ };
190
+ writeManifest(manifest);
191
+ }
192
+
193
+ // ────────────────────────────────────────────────────────────────────────────────
194
+ // JSON MCP CONFIG MERGE (Claude & Cursor)
195
+ // ────────────────────────────────────────────────────────────────────────────────
196
+
197
+ function addToJsonMcp(filePath, serverName, config) {
198
+ mkdirSync(join(filePath, '..'), { recursive: true });
199
+
200
+ let existing = {};
201
+ if (existsSync(filePath)) {
202
+ try {
203
+ existing = JSON.parse(readFileSync(filePath, 'utf8'));
204
+ } catch {
205
+ // Corrupted file, start fresh
206
+ }
207
+ }
208
+
209
+ existing.mcpServers = existing.mcpServers || {};
210
+ existing.mcpServers[serverName] = config;
211
+
212
+ writeFileSync(filePath, JSON.stringify(existing, null, 2) + '\n');
213
+ }
214
+
215
+ // ────────────────────────────────────────────────────────────────────────────────
216
+ // TOML MCP CONFIG MERGE (Codex)
217
+ // ────────────────────────────────────────────────────────────────────────────────
218
+
219
+ function addToTomlMcp(filePath, serverName, url) {
220
+ mkdirSync(join(filePath, '..'), { recursive: true });
221
+
222
+ let content = '';
223
+ if (existsSync(filePath)) {
224
+ content = readFileSync(filePath, 'utf8');
225
+ }
226
+
227
+ // Remove any existing block for this server (idempotent)
228
+ const blockRegex = new RegExp(`\\[mcp_servers\\.${serverName}\\][^\\[]*`, 'g');
229
+ content = content.replace(blockRegex, '');
230
+
231
+ // Append new block
232
+ const newBlock = `[mcp_servers.${serverName}]\nurl = "${url}"\nstartup_timeout_sec = 30\n\n`;
233
+ content = content + newBlock;
234
+
235
+ writeFileSync(filePath, content);
236
+ }
237
+
238
+ // ────────────────────────────────────────────────────────────────────────────────
239
+ // PYTHON CLI INSTALLER (pip / pipx / uv tool, cross-platform)
240
+ // ────────────────────────────────────────────────────────────────────────────────
241
+
242
+ const IS_WINDOWS = process.platform === 'win32';
243
+ const WHICH_CMD = IS_WINDOWS ? 'where' : 'which';
244
+
245
+ // Try interpreters in order; return the first that meets minPython, or null.
246
+ async function detectPython({ major, minor }) {
247
+ const candidates = IS_WINDOWS
248
+ ? ['py -3', 'python', 'python3']
249
+ : ['python3', 'python'];
250
+
251
+ for (const cmd of candidates) {
252
+ try {
253
+ const { stdout } = await execAsync(`${cmd} --version`, { timeout: 10000 });
254
+ const m = stdout.match(/Python (\d+)\.(\d+)/);
255
+ if (!m) continue;
256
+ const [maj, min] = [parseInt(m[1], 10), parseInt(m[2], 10)];
257
+ if (maj > major || (maj === major && min >= minor)) {
258
+ return { cmd, version: `${maj}.${min}` };
259
+ }
260
+ } catch {
261
+ // Interpreter not present on PATH; try next.
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+
267
+ // Pick the best available Python package installer. Prefers tools that manage PATH.
268
+ async function pickPythonInstaller(pythonCmd) {
269
+ for (const tool of ['uv', 'pipx']) {
270
+ try {
271
+ await execAsync(`${WHICH_CMD} ${tool}`, { timeout: 5000 });
272
+ if (tool === 'uv') return { name: 'uv', build: pkg => `uv tool install "${pkg}"` };
273
+ if (tool === 'pipx') return { name: 'pipx', build: pkg => `pipx install "${pkg}"` };
274
+ } catch {
275
+ // Not on PATH; try next.
276
+ }
277
+ }
278
+ // Fall back to user-site pip. Note: bin dir may not be on PATH after install
279
+ // — we work around that by falling back to `python -m <module>` when needed.
280
+ return { name: 'pip', build: pkg => `${pythonCmd} -m pip install --user "${pkg}"` };
281
+ }
282
+
283
+ // Write a global SessionStart hook to ~/.claude/settings.json that registers the
284
+ // current project's graph into ~/.graphify/global-graph.json every time Claude Code
285
+ // opens in a project that has a built graph. Fast (JSON merge only, no LLM).
286
+ // Written globally so one hook covers all projects — guarded by graph.json existence.
287
+ export function installGraphifyGlobalAddHook(homeDir) {
288
+ const claudeDir = join(homeDir, '.claude');
289
+ if (!existsSync(claudeDir)) return;
290
+
291
+ const settingsPath = join(claudeDir, 'settings.json');
292
+ let settings = {};
293
+ if (existsSync(settingsPath)) {
294
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { /* corrupt — start fresh */ }
295
+ }
296
+
297
+ if (!settings.hooks) settings.hooks = {};
298
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
299
+
300
+ const MARKER = 'graphify-global-add';
301
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
302
+ e => e?.description !== MARKER,
303
+ );
304
+
305
+ // Always use `python -m graphify` (never the bare binary) so the hook is safe
306
+ // across future shell sessions where an old graphify binary might shadow the
307
+ // currently-installed version via PATH. Derived fresh here rather than baking
308
+ // in the cli token resolved at install time.
309
+ const pythonExe = IS_WINDOWS ? 'python' : 'python3';
310
+ const cmd = `[ -f graphify-out/graph.json ] && ${pythonExe} -m graphify global add graphify-out/graph.json --as "$(basename "$PWD")" > /dev/null 2>&1 || true`;
311
+
312
+ settings.hooks.SessionStart.push({
313
+ description: MARKER,
314
+ hooks: [{ type: 'command', command: cmd }],
315
+ });
316
+
317
+ mkdirSync(claudeDir, { recursive: true });
318
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
319
+ }
320
+
321
+ // Write the graphify-global MCP server entry to ~/.claude/settings.json so Claude Code
322
+ // auto-starts it on launch. Uses `python -m graphify.serve` to avoid PATH issues with
323
+ // stale graphify.exe binaries that may shadow the currently installed version.
324
+ //
325
+ // The command is a single stable token — `python` on Windows, `python3` on POSIX —
326
+ // so the JSON args array is always parseable (py.cmd can be `py -3` which has a space
327
+ // and would break if used directly as the command field).
328
+ export function installGraphifyMcpServer(homeDir) {
329
+ const claudeDir = join(homeDir, '.claude');
330
+ if (!existsSync(claudeDir)) return;
331
+
332
+ const settingsPath = join(claudeDir, 'settings.json');
333
+ let settings = {};
334
+ if (existsSync(settingsPath)) {
335
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { /* corrupt — start fresh */ }
336
+ }
337
+
338
+ if (!settings.mcpServers) settings.mcpServers = {};
339
+
340
+ // Use join() so the path uses OS-correct separators on all platforms.
341
+ const globalGraphPath = join(homeDir, '.graphify', 'global-graph.json');
342
+ // `python` on Windows (Microsoft Store + standard installer both put it on PATH),
343
+ // `python3` on macOS/Linux (standard convention; `python` may not exist).
344
+ const pythonExe = IS_WINDOWS ? 'python' : 'python3';
345
+
346
+ settings.mcpServers['graphify-global'] = {
347
+ command: pythonExe,
348
+ args: ['-m', 'graphify.serve', globalGraphPath],
349
+ };
350
+
351
+ mkdirSync(claudeDir, { recursive: true });
352
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
353
+ }
354
+
355
+ // After pip install, the CLI binary may not be on PATH (pip --user puts it in a Scripts
356
+ // dir that is often missing from PATH, and on Windows an old binary can shadow the new
357
+ // one). When pip was used, always prefer `python -m <module>` to guarantee we run the
358
+ // just-installed version. For uv/pipx the binary is on PATH — probe with which/where
359
+ // (not --version, since some CLIs don't support that flag and exit non-zero).
360
+ async function resolveCliInvocation(cliCommand, pythonCmd, installer) {
361
+ if (installer?.name === 'pip') {
362
+ return `${pythonCmd} -m ${cliCommand}`;
363
+ }
364
+ try {
365
+ await execAsync(`${WHICH_CMD} ${cliCommand}`, { timeout: 10000 });
366
+ return cliCommand;
367
+ } catch {
368
+ return `${pythonCmd} -m ${cliCommand}`;
369
+ }
370
+ }
371
+
372
+ async function runPythonCli(integration, key, { silent = false } = {}) {
373
+ const spinner = silent ? null : fmt.spinner();
374
+
375
+ // 1. Detect Python ≥ minPython.
376
+ if (!silent) spinner.start(`Checking for Python ${integration.minPython.major}.${integration.minPython.minor}+...`);
377
+ const py = await detectPython(integration.minPython);
378
+ if (!py) {
379
+ if (!silent) spinner.stop(chalk.yellow('Python not found'));
380
+ recordSkipped(key, 'python-cli', 'python-not-found');
381
+ if (!silent) {
382
+ fmt.logWarn(
383
+ `${integration.label} skipped — Python ${integration.minPython.major}.${integration.minPython.minor}+ required.`,
384
+ );
385
+ fmt.note(
386
+ `Install Python from https://www.python.org/downloads/ and re-run \`aw init\` to retry.`,
387
+ 'Skipped',
388
+ );
389
+ }
390
+ return false;
391
+ }
392
+ if (!silent) spinner.stop(`✓ Python ${py.version} found`);
393
+
394
+ // 2. Pick installer (uv > pipx > pip --user).
395
+ const installer = await pickPythonInstaller(py.cmd);
396
+
397
+ // 3. Install the package.
398
+ if (!silent) spinner.start(`Installing ${integration.pipPackage} via ${installer.name}...`);
399
+ try {
400
+ await execAsync(installer.build(integration.pipPackage), {
401
+ timeout: 5 * 60 * 1000,
402
+ maxBuffer: 20 * 1024 * 1024,
403
+ });
404
+ if (!silent) spinner.stop('✓ Package installed');
405
+ } catch (e) {
406
+ if (!silent) spinner.stop(chalk.yellow('Package install failed'));
407
+ throw new Error(`${installer.name} install failed: ${e.message}`);
408
+ }
409
+
410
+ // 4. Resolve the CLI invocation (handles pip --user PATH gaps).
411
+ // Pass installer so pip-installed tools always use `python -m` (avoids Windows
412
+ // PATH shadowing where an old binary intercepts the command).
413
+ const cli = await resolveCliInvocation(integration.cliCommand, py.cmd, installer);
414
+
415
+ // 5. Run global post-install hooks (e.g. `graphify install` registers the skill).
416
+ for (const args of integration.postInstall || []) {
417
+ let resolvedArgs = [...args];
418
+ // graphify install needs --platform windows on Windows so the SKILL.md it writes
419
+ // uses PowerShell syntax instead of bash — without this the skill fails on first use.
420
+ if (integration.cliCommand === 'graphify' && resolvedArgs[0] === 'install' && IS_WINDOWS) {
421
+ resolvedArgs = [...resolvedArgs, '--platform', 'windows'];
422
+ }
423
+ const cmd = `${cli} ${resolvedArgs.join(' ')}`;
424
+ try {
425
+ await execAsync(cmd, { timeout: 60 * 1000, maxBuffer: 10 * 1024 * 1024 });
426
+ } catch (e) {
427
+ if (!silent) fmt.logWarn(`${cmd} failed: ${e.message}`);
428
+ // Non-fatal — global registration may already exist.
429
+ }
430
+ }
431
+
432
+ // 5b. For graphify: wire up the global MCP server and the SessionStart hook that
433
+ // auto-registers any built graph into ~/.graphify/global-graph.json on IDE open.
434
+ // The graph itself is built manually via `/graphify .` — graphify's own per-IDE
435
+ // install (step 6 below) writes the CLAUDE.md / AGENTS.md sections for that.
436
+ if (integration.cliCommand === 'graphify') {
437
+ installGraphifyMcpServer(HOME);
438
+ installGraphifyGlobalAddHook(HOME);
439
+ }
440
+
441
+ // 6. Run per-project hooks if cwd looks like a real project (not HOME).
442
+ const cwd = process.cwd();
443
+ const isHome = cwd === HOME;
444
+ const hasGit = existsSync(join(cwd, '.git'));
445
+ if (!isHome) {
446
+ for (const step of integration.perProjectInstall || []) {
447
+ if (step.requiresGit && !hasGit) continue;
448
+ // Skip IDE-specific steps when that IDE is not configured on this machine.
449
+ if (step.requiresIde && !existsSync(join(HOME, step.requiresIde))) continue;
450
+ // Skip steps that require a specific file to exist in the project (e.g. graph.json).
451
+ if (step.requiresFile && !existsSync(join(cwd, step.requiresFile))) continue;
452
+ const args = step.appendCwdBasename ? [...step.args, basename(cwd)] : step.args;
453
+ const cmd = `${cli} ${args.join(' ')}`;
454
+ try {
455
+ await execAsync(cmd, { cwd, timeout: 5 * 60 * 1000, maxBuffer: 10 * 1024 * 1024 });
456
+ } catch (e) {
457
+ if (!silent) fmt.logWarn(`${cmd} failed: ${e.message}`);
458
+ // Non-fatal — user can re-run later.
459
+ }
460
+ }
461
+ }
462
+
463
+ return true;
464
+ }
465
+
466
+ // ────────────────────────────────────────────────────────────────────────────────
467
+ // PLUGIN INSTALLER (claude plugin install)
468
+ // ────────────────────────────────────────────────────────────────────────────────
469
+
470
+ async function runClaudePlugin(installCmd, { silent = false, marketplaceSource = null } = {}) {
471
+ const spinner = silent ? null : fmt.spinner();
472
+
473
+ try {
474
+ if (!silent) spinner.start(`Installing ${installCmd}...`);
475
+
476
+ // Check if claude CLI is available (cross-platform)
477
+ try {
478
+ execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'ignore' });
479
+ } catch {
480
+ if (!silent) {
481
+ spinner.stop(chalk.yellow('Claude CLI not found'));
482
+ }
483
+ throw new Error('claude: command not found (install Claude Code first)');
484
+ }
485
+
486
+ // Some plugins require a marketplace add step before install
487
+ if (marketplaceSource) {
488
+ await execAsync(`claude plugin marketplace add ${marketplaceSource}`, {
489
+ timeout: 60000,
490
+ maxBuffer: 10 * 1024 * 1024,
491
+ });
492
+ }
493
+
494
+ // Run the install
495
+ await execAsync(`claude plugin install ${installCmd}`, {
496
+ timeout: 120000,
497
+ maxBuffer: 10 * 1024 * 1024,
498
+ });
499
+
500
+ if (!silent) spinner.stop('✓ Installed');
501
+ return true;
502
+ } catch (e) {
503
+ if (!silent) spinner.stop(chalk.yellow('Installation failed'));
504
+ throw e;
505
+ }
506
+ }
507
+
508
+ // ────────────────────────────────────────────────────────────────────────────────
509
+ // UNIVERSAL INSTALLER (OS-detected master script — covers all IDEs in one shot)
510
+ // ────────────────────────────────────────────────────────────────────────────────
511
+
512
+ async function runUniversalInstaller(integration, key, { silent = false } = {}) {
513
+ const spinner = silent ? null : fmt.spinner();
514
+
515
+ if (!silent) spinner.start(`Installing ${integration.label} for all detected IDEs...`);
516
+
517
+ try {
518
+ let cmd;
519
+
520
+ // If no scripts defined at all, skip binary install and go straight to postInstall
521
+ const hasScript = integration.scripts?.win32 || integration.scripts?.posix;
522
+
523
+ if (!hasScript && IS_WINDOWS && integration.npmPackage) {
524
+ // npm-only tool on Windows (e.g. Skills)
525
+ if (!silent) fmt.logWarn(`No binary installer — using npm for ${integration.label}`);
526
+ cmd = `npm install -g ${integration.npmPackage}`;
527
+ } else if (!hasScript) {
528
+ // No binary, no npm — skip straight to postInstall (e.g. npx-only tools)
529
+ cmd = null;
530
+ } else if (IS_WINDOWS) {
531
+ if (integration.scripts.win32) {
532
+ cmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm '${integration.scripts.win32}' | iex"`;
533
+ } else {
534
+ // No Windows script — try WSL first, then npm fallback
535
+ let wslOk = false;
536
+ try {
537
+ execSync('wsl --status', { stdio: 'ignore' });
538
+ // Verify bash is actually available inside WSL
539
+ execSync('wsl -- bash --version', { stdio: 'ignore' });
540
+ wslOk = true;
541
+ } catch { /* WSL absent or bash not installed */ }
542
+
543
+ if (wslOk) {
544
+ cmd = `wsl -- bash -c "curl -fsSL '${integration.scripts.posix}' | sh"`;
545
+ } else if (integration.npmPackage) {
546
+ if (!silent) fmt.logWarn('WSL/bash not available — installing via npm instead');
547
+ cmd = `npm install -g ${integration.npmPackage}`;
548
+ } else {
549
+ if (!silent) {
550
+ spinner.stop(chalk.yellow('WSL not found'));
551
+ fmt.logWarn(
552
+ `${integration.label} requires WSL on Windows for full support.\n` +
553
+ ` Install WSL: https://learn.microsoft.com/en-us/windows/wsl/install`,
554
+ 'Skipped',
555
+ );
556
+ }
557
+ recordSkipped(key, 'universal-installer', 'wsl-not-found');
558
+ return false;
559
+ }
560
+ }
561
+ } else {
562
+ cmd = `curl -fsSL '${integration.scripts.posix}' | bash`;
563
+ }
564
+
565
+ if (cmd) {
566
+ await execAsync(cmd, {
567
+ timeout: 5 * 60 * 1000,
568
+ maxBuffer: 20 * 1024 * 1024,
569
+ });
570
+ }
571
+
572
+ // Run post-install commands (e.g. rtk init -g to wire the agent hook)
573
+ for (const postCmd of integration.postInstall || []) {
574
+ let finalPostCmd = postCmd;
575
+ // If on Windows and we used WSL for the main install, we must use it for the post command too
576
+ if (IS_WINDOWS && cmd && cmd.startsWith('wsl')) {
577
+ finalPostCmd = `wsl -- ${postCmd}`;
578
+ }
579
+
580
+ try {
581
+ await execAsync(finalPostCmd, { timeout: 60 * 1000, maxBuffer: 10 * 1024 * 1024 });
582
+ } catch (e) {
583
+ if (!silent) fmt.logWarn(`Post-install step failed: ${finalPostCmd} — ${e.message}`);
584
+ // Non-fatal — user can re-run later
585
+ }
586
+ }
587
+
588
+ if (!silent) spinner.stop('✓ Installed');
589
+ return true;
590
+ } catch (e) {
591
+ if (!silent) spinner.stop(chalk.yellow('Installation failed'));
592
+ throw e;
593
+ }
594
+ }
595
+
596
+ // ────────────────────────────────────────────────────────────────────────────────
597
+ // INTEGRATION INSTALLER
598
+ // ────────────────────────────────────────────────────────────────────────────────
599
+
600
+ export async function installIntegration(key, { silent = false } = {}) {
601
+ const integration = INTEGRATIONS[key];
602
+ if (!integration) {
603
+ throw new Error(`Unknown integration: ${key}`);
604
+ }
605
+
606
+ if (isInstalled(key)) {
607
+ if (!silent) fmt.logWarn(`${integration.label} is already installed`);
608
+ return false;
609
+ }
610
+
611
+ try {
612
+ if (integration.type === 'plugin') {
613
+ // PLUGIN: Run claude plugin install
614
+ await runClaudePlugin(integration.installCmd, { silent, marketplaceSource: integration.marketplaceSource ?? null });
615
+ recordInstalled(key, 'plugin');
616
+
617
+ if (!silent) {
618
+ fmt.logSuccess(`${integration.label} plugin installed`);
619
+ if (integration.authNote) {
620
+ fmt.note(integration.authNote, 'Next Step');
621
+ }
622
+ }
623
+ } else if (integration.type === 'remote-mcp') {
624
+ // REMOTE MCP: Add to mcp.json files
625
+ if (!silent) fmt.logStep(`Adding ${integration.label} MCP server...`);
626
+
627
+ const config = { type: 'http', url: integration.mcpUrl };
628
+
629
+ // Claude Code: ~/.claude.json
630
+ addToJsonMcp(
631
+ join(HOME, '.claude.json'),
632
+ key,
633
+ config
634
+ );
635
+
636
+ // Cursor: ~/.cursor/mcp.json
637
+ addToJsonMcp(
638
+ join(HOME, '.cursor', 'mcp.json'),
639
+ key,
640
+ config
641
+ );
642
+
643
+ // Codex: ~/.codex/config.toml
644
+ addToTomlMcp(
645
+ join(HOME, '.codex', 'config.toml'),
646
+ key,
647
+ integration.mcpUrl
648
+ );
649
+
650
+ recordInstalled(key, 'remote-mcp');
651
+
652
+ if (!silent) {
653
+ fmt.logSuccess(`${integration.label} added to MCP servers`);
654
+ if (integration.authNote) {
655
+ fmt.note(integration.authNote, 'Note');
656
+ }
657
+ }
658
+ } else if (integration.type === 'python-cli') {
659
+ // PYTHON CLI: pip-install + run post-install + per-project hooks
660
+ const ok = await runPythonCli(integration, key, { silent });
661
+ if (!ok) return false; // skipped (e.g. Python missing) — manifest already recorded
662
+
663
+ recordInstalled(key, 'python-cli');
664
+
665
+ if (!silent) {
666
+ fmt.logSuccess(`${integration.label} installed`);
667
+ if (integration.authNote) {
668
+ fmt.note(integration.authNote, 'Note');
669
+ }
670
+ }
671
+ } else if (integration.type === 'universal-installer') {
672
+ // UNIVERSAL INSTALLER: OS-detected master script, covers all IDEs in one shot
673
+ const ok = await runUniversalInstaller(integration, key, { silent });
674
+ if (!ok) return false; // skipped (e.g. WSL not found) — manifest already recorded
675
+ recordInstalled(key, 'universal-installer');
676
+
677
+ if (!silent) {
678
+ fmt.logSuccess(`${integration.label} installed across all detected IDEs`);
679
+ if (integration.authNote) {
680
+ fmt.note(integration.authNote, 'Note');
681
+ }
682
+ }
683
+ }
684
+
685
+ return true;
686
+ } catch (e) {
687
+ if (!silent) {
688
+ fmt.logError(`Failed to install ${integration.label}: ${e.message}`);
689
+ }
690
+ // Don't record as installed if it failed
691
+ return false;
692
+ }
693
+ }
694
+
695
+ // ────────────────────────────────────────────────────────────────────────────────
696
+ // INTEGRATION REMOVER
697
+ // ────────────────────────────────────────────────────────────────────────────────
698
+
699
+ export async function removeIntegration(key, { silent = false } = {}) {
700
+ const integration = INTEGRATIONS[key];
701
+ if (!integration) {
702
+ throw new Error(`Unknown integration: ${key}`);
703
+ }
704
+
705
+ if (!isInstalled(key)) {
706
+ if (!silent) fmt.logWarn(`${integration.label} is not installed`);
707
+ return false;
708
+ }
709
+
710
+ try {
711
+ if (integration.type === 'plugin') {
712
+ // PLUGIN: Manual removal instruction
713
+ if (!silent) {
714
+ fmt.logWarn(
715
+ `To remove the ${integration.label} plugin, run: /plugin in Claude Code and disable it`,
716
+ 'Manual Removal'
717
+ );
718
+ }
719
+ } else if (integration.type === 'remote-mcp') {
720
+ // REMOTE MCP: Remove from mcp.json files
721
+ if (!silent) fmt.logStep(`Removing ${integration.label} MCP server...`);
722
+
723
+ // Claude Code
724
+ try {
725
+ const claudePath = join(HOME, '.claude.json');
726
+ if (existsSync(claudePath)) {
727
+ const config = JSON.parse(readFileSync(claudePath, 'utf8'));
728
+ delete config.mcpServers?.[key];
729
+ writeFileSync(claudePath, JSON.stringify(config, null, 2) + '\n');
730
+ }
731
+ } catch {
732
+ // Best effort
733
+ }
734
+
735
+ // Cursor
736
+ try {
737
+ const cursorPath = join(HOME, '.cursor', 'mcp.json');
738
+ if (existsSync(cursorPath)) {
739
+ const config = JSON.parse(readFileSync(cursorPath, 'utf8'));
740
+ delete config.mcpServers?.[key];
741
+ writeFileSync(cursorPath, JSON.stringify(config, null, 2) + '\n');
742
+ }
743
+ } catch {
744
+ // Best effort
745
+ }
746
+
747
+ // Codex
748
+ try {
749
+ const codexPath = join(HOME, '.codex', 'config.toml');
750
+ if (existsSync(codexPath)) {
751
+ let content = readFileSync(codexPath, 'utf8');
752
+ const blockRegex = new RegExp(
753
+ `\\[mcp_servers\\.${key}\\][^\\[]*`,
754
+ 'g'
755
+ );
756
+ content = content.replace(blockRegex, '');
757
+ writeFileSync(codexPath, content);
758
+ }
759
+ } catch {
760
+ // Best effort
761
+ }
762
+
763
+ if (!silent) fmt.logSuccess(`${integration.label} removed from MCP servers`);
764
+ } else if (integration.type === 'python-cli') {
765
+ // PYTHON CLI: only remove the manifest entry — leave the pip package installed
766
+ // because the user may use the CLI outside of `aw`. Print manual cleanup hints.
767
+ if (!silent) {
768
+ fmt.logWarn(
769
+ `Removed ${integration.label} from the aw manifest. The Python package was left installed.\n` +
770
+ ` To fully remove, run in each project: \`${integration.cliCommand} claude uninstall\` and \`${integration.cliCommand} hook uninstall\`\n` +
771
+ ` Then uninstall the package: \`pip uninstall ${integration.pipPackage.split(/[<>=]/)[0]}\` (or \`uv tool uninstall\` / \`pipx uninstall\`)`,
772
+ 'Manual Cleanup',
773
+ );
774
+ }
775
+ } else if (integration.type === 'universal-installer') {
776
+ if (!silent) {
777
+ fmt.logWarn(
778
+ `Removed ${integration.label} from the aw manifest.\n` +
779
+ ` To fully uninstall: follow the uninstall instructions for ${integration.label} in its documentation.`,
780
+ 'Manual Cleanup',
781
+ );
782
+ }
783
+ }
784
+
785
+ // Remove from manifest
786
+ const manifest = readManifest();
787
+ delete manifest.installed[key];
788
+ writeManifest(manifest);
789
+
790
+ return true;
791
+ } catch (e) {
792
+ if (!silent) {
793
+ fmt.logError(`Failed to remove ${integration.label}: ${e.message}`);
794
+ }
795
+ return false;
796
+ }
797
+ }
798
+
799
+ // ────────────────────────────────────────────────────────────────────────────────
800
+ // HELPERS
801
+ // ────────────────────────────────────────────────────────────────────────────────
802
+
803
+ export function getInstalledList() {
804
+ const manifest = readManifest();
805
+ // Only return entries actually installed — skipped entries (e.g. Python missing)
806
+ // shouldn't show up as "installed" to the rest of the system.
807
+ return Object.entries(manifest.installed)
808
+ .filter(([, entry]) => entry.status !== 'skipped')
809
+ .map(([key]) => key);
810
+ }
811
+
812
+ export function suggestForTeam(namespace) {
813
+ if (!namespace) return [];
814
+
815
+ const team = namespace.split('/')[0]; // e.g. 'platform' from 'platform/crm'
816
+
817
+ return Object.entries(INTEGRATIONS)
818
+ .filter(([, integration]) => {
819
+ // Show if: team is in the integration's teams list OR teams list is empty (universal)
820
+ return (
821
+ integration.teams.length === 0 || integration.teams.includes(team)
822
+ );
823
+ })
824
+ .map(([key]) => key);
825
+ }
826
+
827
+ // ────────────────────────────────────────────────────────────────────────────────
828
+ // AUTO-INSTALL (called from init.mjs - installs suggested integrations)
829
+ // ────────────────────────────────────────────────────────────────────────────────
830
+
831
+ export async function autoInstallIntegrations(team, { silent = false, installer = installIntegration } = {}) {
832
+ // Get suggested integrations for this team
833
+ const suggested = suggestForTeam(team);
834
+ if (!suggested || suggested.length === 0) {
835
+ return [];
836
+ }
837
+
838
+ // Only install if they're not already installed
839
+ const toInstall = suggested.filter((key) => !isInstalled(key));
840
+ if (toInstall.length === 0) {
841
+ return [];
842
+ }
843
+
844
+ if (!silent) {
845
+ fmt.logStep(`Setting up integrations for ${team}...`);
846
+ }
847
+
848
+ const installed = [];
849
+ for (const key of toInstall) {
850
+ const success = await installer(key, { silent });
851
+ if (success) {
852
+ installed.push(INTEGRATIONS[key].label);
853
+ }
854
+ }
855
+
856
+ return installed;
857
+ }
858
+
859
+ // ────────────────────────────────────────────────────────────────────────────────
860
+ // INTERACTIVE SETUP (can be called via: aw integrations)
861
+ // ────────────────────────────────────────────────────────────────────────────────
862
+
863
+ export async function promptAndInstall(team, { silent = false } = {}) {
864
+ // Skip if: silent mode, no TTY, or all integrations already installed
865
+ if (silent || !process.stdin.isTTY) {
866
+ return [];
867
+ }
868
+
869
+ const suggested = suggestForTeam(team);
870
+ if (!suggested || suggested.length === 0) {
871
+ return [];
872
+ }
873
+
874
+ // Check if any suggested integrations are NOT installed
875
+ const availableToInstall = suggested.filter((key) => !isInstalled(key));
876
+ if (availableToInstall.length === 0) {
877
+ // All already installed
878
+ return [];
879
+ }
880
+
881
+ // Prompt user
882
+ const p = await import('@clack/prompts');
883
+ const shouldSetup = await p.default.confirm({
884
+ message: `Your team (${team}) can use integrations like Codex, Caveman, etc. Set up any now?`,
885
+ initialValue: false,
886
+ });
887
+
888
+ if (p.default.isCancel(shouldSetup) || !shouldSetup) {
889
+ return [];
890
+ }
891
+
892
+ // Show bundles + individual tools
893
+ const bundleOptions = Object.entries(BUNDLES)
894
+ .filter(([, bundle]) => {
895
+ return (
896
+ bundle.teams.length === 0 || bundle.teams.includes(team.split('/')[0])
897
+ );
898
+ })
899
+ .map(([bundleKey, bundle]) => ({
900
+ value: `bundle:${bundleKey}`,
901
+ label: `📦 ${bundle.label}`,
902
+ description: bundle.description,
903
+ }));
904
+
905
+ const individualOptions = availableToInstall.map((key) => {
906
+ const integration = INTEGRATIONS[key];
907
+ const icon =
908
+ integration.type === 'plugin' ? '🔌'
909
+ : integration.type === 'remote-mcp' ? '🌐'
910
+ : integration.type === 'python-cli' ? '🐍'
911
+ : '⚙️';
912
+ return {
913
+ value: `integration:${key}`,
914
+ label: `${icon} ${integration.label}`,
915
+ description: integration.description,
916
+ };
917
+ });
918
+
919
+ const choices = [...bundleOptions, ...individualOptions];
920
+
921
+ const selected = await p.default.multiselect({
922
+ message: 'Select integrations to install:',
923
+ options: choices,
924
+ required: false,
925
+ });
926
+
927
+ if (p.default.isCancel(selected)) {
928
+ return [];
929
+ }
930
+
931
+ // Process selections
932
+ const toInstall = new Set();
933
+ for (const selection of selected) {
934
+ if (selection.startsWith('bundle:')) {
935
+ const bundleKey = selection.replace('bundle:', '');
936
+ const bundle = BUNDLES[bundleKey];
937
+ bundle.includes.forEach((key) => toInstall.add(key));
938
+ } else if (selection.startsWith('integration:')) {
939
+ const integrationKey = selection.replace('integration:', '');
940
+ toInstall.add(integrationKey);
941
+ }
942
+ }
943
+
944
+ // Install all
945
+ const installed = [];
946
+ for (const key of toInstall) {
947
+ const success = await installIntegration(key, { silent: false });
948
+ if (success) {
949
+ installed.push(INTEGRATIONS[key].label);
950
+ }
951
+ }
952
+
953
+ return installed;
954
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.45",
3
+ "version": "0.1.47-beta.0",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "hooks/",
35
35
  "startup.mjs",
36
36
  "ecc.mjs",
37
+ "integrations.mjs",
37
38
  "render-rules.mjs",
38
39
  "telemetry.mjs"
39
40
  ],
@@ -51,9 +52,9 @@
51
52
  "license": "MIT",
52
53
  "scripts": {
53
54
  "test": "yarn test:vitest && yarn test:node",
54
- "test:vitest": "vitest run --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4",
55
+ "test:vitest": "vitest run --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4 tests/integrations-graphify.test.mjs",
55
56
  "test:node": "node tests/run-node-tests.mjs",
56
- "test:watch": "vitest --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4",
57
+ "test:watch": "vitest --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/c4 tests/integrations-graphify.test.mjs",
57
58
  "preuninstall": "node bin.js nuke 2>/dev/null || true"
58
59
  },
59
60
  "publishConfig": {