@ghl-ai/aw 0.1.44 → 0.1.46

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.
@@ -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
+ }