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.
Files changed (93) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +57378 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/extensions/beads.ts +109 -0
  6. package/extensions/core/adapter.ts +45 -0
  7. package/extensions/core/lib.ts +3 -0
  8. package/extensions/core/logger.ts +45 -0
  9. package/extensions/core/runner.ts +71 -0
  10. package/extensions/custom-footer.ts +160 -0
  11. package/extensions/main-guard-post-push.ts +44 -0
  12. package/extensions/main-guard.ts +126 -0
  13. package/extensions/minimal-mode.ts +201 -0
  14. package/extensions/quality-gates.ts +67 -0
  15. package/extensions/service-skills.ts +150 -0
  16. package/extensions/xtrm-loader.ts +89 -0
  17. package/hooks/gitnexus-impact-reminder.py +13 -0
  18. package/lib/atomic-config.js +236 -0
  19. package/lib/config-adapter.js +231 -0
  20. package/lib/config-injector.js +80 -0
  21. package/lib/context.js +73 -0
  22. package/lib/diff.js +142 -0
  23. package/lib/env-manager.js +160 -0
  24. package/lib/sync-mcp-cli.js +345 -0
  25. package/lib/sync.js +227 -0
  26. package/package.json +47 -0
  27. package/src/adapters/base.ts +29 -0
  28. package/src/adapters/claude.ts +38 -0
  29. package/src/adapters/registry.ts +21 -0
  30. package/src/commands/claude.ts +122 -0
  31. package/src/commands/clean.ts +371 -0
  32. package/src/commands/end.ts +239 -0
  33. package/src/commands/finish.ts +25 -0
  34. package/src/commands/help.ts +180 -0
  35. package/src/commands/init.ts +959 -0
  36. package/src/commands/install-pi.ts +276 -0
  37. package/src/commands/install-service-skills.ts +281 -0
  38. package/src/commands/install.ts +427 -0
  39. package/src/commands/pi-install.ts +119 -0
  40. package/src/commands/pi.ts +128 -0
  41. package/src/commands/reset.ts +12 -0
  42. package/src/commands/status.ts +170 -0
  43. package/src/commands/worktree.ts +193 -0
  44. package/src/core/context.ts +141 -0
  45. package/src/core/diff.ts +174 -0
  46. package/src/core/interactive-plan.ts +165 -0
  47. package/src/core/manifest.ts +26 -0
  48. package/src/core/preflight.ts +142 -0
  49. package/src/core/rollback.ts +32 -0
  50. package/src/core/session-state.ts +139 -0
  51. package/src/core/sync-executor.ts +427 -0
  52. package/src/core/xtrm-finish.ts +267 -0
  53. package/src/index.ts +87 -0
  54. package/src/tests/policy-parity.test.ts +204 -0
  55. package/src/tests/session-flow-parity.test.ts +118 -0
  56. package/src/tests/session-state.test.ts +124 -0
  57. package/src/tests/xtrm-finish.test.ts +148 -0
  58. package/src/types/config.ts +51 -0
  59. package/src/types/models.ts +52 -0
  60. package/src/utils/atomic-config.ts +467 -0
  61. package/src/utils/banner.ts +194 -0
  62. package/src/utils/config-adapter.ts +90 -0
  63. package/src/utils/config-injector.ts +81 -0
  64. package/src/utils/env-manager.ts +193 -0
  65. package/src/utils/hash.ts +42 -0
  66. package/src/utils/repo-root.ts +39 -0
  67. package/src/utils/sync-mcp-cli.ts +395 -0
  68. package/src/utils/theme.ts +37 -0
  69. package/src/utils/worktree-session.ts +93 -0
  70. package/test/atomic-config-prune.test.ts +101 -0
  71. package/test/atomic-config.test.ts +138 -0
  72. package/test/clean.test.ts +172 -0
  73. package/test/config-schema.test.ts +52 -0
  74. package/test/context.test.ts +33 -0
  75. package/test/end-worktree.test.ts +168 -0
  76. package/test/extensions/beads.test.ts +166 -0
  77. package/test/extensions/extension-harness.ts +85 -0
  78. package/test/extensions/main-guard.test.ts +77 -0
  79. package/test/extensions/minimal-mode.test.ts +107 -0
  80. package/test/extensions/quality-gates.test.ts +79 -0
  81. package/test/extensions/service-skills.test.ts +84 -0
  82. package/test/extensions/xtrm-loader.test.ts +53 -0
  83. package/test/hooks/quality-check-hooks.test.ts +45 -0
  84. package/test/hooks.test.ts +1075 -0
  85. package/test/install-pi.test.ts +185 -0
  86. package/test/install-project.test.ts +378 -0
  87. package/test/install-service-skills.test.ts +131 -0
  88. package/test/install-surface.test.ts +72 -0
  89. package/test/runtime-subcommands.test.ts +121 -0
  90. package/test/session-launcher.test.ts +139 -0
  91. package/tsconfig.json +22 -0
  92. package/tsup.config.ts +17 -0
  93. package/vitest.config.ts +10 -0
