xtrm-cli 0.5.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/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { execSync, exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import kleur from 'kleur';
|
|
7
|
+
// @ts-ignore
|
|
8
|
+
import ora from 'ora';
|
|
9
|
+
import { ensureEnvFile, loadEnvFile, checkRequiredEnvVars, handleMissingEnvVars, getEnvFilePath } from './env-manager.js';
|
|
10
|
+
|
|
11
|
+
export type AgentName = 'claude';
|
|
12
|
+
|
|
13
|
+
interface AgentCLI {
|
|
14
|
+
command: string;
|
|
15
|
+
listArgs: string[];
|
|
16
|
+
addStdio: (name: string, cmd: string, args?: string[], env?: Record<string, string>) => string[];
|
|
17
|
+
addHttp: (name: string, url: string, headers?: Record<string, string>) => string[];
|
|
18
|
+
addSse: (name: string, url: string) => string[];
|
|
19
|
+
remove: (name: string) => string[];
|
|
20
|
+
parseList: (output: string) => string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const AGENT_CLI: Record<AgentName, AgentCLI> = {
|
|
24
|
+
claude: {
|
|
25
|
+
command: 'claude',
|
|
26
|
+
listArgs: ['mcp', 'list'],
|
|
27
|
+
addStdio: (name, cmd, args, env) => {
|
|
28
|
+
const base = ['mcp', 'add', '-s', 'user', name, '--'];
|
|
29
|
+
if (env && Object.keys(env).length > 0) {
|
|
30
|
+
for (const [key, value] of Object.entries(env)) {
|
|
31
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
base.push(cmd, ...(args || []));
|
|
35
|
+
return base;
|
|
36
|
+
},
|
|
37
|
+
addHttp: (name, url, headers) => {
|
|
38
|
+
const base = ['mcp', 'add', '-s', 'user', '--transport', 'http', name, url];
|
|
39
|
+
if (headers) {
|
|
40
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
41
|
+
base.push('--header', `${key}: ${resolveEnvVar(value)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return base;
|
|
45
|
+
},
|
|
46
|
+
addSse: (name, url) => {
|
|
47
|
+
return ['mcp', 'add', '-s', 'user', '--transport', 'sse', name, url];
|
|
48
|
+
},
|
|
49
|
+
remove: (name) => ['mcp', 'remove', '-s', 'user', name],
|
|
50
|
+
parseList: (output) => parseMcpListOutput(output, /^([a-zA-Z0-9_-]+):/)
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Strip ANSI escape codes (e.g. qwen wraps ✓ in color codes)
|
|
55
|
+
function stripAnsi(str: string): string {
|
|
56
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseMcpListOutput(output: string, pattern: RegExp): string[] {
|
|
60
|
+
const servers: string[] = [];
|
|
61
|
+
for (const line of output.split('\n')) {
|
|
62
|
+
const match = stripAnsi(line).match(pattern);
|
|
63
|
+
if (match) {
|
|
64
|
+
servers.push(match[1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return servers;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveEnvVar(value: string): string {
|
|
71
|
+
if (typeof value !== 'string') return value;
|
|
72
|
+
|
|
73
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_match, envName) => {
|
|
74
|
+
const upperName = envName.toUpperCase();
|
|
75
|
+
const envValue = process.env[upperName];
|
|
76
|
+
if (envValue) {
|
|
77
|
+
return envValue;
|
|
78
|
+
} else {
|
|
79
|
+
console.warn(kleur.yellow(` ⚠️ Environment variable ${upperName} is not set in ${getEnvFilePath()}`));
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function detectAgent(systemRoot: string): AgentName | null {
|
|
86
|
+
const normalizedRoot = systemRoot.replace(/\\/g, '/').toLowerCase();
|
|
87
|
+
if (normalizedRoot.includes('.claude') || normalizedRoot.includes('/claude')) {
|
|
88
|
+
return 'claude';
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildAddCommand(agent: AgentName, name: string, server: any): string[] | null {
|
|
94
|
+
const cli = AGENT_CLI[agent];
|
|
95
|
+
if (!cli) return null;
|
|
96
|
+
|
|
97
|
+
if (server.url || server.serverUrl) {
|
|
98
|
+
const url = server.url || server.serverUrl;
|
|
99
|
+
const type = server.type || (url.includes('/sse') ? 'sse' : 'http');
|
|
100
|
+
|
|
101
|
+
if (type === 'sse') {
|
|
102
|
+
return cli.addSse(name, url);
|
|
103
|
+
} else {
|
|
104
|
+
return cli.addHttp(name, url, server.headers);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (server.command) {
|
|
109
|
+
return cli.addStdio(name, server.command, server.args, server.env);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.warn(kleur.yellow(` ⚠️ Skipping server "${name}": Unknown configuration`));
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface CommandResult {
|
|
117
|
+
success: boolean;
|
|
118
|
+
dryRun?: boolean;
|
|
119
|
+
skipped?: boolean;
|
|
120
|
+
error?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function executeCommand(agent: AgentName, args: string[], dryRun: boolean = false, displayName?: string): CommandResult {
|
|
124
|
+
const cli = AGENT_CLI[agent];
|
|
125
|
+
|
|
126
|
+
const quotedArgs = args.map(arg => {
|
|
127
|
+
if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
|
|
128
|
+
return `"${arg}"`;
|
|
129
|
+
}
|
|
130
|
+
return arg;
|
|
131
|
+
});
|
|
132
|
+
const command = `${cli.command} ${quotedArgs.join(' ')}`;
|
|
133
|
+
|
|
134
|
+
if (dryRun) {
|
|
135
|
+
console.log(kleur.cyan(` [DRY RUN] ${displayName ?? args.slice(2).join(' ')}`));
|
|
136
|
+
return { success: true, dryRun: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
execSync(command, { stdio: 'pipe', timeout: 10000 });
|
|
141
|
+
console.log(kleur.green(` ✓ ${displayName ?? args.slice(2).join(' ')}`));
|
|
142
|
+
return { success: true };
|
|
143
|
+
} catch (error: any) {
|
|
144
|
+
const stderr = error.stderr?.toString() || error.message;
|
|
145
|
+
|
|
146
|
+
if (stderr.includes('already exists') || stderr.includes('already configured')) {
|
|
147
|
+
let serverName = 'unknown';
|
|
148
|
+
if (agent === 'claude') {
|
|
149
|
+
const addIndex = args.indexOf('add');
|
|
150
|
+
for (let i = addIndex + 1; i < args.length; i++) {
|
|
151
|
+
const arg = args[i];
|
|
152
|
+
if (arg === '--') continue;
|
|
153
|
+
if (arg.startsWith('-')) continue;
|
|
154
|
+
if (['local', 'user', 'project', 'http', 'sse', 'stdio'].includes(arg)) continue;
|
|
155
|
+
serverName = arg;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
console.log(kleur.dim(` ✓ ${serverName} (already configured)`));
|
|
160
|
+
return { success: true, skipped: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(kleur.red(` ✗ Failed: ${stderr.trim()}`));
|
|
164
|
+
return { success: false, error: stderr };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function getCurrentServers(agent: AgentName): string[] {
|
|
169
|
+
const cli = AGENT_CLI[agent];
|
|
170
|
+
try {
|
|
171
|
+
const output = execSync(`${cli.command} ${cli.listArgs.join(' ')}`, {
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
174
|
+
});
|
|
175
|
+
return cli.parseList(output);
|
|
176
|
+
} catch (error: any) {
|
|
177
|
+
// Some CLIs (e.g. gemini) write server list to stderr
|
|
178
|
+
const combined = (error.stdout || '') + '\n' + (error.stderr || '');
|
|
179
|
+
return cli.parseList(combined);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function getCurrentServersAsync(agent: AgentName): Promise<string[]> {
|
|
184
|
+
const cli = AGENT_CLI[agent];
|
|
185
|
+
try {
|
|
186
|
+
const { stdout, stderr } = await execAsync(`${cli.command} ${cli.listArgs.join(' ')}`, {
|
|
187
|
+
timeout: 10000,
|
|
188
|
+
});
|
|
189
|
+
// Some CLIs (e.g. gemini) write server list to stderr
|
|
190
|
+
return cli.parseList(stdout + '\n' + stderr);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract ${VAR_NAME} references from a list of server config objects
|
|
198
|
+
*/
|
|
199
|
+
function getServerEnvVarNames(servers: any[]): string[] {
|
|
200
|
+
const vars = new Set<string>();
|
|
201
|
+
const pattern = /\$\{([A-Z0-9_]+)\}/g;
|
|
202
|
+
for (const server of servers) {
|
|
203
|
+
const json = JSON.stringify(server);
|
|
204
|
+
let match;
|
|
205
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
206
|
+
while ((match = re.exec(json)) !== null) {
|
|
207
|
+
vars.add(match[1]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return Array.from(vars);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Sync MCP servers to an agent using official CLI
|
|
215
|
+
*/
|
|
216
|
+
// Prevents ensureEnvFile/loadEnvFile from firing once per target directory
|
|
217
|
+
let envInitialized = false;
|
|
218
|
+
|
|
219
|
+
export async function syncMcpServersWithCli(
|
|
220
|
+
agent: AgentName,
|
|
221
|
+
mcpConfig: any,
|
|
222
|
+
dryRun: boolean = false,
|
|
223
|
+
prune: boolean = false
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const cli = AGENT_CLI[agent];
|
|
226
|
+
if (!cli) {
|
|
227
|
+
console.log(kleur.yellow(` ⚠️ Unsupported agent: ${agent}`));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!envInitialized) {
|
|
232
|
+
ensureEnvFile();
|
|
233
|
+
loadEnvFile();
|
|
234
|
+
envInitialized = true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const spinner = ora({ text: kleur.dim(' checking installed servers…'), indent: 2 }).start();
|
|
238
|
+
const currentServers = await getCurrentServersAsync(agent);
|
|
239
|
+
spinner.stop();
|
|
240
|
+
const currentServersSet = new Set(currentServers);
|
|
241
|
+
const canonicalServers = new Set(Object.keys(mcpConfig.mcpServers || {}));
|
|
242
|
+
|
|
243
|
+
if (prune) {
|
|
244
|
+
const toRemove = currentServers.filter(s => !canonicalServers.has(s));
|
|
245
|
+
if (toRemove.length > 0) {
|
|
246
|
+
console.log(kleur.red(` Pruning ${toRemove.length} server(s)...`));
|
|
247
|
+
for (const serverName of toRemove) {
|
|
248
|
+
executeCommand(agent, cli.remove(serverName), dryRun);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Determine which servers actually need to be added
|
|
254
|
+
const toAdd = Object.entries(mcpConfig.mcpServers || {}).filter(([name]) => !currentServersSet.has(name));
|
|
255
|
+
const skippedCount = canonicalServers.size - toAdd.length;
|
|
256
|
+
|
|
257
|
+
if (toAdd.length === 0) {
|
|
258
|
+
// Nothing to add — skip env warning, no CLI calls needed
|
|
259
|
+
console.log(kleur.dim(` ✓ ${skippedCount} server(s) already installed`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Step 1: Multiselect — all servers pre-selected, user can deselect with space
|
|
264
|
+
let selectedNames: string[] = toAdd.map(([name]) => name);
|
|
265
|
+
|
|
266
|
+
if (!dryRun) {
|
|
267
|
+
// @ts-ignore
|
|
268
|
+
const prompts = await import('prompts');
|
|
269
|
+
const { selected } = await prompts.default({
|
|
270
|
+
type: 'multiselect',
|
|
271
|
+
name: 'selected',
|
|
272
|
+
message: `Select MCP servers to install via ${agent} CLI:`,
|
|
273
|
+
choices: toAdd.map(([name, server]: [string, any]) => ({
|
|
274
|
+
title: name,
|
|
275
|
+
description: (server as any)._notes?.description || '',
|
|
276
|
+
value: name,
|
|
277
|
+
selected: true
|
|
278
|
+
})),
|
|
279
|
+
hint: '- Space to toggle. Enter to confirm.',
|
|
280
|
+
instructions: false
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (!selected || selected.length === 0) {
|
|
284
|
+
console.log(kleur.gray(' Skipped MCP installation.'));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
selectedNames = selected;
|
|
289
|
+
|
|
290
|
+
// Step 2: Only ask for env vars needed by the selected servers
|
|
291
|
+
const selectedEntries = toAdd.filter(([name]) => new Set(selectedNames).has(name));
|
|
292
|
+
const neededVarNames = getServerEnvVarNames(selectedEntries.map(([, s]) => s as any));
|
|
293
|
+
const missingEnvVars = checkRequiredEnvVars(neededVarNames);
|
|
294
|
+
if (missingEnvVars.length > 0) {
|
|
295
|
+
const shouldProceed = await handleMissingEnvVars(missingEnvVars);
|
|
296
|
+
if (!shouldProceed) return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Step 3: Build and execute commands for selected servers only
|
|
301
|
+
const selectedSet = new Set(selectedNames);
|
|
302
|
+
const commandsToRun: Array<{ name: string; cmd: string[] }> = [];
|
|
303
|
+
for (const [name, server] of toAdd) {
|
|
304
|
+
if (!selectedSet.has(name)) continue;
|
|
305
|
+
const cmd = buildAddCommand(agent, name, server as any);
|
|
306
|
+
if (cmd) commandsToRun.push({ name, cmd });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (commandsToRun.length === 0) return;
|
|
310
|
+
|
|
311
|
+
let successCount = 0;
|
|
312
|
+
for (const { name, cmd } of commandsToRun) {
|
|
313
|
+
const result = executeCommand(agent, cmd, dryRun, name);
|
|
314
|
+
if (result.success && !result.skipped) {
|
|
315
|
+
successCount++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (skippedCount > 0) {
|
|
320
|
+
console.log(kleur.dim(` ✓ ${skippedCount} already installed, ${successCount} added`));
|
|
321
|
+
} else {
|
|
322
|
+
console.log(kleur.green(` ✓ ${successCount} server(s) added`));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Load canonical MCP config from repository
|
|
328
|
+
*/
|
|
329
|
+
export function loadCanonicalMcpConfig(repoRoot: string, includeOptional: boolean = false): any {
|
|
330
|
+
const corePath = path.join(repoRoot, 'config', 'mcp_servers.json');
|
|
331
|
+
const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
|
|
332
|
+
|
|
333
|
+
const config: any = { mcpServers: {} };
|
|
334
|
+
|
|
335
|
+
if (fs.existsSync(corePath)) {
|
|
336
|
+
const core = fs.readJsonSync(corePath);
|
|
337
|
+
config.mcpServers = { ...config.mcpServers, ...core.mcpServers };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (includeOptional && fs.existsSync(optionalPath)) {
|
|
341
|
+
const optional = fs.readJsonSync(optionalPath);
|
|
342
|
+
config.mcpServers = { ...config.mcpServers, ...optional.mcpServers };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return config;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Prompt user to select optional MCP servers
|
|
350
|
+
*/
|
|
351
|
+
export async function promptOptionalServers(repoRoot: string): Promise<string[] | false> {
|
|
352
|
+
const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
|
|
353
|
+
|
|
354
|
+
if (!fs.existsSync(optionalPath)) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const optional = fs.readJsonSync(optionalPath);
|
|
359
|
+
const servers = Object.entries(optional.mcpServers || {}).map(([name, server]: [string, any]) => ({
|
|
360
|
+
name,
|
|
361
|
+
description: server._notes?.description || 'No description',
|
|
362
|
+
prerequisite: server._notes?.prerequisite || ''
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
if (servers.length === 0) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// @ts-ignore
|
|
370
|
+
const prompts = await import('prompts');
|
|
371
|
+
|
|
372
|
+
const { selected } = await prompts.default({
|
|
373
|
+
type: 'multiselect',
|
|
374
|
+
name: 'selected',
|
|
375
|
+
message: 'Optional MCP servers available — select to install (space to toggle, enter to confirm):',
|
|
376
|
+
choices: servers.map(s => ({
|
|
377
|
+
title: s.name,
|
|
378
|
+
description: s.prerequisite
|
|
379
|
+
? `${s.description} — ⚠️ ${s.prerequisite}`
|
|
380
|
+
: s.description,
|
|
381
|
+
value: s.name,
|
|
382
|
+
selected: false
|
|
383
|
+
})),
|
|
384
|
+
hint: '- Space to select. Enter to skip or confirm.',
|
|
385
|
+
instructions: false
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
if (!selected || selected.length === 0) {
|
|
389
|
+
console.log(kleur.gray(' Skipping optional servers.\n'));
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
console.log(kleur.green(` Selected: ${selected.join(', ')}\n`));
|
|
394
|
+
return selected;
|
|
395
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
|
|
3
|
+
/** Semantic color tokens */
|
|
4
|
+
export const t = {
|
|
5
|
+
// Status
|
|
6
|
+
success: (s: string) => kleur.green(s),
|
|
7
|
+
error: (s: string) => kleur.red(s),
|
|
8
|
+
warning: (s: string) => kleur.yellow(s),
|
|
9
|
+
info: (s: string) => kleur.cyan(s), // was blue — unreadable on dark terminals
|
|
10
|
+
|
|
11
|
+
// Hierarchy (3-tier weight system)
|
|
12
|
+
header: (s: string) => kleur.bold().white(s), // section headers
|
|
13
|
+
label: (s: string) => kleur.dim(s), // metadata labels
|
|
14
|
+
muted: (s: string) => kleur.gray(s),
|
|
15
|
+
accent: (s: string) => kleur.cyan(s),
|
|
16
|
+
bold: (s: string) => kleur.bold(s),
|
|
17
|
+
dim: (s: string) => kleur.dim(s),
|
|
18
|
+
|
|
19
|
+
// Compound
|
|
20
|
+
boldGreen: (s: string) => kleur.bold().green(s),
|
|
21
|
+
boldRed: (s: string) => kleur.bold().red(s),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Status symbols with colour baked in */
|
|
25
|
+
export const sym = {
|
|
26
|
+
ok: kleur.green('✓'),
|
|
27
|
+
fail: kleur.red('✗'),
|
|
28
|
+
warn: kleur.yellow('⚠'),
|
|
29
|
+
|
|
30
|
+
// File change states — directional metaphor
|
|
31
|
+
missing: kleur.green('+'),
|
|
32
|
+
outdated: kleur.yellow('↑'),
|
|
33
|
+
drifted: kleur.magenta('≠'), // was red '!' — magenta = conflict/divergence
|
|
34
|
+
|
|
35
|
+
arrow: kleur.gray('→'),
|
|
36
|
+
bullet: kleur.gray('•'),
|
|
37
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
5
|
+
import { findRepoRoot } from './repo-root.js';
|
|
6
|
+
|
|
7
|
+
export interface WorktreeSessionOptions {
|
|
8
|
+
runtime: 'claude' | 'pi';
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function randomSlug(len: number = 4): string {
|
|
13
|
+
return Math.random().toString(36).slice(2, 2 + len);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function shortDate(): string {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Launch a Claude or Pi session in a sandboxed git worktree.
|
|
23
|
+
*
|
|
24
|
+
* Worktree path: sibling to CWD, named <cwd-basename>-xt-<runtime>-<shortdate>
|
|
25
|
+
* Branch: xt/<name> if name provided, xt/<4-char-random> otherwise
|
|
26
|
+
* Dolt bootstrap: redirect worktree to main's canonical beads db
|
|
27
|
+
*/
|
|
28
|
+
export async function launchWorktreeSession(opts: WorktreeSessionOptions): Promise<void> {
|
|
29
|
+
const { runtime, name } = opts;
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
const repoRoot = await findRepoRoot();
|
|
32
|
+
const cwdBasename = path.basename(cwd);
|
|
33
|
+
|
|
34
|
+
// Resolve worktree path (sibling to cwd)
|
|
35
|
+
const date = shortDate();
|
|
36
|
+
const worktreeName = `${cwdBasename}-xt-${runtime}-${date}`;
|
|
37
|
+
const worktreePath = path.join(path.dirname(cwd), worktreeName);
|
|
38
|
+
|
|
39
|
+
// Resolve branch name
|
|
40
|
+
const branchName = `xt/${name ?? randomSlug(4)}`;
|
|
41
|
+
|
|
42
|
+
console.log(kleur.bold(`\n Launching ${runtime} session`));
|
|
43
|
+
console.log(kleur.dim(` worktree: ${worktreePath}`));
|
|
44
|
+
console.log(kleur.dim(` branch: ${branchName}\n`));
|
|
45
|
+
|
|
46
|
+
// Create worktree (create branch if it doesn't exist)
|
|
47
|
+
const branchExists = spawnSync('git', ['rev-parse', '--verify', branchName], {
|
|
48
|
+
cwd: repoRoot, stdio: 'pipe',
|
|
49
|
+
}).status === 0;
|
|
50
|
+
|
|
51
|
+
const worktreeArgs = branchExists
|
|
52
|
+
? ['worktree', 'add', worktreePath, branchName]
|
|
53
|
+
: ['worktree', 'add', '-b', branchName, worktreePath];
|
|
54
|
+
|
|
55
|
+
const worktreeResult = spawnSync('git', worktreeArgs, { cwd: repoRoot, stdio: 'inherit' });
|
|
56
|
+
if (worktreeResult.status !== 0) {
|
|
57
|
+
console.error(kleur.red(`\n ✗ Failed to create worktree at ${worktreePath}\n`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Dolt bootstrap: redirect worktree to main's canonical beads db
|
|
62
|
+
const mainBeadsDir = path.join(repoRoot, '.beads');
|
|
63
|
+
const worktreeBeadsDir = path.join(worktreePath, '.beads');
|
|
64
|
+
const mainPortFile = path.join(mainBeadsDir, 'dolt-server.port');
|
|
65
|
+
|
|
66
|
+
if (await fs.pathExists(mainBeadsDir)) {
|
|
67
|
+
const worktreePortFile = path.join(worktreeBeadsDir, 'dolt-server.port');
|
|
68
|
+
|
|
69
|
+
// Stop the auto-spawned isolated dolt server in the worktree
|
|
70
|
+
spawnSync('bd', ['dolt', 'stop'], { cwd: worktreePath, stdio: 'pipe' });
|
|
71
|
+
|
|
72
|
+
// Read main checkout's port and write to worktree
|
|
73
|
+
if (await fs.pathExists(mainPortFile)) {
|
|
74
|
+
const mainPort = (await fs.readFile(mainPortFile, 'utf8')).trim();
|
|
75
|
+
await fs.ensureDir(worktreeBeadsDir);
|
|
76
|
+
await fs.writeFile(worktreePortFile, mainPort, 'utf8');
|
|
77
|
+
console.log(kleur.dim(` beads: redirected to main server (port ${mainPort})`));
|
|
78
|
+
} else {
|
|
79
|
+
console.log(kleur.dim(' beads: no port file found in main checkout, skipping redirect'));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(kleur.green(`\n ✓ Worktree ready — launching ${runtime}...\n`));
|
|
84
|
+
|
|
85
|
+
// Launch the runtime in the worktree
|
|
86
|
+
const runtimeCmd = runtime === 'claude' ? 'claude' : 'pi';
|
|
87
|
+
const launchResult = spawnSync(runtimeCmd, [], {
|
|
88
|
+
cwd: worktreePath,
|
|
89
|
+
stdio: 'inherit',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
process.exit(launchResult.status ?? 0);
|
|
93
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { deepMergeWithProtection } from '../src/utils/atomic-config.js';
|
|
3
|
+
|
|
4
|
+
const hooksDir = '/home/user/.claude/hooks';
|
|
5
|
+
const cmd = (script: string) => `node "${hooksDir}/${script}"`;
|
|
6
|
+
|
|
7
|
+
function wrap(matcher: string, script: string, timeout = 5000) {
|
|
8
|
+
return { matcher, hooks: [{ type: 'command', command: cmd(script), timeout }] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function wrapNoMatcher(script: string) {
|
|
12
|
+
return { hooks: [{ type: 'command', command: cmd(script) }] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const canonicalHooks = {
|
|
16
|
+
SessionStart: [wrapNoMatcher('beads-compact-restore.mjs'), wrapNoMatcher('serena-workflow-reminder.py')],
|
|
17
|
+
UserPromptSubmit: [{ ...wrapNoMatcher('branch-state.mjs'), timeout: 3000 }],
|
|
18
|
+
PreToolUse: [
|
|
19
|
+
wrap('Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol', 'main-guard.mjs'),
|
|
20
|
+
wrap('Bash', 'main-guard.mjs'),
|
|
21
|
+
],
|
|
22
|
+
PostToolUse: [
|
|
23
|
+
wrap('Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview', 'gitnexus/gitnexus-hook.cjs', 10000),
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('deepMergeWithProtection — pruneHooks mode', () => {
|
|
28
|
+
it('replaces stale PreToolUse matcher with canonical one', () => {
|
|
29
|
+
const existing = {
|
|
30
|
+
hooks: {
|
|
31
|
+
PreToolUse: [
|
|
32
|
+
wrap('Read|Grep|Glob|Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol', 'main-guard.mjs'),
|
|
33
|
+
wrap('Bash', 'main-guard.mjs'),
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: true });
|
|
39
|
+
const preToolUse = result.hooks.PreToolUse;
|
|
40
|
+
expect(preToolUse).toHaveLength(2);
|
|
41
|
+
expect(preToolUse[0].matcher).toBe('Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol');
|
|
42
|
+
expect(preToolUse[1].matcher).toBe('Bash');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('removes script wired to wrong event (serena-workflow-reminder.py in PreToolUse)', () => {
|
|
46
|
+
const existing = {
|
|
47
|
+
hooks: {
|
|
48
|
+
SessionStart: [wrapNoMatcher('serena-workflow-reminder.py')],
|
|
49
|
+
PreToolUse: [
|
|
50
|
+
wrap('Read|Edit', 'serena-workflow-reminder.py'),
|
|
51
|
+
wrap('Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol', 'main-guard.mjs'),
|
|
52
|
+
wrap('Bash', 'main-guard.mjs'),
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: true });
|
|
58
|
+
const preToolUse = result.hooks.PreToolUse;
|
|
59
|
+
const hasStale = preToolUse.some((w: any) =>
|
|
60
|
+
w.hooks?.some((h: any) => h.command?.includes('serena-workflow-reminder.py'))
|
|
61
|
+
);
|
|
62
|
+
expect(hasStale).toBe(false);
|
|
63
|
+
expect(preToolUse).toHaveLength(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('preserves user-local hooks (not from ~/.claude/hooks/) in canonical events', () => {
|
|
67
|
+
const userLocalHook = { hooks: [{ type: 'command', command: '/usr/local/bin/my-custom-hook.sh' }] };
|
|
68
|
+
const existing = {
|
|
69
|
+
hooks: {
|
|
70
|
+
PreToolUse: [
|
|
71
|
+
wrap('Bash', 'main-guard.mjs'),
|
|
72
|
+
userLocalHook,
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: true });
|
|
78
|
+
const preToolUse = result.hooks.PreToolUse;
|
|
79
|
+
const hasUserLocal = preToolUse.some((w: any) =>
|
|
80
|
+
w.hooks?.some((h: any) => h.command === '/usr/local/bin/my-custom-hook.sh')
|
|
81
|
+
);
|
|
82
|
+
expect(hasUserLocal).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('normal merge (no pruneHooks) preserves stale matcher tokens', () => {
|
|
86
|
+
const existing = {
|
|
87
|
+
hooks: {
|
|
88
|
+
PreToolUse: [
|
|
89
|
+
wrap('Read|Grep|Glob|Write|Edit|Bash', 'main-guard.mjs'),
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: false });
|
|
95
|
+
const preToolUse = result.hooks.PreToolUse;
|
|
96
|
+
const matcher = preToolUse.find((w: any) =>
|
|
97
|
+
w.hooks?.some((h: any) => h.command?.includes('main-guard.mjs'))
|
|
98
|
+
)?.matcher ?? '';
|
|
99
|
+
expect(matcher).toContain('Read');
|
|
100
|
+
});
|
|
101
|
+
});
|