@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.
- package/c4/templates/scripts/aw-c4-bootstrap.sh +19 -4
- package/cli.mjs +7 -1
- package/commands/init.mjs +17 -0
- package/commands/integrations.mjs +254 -0
- package/integrations.mjs +954 -0
- package/package.json +4 -3
- package/update.mjs +8 -1
package/integrations.mjs
ADDED
|
@@ -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
|
+
}
|