package/lib/diff.js ADDED
@@ -0,0 +1,142 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import crypto from 'crypto';
4
+
5
+ /**
6
+ * Calculate MD5 hash of a file or directory
7
+ */
8
+ async function getHash(targetPath) {
9
+ if (!fs.existsSync(targetPath)) return null;
10
+
11
+ const stats = await fs.stat(targetPath);
12
+
13
+ if (stats.isDirectory()) {
14
+ const children = await fs.readdir(targetPath);
15
+ const childHashes = await Promise.all(
16
+ children.sort().map(async child => {
17
+ const h = await getHash(path.join(targetPath, child));
18
+ return `${child}:${h}`;
19
+ })
20
+ );
21
+ return crypto.createHash('md5').update(childHashes.join('|')).digest('hex');
22
+ } else {
23
+ const content = await fs.readFile(targetPath);
24
+ return crypto.createHash('md5').update(content).digest('hex');
25
+ }
26
+ }
27
+
28
+ async function getNewestMtime(targetPath) {
29
+ const stats = await fs.stat(targetPath);
30
+ let maxTime = stats.mtimeMs;
31
+
32
+ if (stats.isDirectory()) {
33
+ const children = await fs.readdir(targetPath);
34
+ for (const child of children) {
35
+ const childPath = path.join(targetPath, child);
36
+ const childTime = await getNewestMtime(childPath);
37
+ if (childTime > maxTime) maxTime = childTime;
38
+ }
39
+ }
40
+ return maxTime;
41
+ }
42
+
43
+ export async function calculateDiff(repoRoot, systemRoot) {
44
+ const isClaude = systemRoot.includes('.claude') || systemRoot.includes('Claude');
45
+ const isQwen = systemRoot.includes('.qwen') || systemRoot.includes('Qwen');
46
+ const isGemini = systemRoot.includes('.gemini') || systemRoot.includes('Gemini');
47
+
48
+ const changeSet = {
49
+ skills: { missing: [], outdated: [], drifted: [], total: 0 },
50
+ hooks: { missing: [], outdated: [], drifted: [], total: 0 },
51
+ config: { missing: [], outdated: [], drifted: [], total: 0 },
52
+ commands: { missing: [], outdated: [], drifted: [], total: 0 },
53
+ 'qwen-commands': { missing: [], outdated: [], drifted: [], total: 0 },
54
+ 'antigravity-workflows': { missing: [], outdated: [], drifted: [], total: 0 }
55
+ };
56
+
57
+ // 1. Folders: Skills & Hooks & Commands (for different environments)
58
+ const folders = ['skills', 'hooks'];
59
+ if (isQwen) {
60
+ folders.push('qwen-commands');
61
+ } else if (isGemini) {
62
+ folders.push('commands', 'antigravity-workflows');
63
+ } else if (!isClaude) {
64
+ folders.push('commands');
65
+ }
66
+
67
+ for (const category of folders) {
68
+ let repoPath;
69
+ if (category === 'commands') {
70
+ // Commands are always in .gemini/commands in repo
71
+ repoPath = path.join(repoRoot, '.gemini', 'commands');
72
+ } else if (category === 'qwen-commands') {
73
+ // Qwen commands are in .qwen/commands in repo
74
+ repoPath = path.join(repoRoot, '.qwen', 'commands');
75
+ } else if (category === 'antigravity-workflows') {
76
+ // Antigravity workflows are in .gemini/antigravity/global_workflows in repo
77
+ repoPath = path.join(repoRoot, '.gemini', 'antigravity', 'global_workflows');
78
+ } else {
79
+ repoPath = path.join(repoRoot, category);
80
+ }
81
+
82
+ let systemPath;
83
+ if (category === 'qwen-commands') {
84
+ systemPath = path.join(systemRoot, 'commands');
85
+ } else if (category === 'antigravity-workflows') {
86
+ systemPath = path.join(systemRoot, '.gemini', 'antigravity', 'global_workflows');
87
+ } else if (category === 'commands') {
88
+ systemPath = path.join(systemRoot, category);
89
+ } else {
90
+ systemPath = path.join(systemRoot, category);
91
+ }
92
+
93
+ if (!fs.existsSync(repoPath)) continue;
94
+
95
+ const items = await fs.readdir(repoPath);
96
+ changeSet[category].total = items.length;
97
+
98
+ for (const item of items) {
99
+ const itemRepoPath = path.join(repoPath, item);
100
+ const itemSystemPath = path.join(systemPath, item);
101
+
102
+ await compareItem(category, item, itemRepoPath, itemSystemPath, changeSet);
103
+ }
104
+ }
105
+
106
+ // 2. Config Files (Explicit Mapping)
107
+ const configMapping = {
108
+ 'settings.json': { repo: 'config/settings.json', sys: 'settings.json' }
109
+ };
110
+
111
+ for (const [name, paths] of Object.entries(configMapping)) {
112
+ const itemRepoPath = path.join(repoRoot, paths.repo);
113
+ const itemSystemPath = path.join(systemRoot, paths.sys);
114
+
115
+ if (fs.existsSync(itemRepoPath)) {
116
+ await compareItem('config', name, itemRepoPath, itemSystemPath, changeSet);
117
+ }
118
+ }
119
+
120
+ return changeSet;
121
+ }
122
+
123
+ async function compareItem(category, item, repoPath, systemPath, changeSet) {
124
+ if (!fs.existsSync(systemPath)) {
125
+ changeSet[category].missing.push(item);
126
+ return;
127
+ }
128
+
129
+ const repoHash = await getHash(repoPath);
130
+ const systemHash = await getHash(systemPath);
131
+
132
+ if (repoHash !== systemHash) {
133
+ const repoMtime = await getNewestMtime(repoPath);
134
+ const systemMtime = await getNewestMtime(systemPath);
135
+
136
+ if (systemMtime > repoMtime + 2000) {
137
+ changeSet[category].drifted.push(item);
138
+ } else {
139
+ changeSet[category].outdated.push(item);
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,160 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import kleur from 'kleur';
5
+ import dotenv from 'dotenv';
6
+
7
+ /**
8
+ * Environment file location: ~/.config/jaggers-agent-tools/.env
9
+ */
10
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'jaggers-agent-tools');
11
+ const ENV_FILE = path.join(CONFIG_DIR, '.env');
12
+ const ENV_EXAMPLE_FILE = path.join(CONFIG_DIR, '.env.example');
13
+
14
+ /**
15
+ * Required environment variables for MCP servers
16
+ */
17
+ const REQUIRED_ENV_VARS = {
18
+ 'CONTEXT7_API_KEY': {
19
+ description: 'Context7 MCP server API key',
20
+ example: 'ctx7sk-your-api-key-here',
21
+ getUrl: () => 'https://context7.com/'
22
+ }
23
+ };
24
+
25
+ /**
26
+ * Ensure config directory and .env file exist
27
+ */
28
+ export function ensureEnvFile() {
29
+ // Create config directory if missing
30
+ if (!fs.existsSync(CONFIG_DIR)) {
31
+ fs.ensureDirSync(CONFIG_DIR);
32
+ console.log(kleur.gray(` Created config directory: ${CONFIG_DIR}`));
33
+ }
34
+
35
+ // Create .env.example if missing
36
+ if (!fs.existsSync(ENV_EXAMPLE_FILE)) {
37
+ createEnvExample();
38
+ }
39
+
40
+ // Create .env if missing
41
+ if (!fs.existsSync(ENV_FILE)) {
42
+ createEnvFile();
43
+ return false; // File was created (user needs to fill it)
44
+ }
45
+
46
+ return true; // File already exists
47
+ }
48
+
49
+ /**
50
+ * Create .env.example file
51
+ */
52
+ function createEnvExample() {
53
+ const content = [
54
+ '# Jaggers Agent Tools - Environment Variables',
55
+ '# Copy this file to .env and fill in your actual values',
56
+ '',
57
+ ...Object.entries(REQUIRED_ENV_VARS).map(([key, config]) => {
58
+ return [
59
+ `# ${config.description}`,
60
+ `# Get your key from: ${config.getUrl()}`,
61
+ `${key}=${config.example}`,
62
+ ''
63
+ ].join('\n');
64
+ }).join('\n'),
65
+ '# See config/.env.example in the repository for all available options',
66
+ ''
67
+ ];
68
+
69
+ fs.writeFileSync(ENV_EXAMPLE_FILE, content);
70
+ console.log(kleur.gray(` Created example file: ${ENV_EXAMPLE_FILE}`));
71
+ }
72
+
73
+ /**
74
+ * Create empty .env file with header
75
+ */
76
+ function createEnvFile() {
77
+ const content = [
78
+ '# Jaggers Agent Tools - Environment Variables',
79
+ '# Generated automatically by jaggers-agent-tools CLI',
80
+ '',
81
+ '# Copy values from .env.example and fill in your actual keys',
82
+ '',
83
+ ].join('\n');
84
+
85
+ fs.writeFileSync(ENV_FILE, content);
86
+ console.log(kleur.green(` Created environment file: ${ENV_FILE}`));
87
+ }
88
+
89
+ /**
90
+ * Load environment variables from .env file
91
+ * Also loads from process.env (which takes precedence)
92
+ */
93
+ export function loadEnvFile() {
94
+ if (fs.existsSync(ENV_FILE)) {
95
+ const envConfig = dotenv.parse(fs.readFileSync(ENV_FILE));
96
+
97
+ // Merge with process.env (process.env takes precedence)
98
+ for (const [key, value] of Object.entries(envConfig)) {
99
+ if (!process.env[key]) {
100
+ process.env[key] = value;
101
+ }
102
+ }
103
+
104
+ return envConfig;
105
+ }
106
+
107
+ return {};
108
+ }
109
+
110
+ /**
111
+ * Check if required environment variables are set
112
+ * Returns array of missing variable names
113
+ */
114
+ export function checkRequiredEnvVars() {
115
+ const missing = [];
116
+
117
+ for (const [key, config] of Object.entries(REQUIRED_ENV_VARS)) {
118
+ if (!process.env[key]) {
119
+ missing.push(key);
120
+ }
121
+ }
122
+
123
+ return missing;
124
+ }
125
+
126
+ /**
127
+ * Prompt user about missing environment variables
128
+ * Returns true if user wants to proceed anyway
129
+ */
130
+ export function handleMissingEnvVars(missing) {
131
+ if (missing.length === 0) {
132
+ return true;
133
+ }
134
+
135
+ console.log(kleur.yellow('\n ⚠️ Missing environment variables:'));
136
+ for (const key of missing) {
137
+ const config = REQUIRED_ENV_VARS[key];
138
+ console.log(kleur.yellow(` - ${key}: ${config.description}`));
139
+ console.log(kleur.dim(` Get your key from: ${config.getUrl()}`));
140
+ }
141
+
142
+ console.log(kleur.yellow(`\n Please edit: ${ENV_FILE}`));
143
+ console.log(kleur.gray(` Or copy from example: ${ENV_EXAMPLE_FILE}`));
144
+
145
+ return false; // Don't proceed automatically
146
+ }
147
+
148
+ /**
149
+ * Get the path to the .env file
150
+ */
151
+ export function getEnvFilePath() {
152
+ return ENV_FILE;
153
+ }
154
+
155
+ /**
156
+ * Get the path to the config directory
157
+ */
158
+ export function getConfigDir() {
159
+ return CONFIG_DIR;
160
+ }
@@ -0,0 +1,345 @@
1
+ import { execSync } from 'child_process';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import kleur from 'kleur';
5
+ import { ensureEnvFile, loadEnvFile, checkRequiredEnvVars, handleMissingEnvVars, getEnvFilePath } from './env-manager.js';
6
+
7
+ /**
8
+ * Agent-specific MCP CLI handlers
9
+ */
10
+ const AGENT_CLI = {
11
+ claude: {
12
+ command: 'claude',
13
+ listArgs: ['mcp', 'list'], // list doesn't support -s flag
14
+ addStdio: (name, cmd, args, env) => {
15
+ // Use -s user for user-level config (~/.claude.json global)
16
+ const base = ['mcp', 'add', '-s', 'user', name, '--'];
17
+ if (env && Object.keys(env).length > 0) {
18
+ for (const [key, value] of Object.entries(env)) {
19
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
20
+ }
21
+ }
22
+ base.push(cmd, ...(args || []));
23
+ return base;
24
+ },
25
+ addHttp: (name, url, headers) => {
26
+ // Use -s user for user-level config
27
+ const base = ['mcp', 'add', '-s', 'user', '--transport', 'http', name, url];
28
+ if (headers) {
29
+ for (const [key, value] of Object.entries(headers)) {
30
+ base.push('--header', `${key}: ${resolveEnvVar(value)}`);
31
+ }
32
+ }
33
+ return base;
34
+ },
35
+ addSse: (name, url) => {
36
+ return ['mcp', 'add', '-s', 'user', '--transport', 'sse', name, url];
37
+ },
38
+ remove: (name) => ['mcp', 'remove', '-s', 'user', name],
39
+ parseList: (output) => parseMcpListOutput(output, /^([a-zA-Z0-9_-]+):/)
40
+ },
41
+ gemini: {
42
+ command: 'gemini',
43
+ listArgs: ['mcp', 'list'],
44
+ addStdio: (name, cmd, args, env) => {
45
+ const base = ['mcp', 'add', name, cmd];
46
+ if (args && args.length > 0) base.push(...args);
47
+ if (env && Object.keys(env).length > 0) {
48
+ for (const [key, value] of Object.entries(env)) {
49
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
50
+ }
51
+ }
52
+ return base;
53
+ },
54
+ addHttp: (name, url, headers) => {
55
+ const base = ['mcp', 'add', '-t', 'http', name, url];
56
+ if (headers) {
57
+ for (const [key, value] of Object.entries(headers)) {
58
+ base.push('-H', `${key}=${resolveEnvVar(value)}`);
59
+ }
60
+ }
61
+ return base;
62
+ },
63
+ addSse: (name, url) => {
64
+ return ['mcp', 'add', '-t', 'sse', name, url];
65
+ },
66
+ remove: (name) => ['mcp', 'remove', name],
67
+ parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
68
+ },
69
+ qwen: {
70
+ command: 'qwen',
71
+ listArgs: ['mcp', 'list'],
72
+ addStdio: (name, cmd, args, env) => {
73
+ const base = ['mcp', 'add', name, cmd];
74
+ if (args && args.length > 0) base.push(...args);
75
+ if (env && Object.keys(env).length > 0) {
76
+ for (const [key, value] of Object.entries(env)) {
77
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
78
+ }
79
+ }
80
+ return base;
81
+ },
82
+ addHttp: (name, url, headers) => {
83
+ const base = ['mcp', 'add', '-t', 'http', name, url];
84
+ if (headers) {
85
+ for (const [key, value] of Object.entries(headers)) {
86
+ base.push('-H', `${key}=${resolveEnvVar(value)}`);
87
+ }
88
+ }
89
+ return base;
90
+ },
91
+ addSse: (name, url) => {
92
+ return ['mcp', 'add', '-t', 'sse', name, url];
93
+ },
94
+ remove: (name) => ['mcp', 'remove', name],
95
+ parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Parse MCP list output to extract server names
101
+ */
102
+ function parseMcpListOutput(output, pattern) {
103
+ const servers = [];
104
+ for (const line of output.split('\n')) {
105
+ const match = line.match(pattern);
106
+ if (match) {
107
+ servers.push(match[1]);
108
+ }
109
+ }
110
+ return servers;
111
+ }
112
+
113
+ /**
114
+ * Resolve environment variable references like ${VAR}
115
+ */
116
+ function resolveEnvVar(value) {
117
+ if (typeof value !== 'string') return value;
118
+
119
+ const envMatch = value.match(/\$\{([A-Z0-9_]+)\}/i);
120
+ if (envMatch) {
121
+ const envName = envMatch[1];
122
+ const envValue = process.env[envName];
123
+ if (envValue) {
124
+ return envValue;
125
+ } else {
126
+ // Return empty string - server will be added but won't work until key is added
127
+ console.warn(kleur.yellow(` ⚠️ Environment variable ${envName} is not set in ${getEnvFilePath()}`));
128
+ return '';
129
+ }
130
+ }
131
+
132
+ return value;
133
+ }
134
+
135
+ /**
136
+ * Detect which agent CLI is available
137
+ */
138
+ export function detectAgent(systemRoot) {
139
+ if (systemRoot.includes('.claude') || systemRoot.includes('Claude')) {
140
+ return 'claude';
141
+ } else if (systemRoot.includes('.gemini') || systemRoot.includes('Gemini')) {
142
+ return 'gemini';
143
+ } else if (systemRoot.includes('.qwen') || systemRoot.includes('Qwen')) {
144
+ return 'qwen';
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Build MCP add commands for a server
151
+ */
152
+ function buildAddCommand(agent, name, server) {
153
+ const cli = AGENT_CLI[agent];
154
+ if (!cli) return null;
155
+
156
+ // HTTP/SSE servers
157
+ if (server.url || server.serverUrl) {
158
+ const url = server.url || server.serverUrl;
159
+ const type = server.type || (url.includes('/sse') ? 'sse' : 'http');
160
+
161
+ if (type === 'sse') {
162
+ return cli.addSse(name, url);
163
+ } else {
164
+ return cli.addHttp(name, url, server.headers);
165
+ }
166
+ }
167
+
168
+ // Stdio servers
169
+ if (server.command) {
170
+ return cli.addStdio(name, server.command, server.args, server.env);
171
+ }
172
+
173
+ console.warn(kleur.yellow(` ⚠️ Skipping server "${name}": Unknown configuration`));
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Execute an MCP command
179
+ */
180
+ function executeCommand(agent, args, dryRun = false) {
181
+ const cli = AGENT_CLI[agent];
182
+
183
+ // Build command string with proper quoting for arguments with spaces
184
+ const quotedArgs = args.map(arg => {
185
+ if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
186
+ return `"${arg}"`;
187
+ }
188
+ return arg;
189
+ });
190
+ const command = `${cli.command} ${quotedArgs.join(' ')}`;
191
+
192
+ if (dryRun) {
193
+ console.log(kleur.cyan(` [DRY RUN] ${command}`));
194
+ return { success: true, dryRun: true };
195
+ }
196
+
197
+ try {
198
+ execSync(command, { stdio: 'pipe' });
199
+ console.log(kleur.green(` ✓ ${args.slice(2).join(' ')}`));
200
+ return { success: true };
201
+ } catch (error) {
202
+ const stderr = error.stderr?.toString() || error.message;
203
+
204
+ // Handle "already exists" case as success (idempotent)
205
+ if (stderr.includes('already exists') || stderr.includes('already configured')) {
206
+ // Extract server name based on agent
207
+ let serverName = 'unknown';
208
+ if (agent === 'claude') {
209
+ // Claude: claude mcp add [-s scope] [--transport type] <name> ...
210
+ // Find the server name: first non-flag arg after 'add' that's not scope or transport
211
+ const addIndex = args.indexOf('add');
212
+ for (let i = addIndex + 1; i < args.length; i++) {
213
+ const arg = args[i];
214
+ if (arg === '--') continue; // Skip separator
215
+ if (arg.startsWith('-')) continue; // Skip flags
216
+ if (['local', 'user', 'project', 'http', 'sse', 'stdio'].includes(arg)) continue; // Skip scope and transport
217
+ serverName = arg;
218
+ break;
219
+ }
220
+ } else if (agent === 'gemini' || agent === 'qwen') {
221
+ // Gemini/Qwen: <agent> mcp add [-t type] <name> <url>...
222
+ // Find first non-flag arg after 'add' that's not a transport type
223
+ const addIndex = args.indexOf('add');
224
+ for (let i = addIndex + 1; i < args.length; i++) {
225
+ const arg = args[i];
226
+ if (arg === '-t') { i++; continue; } // Skip transport flag and value
227
+ if (arg.startsWith('-')) continue; // Skip other flags
228
+ if (['http', 'sse', 'stdio'].includes(arg)) continue; // Skip transport types
229
+ serverName = arg;
230
+ break;
231
+ }
232
+ } else {
233
+ // Fallback: use third arg
234
+ serverName = args[2];
235
+ }
236
+ console.log(kleur.dim(` ✓ ${serverName} (already configured)`));
237
+ return { success: true, skipped: true };
238
+ }
239
+
240
+ console.log(kleur.red(` ✗ Failed: ${stderr.trim()}`));
241
+ return { success: false, error: stderr };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Get current MCP servers for an agent
247
+ */
248
+ function getCurrentServers(agent) {
249
+ const cli = AGENT_CLI[agent];
250
+ try {
251
+ const output = execSync(`${cli.command} ${cli.listArgs.join(' ')}`, {
252
+ encoding: 'utf8',
253
+ stdio: ['pipe', 'pipe', 'ignore']
254
+ });
255
+ return cli.parseList(output);
256
+ } catch (error) {
257
+ return [];
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Sync MCP servers to an agent using official CLI
263
+ *
264
+ * @param {string} agent - 'claude', 'gemini', or 'qwen'
265
+ * @param {Object} mcpConfig - Canonical MCP configuration
266
+ * @param {boolean} dryRun - Show commands without executing
267
+ * @param {boolean} prune - Remove servers not in canonical config
268
+ */
269
+ export async function syncMcpServersWithCli(agent, mcpConfig, dryRun = false, prune = false) {
270
+ const cli = AGENT_CLI[agent];
271
+ if (!cli) {
272
+ console.log(kleur.yellow(` ⚠️ Unsupported agent: ${agent}`));
273
+ return;
274
+ }
275
+
276
+ console.log(kleur.bold(`\nSyncing MCP servers to ${agent}...`));
277
+
278
+ // Step 0: Ensure .env file exists and load it
279
+ ensureEnvFile();
280
+ loadEnvFile();
281
+
282
+ // Step 1: Check for missing required env vars
283
+ const missingEnvVars = checkRequiredEnvVars();
284
+ if (missingEnvVars.length > 0) {
285
+ handleMissingEnvVars(missingEnvVars);
286
+ // Continue anyway - servers will be added but may not work until keys are added
287
+ }
288
+
289
+ // Step 2: Get current servers
290
+ const currentServers = getCurrentServers(agent);
291
+ const canonicalServers = new Set(Object.keys(mcpConfig.mcpServers || {}));
292
+
293
+ // Step 2: Remove servers not in canonical (if prune mode)
294
+ if (prune) {
295
+ console.log(kleur.red('\n Prune mode: Removing servers not in canonical config...'));
296
+ for (const serverName of currentServers) {
297
+ if (!canonicalServers.has(serverName)) {
298
+ console.log(kleur.red(` Removing: ${serverName}`));
299
+ executeCommand(agent, cli.remove(serverName), dryRun);
300
+ }
301
+ }
302
+ }
303
+
304
+ // Step 3: Add/update canonical servers
305
+ console.log(kleur.cyan('\n Adding/Updating canonical servers...'));
306
+ let successCount = 0;
307
+
308
+ for (const [name, server] of Object.entries(mcpConfig.mcpServers)) {
309
+ const cmd = buildAddCommand(agent, name, server);
310
+ if (cmd) {
311
+ const result = executeCommand(agent, cmd, dryRun);
312
+ if (result.success) {
313
+ successCount++;
314
+ }
315
+ }
316
+ }
317
+
318
+ // Summary
319
+ console.log(kleur.green(`\n ✓ Synced ${successCount} MCP servers`));
320
+ }
321
+
322
+ /**
323
+ * Load canonical MCP config from repository
324
+ */
325
+ export function loadCanonicalMcpConfig(repoRoot) {
326
+ const corePath = path.join(repoRoot, 'config', 'mcp_servers.json');
327
+ const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
328
+
329
+ const config = { mcpServers: {} };
330
+
331
+ // Always load core servers
332
+ if (fs.existsSync(corePath)) {
333
+ const core = fs.readJsonSync(corePath);
334
+ config.mcpServers = { ...config.mcpServers, ...core.mcpServers };
335
+ }
336
+
337
+ // Optionally load optional servers (user would have selected these)
338
+ // For now, we don't auto-load them
339
+ // if (fs.existsSync(optionalPath)) {
340
+ // const optional = fs.readJsonSync(optionalPath);
341
+ // config.mcpServers = { ...config.mcpServers, ...optional.mcpServers };
342
+ // }
343
+
344
+ return config;
345
+ }