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