@passioncode-ai/passioncode 0.1.52 → 0.2.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/CHANGELOG.md +41 -0
- package/README.md +72 -23
- package/SECURITY.md +23 -3
- package/bin/passioncode.js +71 -6
- package/family.json +2 -2
- package/lib/catalog.js +91 -0
- package/lib/doctor.js +288 -0
- package/lib/launcher.js +123 -58
- package/lib/mcp-probe.js +91 -0
- package/lib/openpgp.js +209 -0
- package/lib/release-key.asc +10 -0
- package/lib/releases.js +211 -0
- package/lib/setup.js +238 -0
- package/package.json +6 -3
- package/payload/.claude-plugin/marketplace.json +3 -3
- package/payload/manifest.json +10 -9
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +20 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +2 -2
- package/payload/plugins/passioncode/hooks/session-start.js +13 -1
- package/payload/plugins/passioncode/skills/setting-up-passioncode/SKILL.md +109 -0
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* `passioncode doctor`: reads the whole PassionCode.ai setup of this machine and names, for every
|
|
4
|
+
* check, its state, the exact fix and what setup may repair (ADR-0004). It changes nothing: the only
|
|
5
|
+
* processes it starts are the registered stdio MCP servers for one proving call each (never Fabric's,
|
|
6
|
+
* which enrols a session when it starts), stopped when the call returns. `doctor --fix` is setup's
|
|
7
|
+
* repair half (lib/setup.js), not this module.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const L = require('./launcher');
|
|
13
|
+
const catalog = require('./catalog');
|
|
14
|
+
const releases = require('./releases');
|
|
15
|
+
const { probe } = require('./mcp-probe');
|
|
16
|
+
|
|
17
|
+
const PKG = (() => { try { return require('../package.json').version; } catch (_) { return '0.0.0'; } })();
|
|
18
|
+
const NPX = 'npx @passioncode-ai/passioncode@latest';
|
|
19
|
+
|
|
20
|
+
function servicesDir(home, platform) {
|
|
21
|
+
if (process.env.PASSIONCODE_SERVICES_DIR) return process.env.PASSIONCODE_SERVICES_DIR;
|
|
22
|
+
if (process.env.FABRIC_SERVICES_DIR) return process.env.FABRIC_SERVICES_DIR;
|
|
23
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ai.passioncode.fabric', 'services');
|
|
24
|
+
if (platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'passioncode-fabric', 'services');
|
|
25
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'passioncode-fabric', 'services');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Agents present on this machine: Claude Code by its CLI, the rest by their config directory. */
|
|
29
|
+
function presentAgents(home, claude) {
|
|
30
|
+
const files = Object.fromEntries(L.mcpFileTargets(home).map((t) => [t.id, t]));
|
|
31
|
+
return catalog.AGENTS.filter((a) => (a.id === 'claude' ? claude.available() : fs.existsSync(files[a.id].dir)))
|
|
32
|
+
.map((a) => ({ ...a, target: files[a.id] || null }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The MCP entry an agent has for `name`: { command, args, env } | { url } | { plugin } | null. */
|
|
36
|
+
function mcpEntryOf(agent, name, home, { claudePlugin, installedIds }) {
|
|
37
|
+
if (agent.id === 'claude') {
|
|
38
|
+
if (claudePlugin && installedIds.some((id) => id.startsWith(claudePlugin))) return { plugin: claudePlugin.replace(/@$/, '') };
|
|
39
|
+
let doc = {};
|
|
40
|
+
try { doc = L.readSettings(path.join(home, '.claude.json')).doc; } catch (_) { return { unreadable: path.join(home, '.claude.json') }; }
|
|
41
|
+
const e = (doc.mcpServers || {})[name];
|
|
42
|
+
return e ? normalise(e) : null;
|
|
43
|
+
}
|
|
44
|
+
const t = agent.target;
|
|
45
|
+
if (t.kind === 'toml') {
|
|
46
|
+
let text = '';
|
|
47
|
+
try { text = fs.readFileSync(t.file, 'utf8'); } catch (_) { return null; }
|
|
48
|
+
const range = L.tomlTableRange(text, name);
|
|
49
|
+
if (!range) return L.tomlMentions(text, name) ? { unparsed: t.file } : null;
|
|
50
|
+
const command = L.tomlTableCommand(range);
|
|
51
|
+
let args = [];
|
|
52
|
+
for (let i = range.start + 1; i < range.end; i++) {
|
|
53
|
+
const m = /^\s*args\s*=\s*(\[.*\])\s*(?:#.*)?$/.exec(range.lines[i]);
|
|
54
|
+
if (m) { try { args = JSON.parse(m[1].replace(/'/g, '"')); } catch (_) { args = null; } }
|
|
55
|
+
const u = /^\s*url\s*=\s*("(?:[^"\\]|\\.)*")/.exec(range.lines[i]);
|
|
56
|
+
if (u) return { url: JSON.parse(u[1]) };
|
|
57
|
+
}
|
|
58
|
+
return command ? { command, args: args || [], env: {} } : { unparsed: t.file };
|
|
59
|
+
}
|
|
60
|
+
let doc = {};
|
|
61
|
+
try { doc = L.readSettings(t.file).doc; } catch (_) { return fs.existsSync(t.file) ? { unreadable: t.file } : null; }
|
|
62
|
+
const e = (doc.mcpServers || {})[name];
|
|
63
|
+
return e ? normalise(e) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalise(e) {
|
|
67
|
+
if (!e || typeof e !== 'object') return { unparsed: true };
|
|
68
|
+
if (e.url || e.httpUrl) return { url: e.url || e.httpUrl };
|
|
69
|
+
if (e.command) return { command: e.command, args: Array.isArray(e.args) ? e.args : [], env: e.env && typeof e.env === 'object' ? e.env : {} };
|
|
70
|
+
return { unparsed: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function wellKnown(origin) {
|
|
74
|
+
let u;
|
|
75
|
+
try { u = new URL('/.well-known/fabric-service', origin); } catch (_) { return { ok: false, reason: `origin ${origin} is not a URL` }; }
|
|
76
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)) return { ok: null, reason: `remote origin ${u.origin}: not probed` };
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(u, { signal: AbortSignal.timeout(5000) });
|
|
79
|
+
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
|
80
|
+
const doc = await res.json();
|
|
81
|
+
return { ok: true, status: doc.status || 'ok', degraded: Array.isArray(doc.degraded) ? doc.degraded.map((d) => d.source || d.code || 'unnamed').slice(0, 5) : [], version: doc.service && doc.service.version };
|
|
82
|
+
} catch (error) { return { ok: false, reason: error.name === 'TimeoutError' ? 'no answer within 5 s' : error.message }; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const newer = (a, b) => { const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number); for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] > pb[i]; return false; };
|
|
86
|
+
|
|
87
|
+
async function collect({ home, platform, claude, online }) {
|
|
88
|
+
const checks = [];
|
|
89
|
+
// `repair` is what setup (and doctor --fix) may do about a check: { kind: 'update' } (the skill
|
|
90
|
+
// set and the bundled MCP registrations), { kind: 'replace', product } (a verified reinstall of an
|
|
91
|
+
// app nobody is running), { kind: 'kickstart', label } (launchd starts a stopped service). A check
|
|
92
|
+
// without one leaves its `fix` to the agent or the person.
|
|
93
|
+
const add = (c) => { checks.push({ fix: null, repair: null, product: null, ...c }); };
|
|
94
|
+
const UPDATE = { kind: 'update' };
|
|
95
|
+
const p = L.paths(home);
|
|
96
|
+
|
|
97
|
+
// Prerequisites and agents.
|
|
98
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
99
|
+
add({ id: 'prereq.node', area: 'prerequisites', state: major >= 18 ? 'ok' : 'fail', detail: `Node ${process.versions.node}${major >= 18 ? '' : ' — 18 or newer is needed'}`, fix: major >= 18 ? null : 'install Node 18+ from https://nodejs.org' });
|
|
100
|
+
const agents = presentAgents(home, claude);
|
|
101
|
+
add({ id: 'prereq.agents', area: 'prerequisites', state: agents.length ? 'ok' : 'warn', detail: agents.length ? `coding agents: ${agents.map((a) => a.name).join(', ')}` : 'no coding agent found (Claude Code, Codex CLI, Gemini CLI or Kimi Code)', fix: agents.length ? null : 'install Claude Code: npm install -g @anthropic-ai/claude-code' });
|
|
102
|
+
|
|
103
|
+
// The skill set.
|
|
104
|
+
const s = L.status({ home, claude });
|
|
105
|
+
if (!s.installed) {
|
|
106
|
+
add({ id: 'skills.installed', area: 'skills', state: 'fail', detail: 'the PassionCode.ai skill set is not installed', fix: `${NPX} setup`, repair: UPDATE });
|
|
107
|
+
} else {
|
|
108
|
+
const behind = s.package && /^\d+\.\d+\.\d+$/.test(s.package) && newer(s.package, s.installed);
|
|
109
|
+
add({ id: 'skills.installed', area: 'skills', state: behind ? 'warn' : 'ok', detail: `skill set ${s.installed}${behind ? `; this launcher carries ${s.package}` : ''}`, fix: behind ? `${NPX} setup` : null, repair: behind ? UPDATE : null });
|
|
110
|
+
for (const m of s.members) {
|
|
111
|
+
const hubOk = m.hub.filter((h) => h.ok).length;
|
|
112
|
+
const problems = [];
|
|
113
|
+
if (m.claude === 'missing') problems.push('not installed as a Claude Code plugin');
|
|
114
|
+
if (hubOk < m.hub.length) problems.push(`hub ${hubOk}/${m.hub.length}`);
|
|
115
|
+
if (m.shadows.length) problems.push(`shadowed by ~/.claude/skills/${m.shadows.join(', ')}`);
|
|
116
|
+
if (m.legacy.length) problems.push(`a second copy from ${m.legacy.join(', ')}`);
|
|
117
|
+
add({ id: `skills.${m.name}`, area: 'skills', state: problems.length ? 'fail' : 'ok', detail: `${m.name} ${m.version}: ${problems.length ? problems.join('; ') : `claude: ${m.claude}, hub ${hubOk}/${m.hub.length}`}`, fix: problems.length ? `${NPX} setup` : null, repair: problems.length ? UPDATE : null });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The apps.
|
|
122
|
+
const installedIds = Object.keys((() => { try { return JSON.parse(fs.readFileSync(path.join(p.claudePlugins, 'installed_plugins.json'), 'utf8')).plugins || {}; } catch (_) { return {}; } })());
|
|
123
|
+
const cacheFile = path.join(p.state, 'releases-cache.json');
|
|
124
|
+
const probes = new Map();
|
|
125
|
+
let docker = null;
|
|
126
|
+
for (const pr of catalog.PRODUCTS) {
|
|
127
|
+
const at = catalog.appPath(pr);
|
|
128
|
+
const installed = platform === 'darwin' && fs.existsSync(at);
|
|
129
|
+
const label = `${pr.name}${pr.preview ? ' (early preview)' : ''}`;
|
|
130
|
+
if (platform !== 'darwin') {
|
|
131
|
+
add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: app checks run on macOS; on this system download it from ${pr.page}` });
|
|
132
|
+
} else if (!installed) {
|
|
133
|
+
add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: not installed`, fix: `${NPX} setup --with ${pr.id}` });
|
|
134
|
+
} else {
|
|
135
|
+
const version = releases.appVersion(at);
|
|
136
|
+
let latest = null, latestErr = null;
|
|
137
|
+
if (online) { try { latest = await releases.latestRelease(pr, { cacheFile }); } catch (error) { latestErr = error.message; } }
|
|
138
|
+
const outdated = latest && version && newer(latest.version, version);
|
|
139
|
+
const running = releases.isRunning(at); // the app, or an agent's MCP server running from its bundle
|
|
140
|
+
add({
|
|
141
|
+
id: `app.${pr.id}`, area: 'apps', product: pr.id, state: outdated || (!latest && online) ? 'warn' : 'ok',
|
|
142
|
+
detail: `${label} ${version || '?'}${latest ? (outdated ? `; ${latest.version} is out` : ' (latest)') : online ? `; latest unknown (${latestErr})` : '; latest not checked (--offline)'}`,
|
|
143
|
+
fix: outdated ? (running ? `quit ${pr.name}, then run \`${NPX} setup\` (or accept the update ${pr.name} offers)` : `${NPX} setup`) : null,
|
|
144
|
+
repair: outdated && !running ? { kind: 'replace', product: pr.id } : null,
|
|
145
|
+
});
|
|
146
|
+
const v = releases.verifyApp(at, { gatekeeper: false });
|
|
147
|
+
add({ id: `app.${pr.id}.signature`, area: 'apps', product: pr.id, state: v.ok ? 'ok' : 'fail', detail: `${pr.name}: ${v.checks.map((c) => `${c.check} ${c.ok ? 'ok' : `FAILED (${c.detail})`}`).join(', ')}`, fix: v.ok ? null : (running ? `quit ${pr.name}, then run \`${NPX} setup\`` : `${NPX} setup`), repair: !v.ok && !running ? { kind: 'replace', product: pr.id } : null });
|
|
148
|
+
add({ id: `app.${pr.id}.running`, area: 'apps', product: pr.id, state: 'info', detail: `${pr.name}: ${running ? 'in use (the app, or an agent\'s MCP server from its bundle)' : 'not running (it starts when opened)'}`, fix: running ? null : `open "${at}" — or ask this machine's app supervisor to start it` });
|
|
149
|
+
if (pr.needs && pr.needs.includes('docker')) {
|
|
150
|
+
docker = docker || releases.run('docker', ['info', '--format', '{{.ServerVersion}}'], { timeout: 15000 });
|
|
151
|
+
const missing = docker.code === 127;
|
|
152
|
+
add({ id: 'prereq.docker', area: 'prerequisites', product: pr.id, state: docker.code === 0 ? 'ok' : missing ? 'fail' : 'warn', detail: docker.code === 0 ? `Docker answers (server ${docker.out.trim()})` : missing ? 'Docker is not installed — Fabric needs it' : 'Docker is installed but not running — Fabric needs it running', fix: docker.code === 0 ? null : missing ? 'install OrbStack (https://orbstack.dev) or Docker Desktop, then start it' : 'start OrbStack or Docker Desktop before you open Fabric' });
|
|
153
|
+
}
|
|
154
|
+
if (pr.needs && pr.needs.includes('supabase')) {
|
|
155
|
+
const sb = releases.run('supabase', ['--version'], { timeout: 15000 });
|
|
156
|
+
add({ id: 'prereq.supabase', area: 'prerequisites', product: pr.id, state: sb.code === 0 ? 'ok' : 'fail', detail: sb.code === 0 ? `Supabase CLI ${sb.out.trim()}` : 'Supabase CLI not found — Fabric needs it', fix: sb.code === 0 ? null : 'brew install supabase/tap/supabase' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// MCP: each present agent's entry for this product.
|
|
161
|
+
const m = pr.mcp;
|
|
162
|
+
if (!m) continue;
|
|
163
|
+
for (const agent of agents) {
|
|
164
|
+
const entry = mcpEntryOf(agent, m.name, home, { claudePlugin: m.claudePlugin, installedIds });
|
|
165
|
+
const id = `mcp.${agent.id}.${m.name}`;
|
|
166
|
+
const bundled = catalog.mcpCommand(pr, agent);
|
|
167
|
+
if (!entry) {
|
|
168
|
+
if (!installed) continue;
|
|
169
|
+
// Only the product's own guide can add it (a virtualenv, a remote server): a warning, not a failure.
|
|
170
|
+
if (m.external) add({ id, area: 'mcp', product: pr.id, state: 'warn', detail: `${agent.name}: no ${m.name} MCP server yet`, fix: pr.guide });
|
|
171
|
+
else if (!bundled) add({ id, area: 'mcp', product: pr.id, state: 'info', detail: `${agent.name}: ${pr.name} has no runner for it` });
|
|
172
|
+
else add({ id, area: 'mcp', product: pr.id, state: 'fail', detail: `${agent.name}: ${m.name} is not registered`, fix: `${NPX} setup`, repair: UPDATE });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (entry.plugin) { add({ id, area: 'mcp', product: pr.id, state: 'ok', detail: `${agent.name}: ${m.name} served by its ${entry.plugin} plugin` }); continue; }
|
|
176
|
+
if (entry.unreadable || entry.unparsed) { add({ id, area: 'mcp', product: pr.id, state: 'warn', detail: `${agent.name}: ${m.name} is declared in a form doctor does not read` }); continue; }
|
|
177
|
+
if (entry.url) {
|
|
178
|
+
add({ id, area: 'mcp', product: pr.id, state: 'info', detail: `${agent.name}: ${m.name} is a remote server; its own guide proves it`, fix: pr.id === 'inbox' ? 'your agent runs the Fabric Inbox onboard guide\'s `prove` step' : null });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (path.isAbsolute(entry.command) && !fs.existsSync(entry.command)) {
|
|
182
|
+
add({ id, area: 'mcp', product: pr.id, state: 'fail', detail: `${agent.name}: ${m.name} points at ${entry.command}, which does not exist`, fix: installed ? `remove that entry from ${agent.name}, then ${NPX} doctor --fix` : `${NPX} setup --with ${pr.id}` });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (pr.mcp.spawn === false) {
|
|
186
|
+
const exec = (() => { try { fs.accessSync(entry.command, fs.constants.X_OK); return true; } catch (_) { return !path.isAbsolute(entry.command); } })();
|
|
187
|
+
const runner = entry.args.includes('--runner');
|
|
188
|
+
add({
|
|
189
|
+
id, area: 'mcp', product: pr.id, state: exec && runner ? 'ok' : 'fail',
|
|
190
|
+
detail: `${agent.name}: ${m.name} ${exec && runner ? 'registered; doctor does not start it (it enrols a session) — your agent\'s first fabric_whoami proves it' : !exec ? `points at ${entry.command}, which cannot run` : 'is registered without --runner, so it exits at start'}`,
|
|
191
|
+
fix: exec && runner ? null : `remove that entry from ${agent.name}, then ${NPX} setup`,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const key = JSON.stringify([entry.command, entry.args]);
|
|
196
|
+
if (!probes.has(key)) probes.set(key, { product: pr, entry, agents: [] });
|
|
197
|
+
probes.get(key).agents.push({ agent, id });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// One proving call per distinct registered command.
|
|
202
|
+
const results = await Promise.all([...probes.values()].map(async (t) => {
|
|
203
|
+
const tool = t.product.mcp.prove ? t.product.mcp.prove.tool : false;
|
|
204
|
+
const r = await probe({ command: t.entry.command, args: t.entry.args, env: t.entry.env, cwd: home, tool, toolArgs: (t.product.mcp.prove || {}).args, version: PKG });
|
|
205
|
+
return { t, r, tool };
|
|
206
|
+
}));
|
|
207
|
+
for (const { t, r, tool } of results) {
|
|
208
|
+
for (const { agent, id } of t.agents) {
|
|
209
|
+
const what = tool ? `${tool} answered` : 'initialize answered (its first real call is the agent\'s)';
|
|
210
|
+
add({
|
|
211
|
+
id, area: 'mcp', product: t.product.id, state: r.ok ? 'ok' : 'fail',
|
|
212
|
+
detail: `${agent.name}: ${t.product.mcp.name} — ${r.ok ? `${what}${r.server ? ` (${r.server.name} ${r.server.version || ''})`.replace(' )', ')') : ''}${r.tools !== null && r.tools !== undefined ? `, ${r.tools} tools` : ''}` : `proving call failed: ${r.reason}`}`,
|
|
213
|
+
fix: r.ok ? null : `open ${t.product.name}, then run doctor again; if it still fails, reinstall it from ${t.product.page}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Fabric services of the family products: the descriptor and its well-known document.
|
|
219
|
+
const dir = servicesDir(home, platform);
|
|
220
|
+
let descriptors = [];
|
|
221
|
+
try { descriptors = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch (_) { descriptors = []; }
|
|
222
|
+
for (const pr of catalog.PRODUCTS.filter((x) => x.serviceIds)) {
|
|
223
|
+
const mine = descriptors.map((f) => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (_) { return null; } })
|
|
224
|
+
.filter((d) => d && pr.serviceIds.includes(d.id));
|
|
225
|
+
const installed = platform === 'darwin' ? fs.existsSync(catalog.appPath(pr)) : true;
|
|
226
|
+
if (!mine.length) {
|
|
227
|
+
if (installed && platform === 'darwin') add({ id: `service.${pr.id}`, area: 'services', product: pr.id, state: 'warn', detail: `${pr.name}: no Fabric service descriptor in ${dir}`, fix: pr.guide });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (const d of mine) {
|
|
231
|
+
const w = await wellKnown(d.origin);
|
|
232
|
+
const supervisor = d.lifecycle ? `${d.lifecycle.manager || 'its supervisor'}${d.lifecycle.label ? ` ${d.lifecycle.label}` : ''}` : 'its supervisor';
|
|
233
|
+
const doctorCmd = d.commands && Array.isArray(d.commands.doctor) ? d.commands.doctor.map((x) => (/\s/.test(x) ? `"${x}"` : x)).join(' ') : null;
|
|
234
|
+
add({
|
|
235
|
+
id: `service.${d.id}.${d.instance || 'default'}`, area: 'services', product: pr.id,
|
|
236
|
+
state: w.ok === true ? (w.status === 'ok' ? 'ok' : 'warn') : w.ok === null ? 'info' : 'fail',
|
|
237
|
+
detail: `${d.name || d.id}: ${w.ok ? `answers at ${d.origin}${w.version ? ` (${w.version})` : ''}, status ${w.status}${w.degraded.length ? ` — degraded: ${w.degraded.join(', ')}` : ''}` : w.reason}`,
|
|
238
|
+
fix: w.ok === false ? `${supervisor} restarts it — never kill it by hand${doctorCmd ? `; diagnose with ${doctorCmd}` : ''}` : (w.ok && w.status !== 'ok' && doctorCmd ? doctorCmd : null),
|
|
239
|
+
repair: w.ok === false && platform === 'darwin' && d.lifecycle && d.lifecycle.manager === 'launchd' && d.lifecycle.label ? { kind: 'kickstart', label: d.lifecycle.label } : null,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return checks;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summarise(checks) {
|
|
247
|
+
const count = (st) => checks.filter((c) => c.state === st).length;
|
|
248
|
+
return { ok: count('ok'), warn: count('warn'), fail: count('fail'), info: count('info') };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Run the checks (read-only) and save the summary for the session hook. */
|
|
252
|
+
async function doctor({ home = os.homedir(), online = true, platform = process.env.PASSIONCODE_PLATFORM || process.platform, claude } = {}) {
|
|
253
|
+
claude = claude || L.claudeRunner();
|
|
254
|
+
const checks = sortChecks(await collect({ home, platform, claude, online }));
|
|
255
|
+
const result = { version: PKG, platform, at: new Date().toISOString(), checks, summary: summarise(checks) };
|
|
256
|
+
result.ok = result.summary.fail === 0;
|
|
257
|
+
save(home, result);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const ORDER = ['prerequisites', 'skills', 'apps', 'mcp', 'services'];
|
|
262
|
+
function sortChecks(checks) {
|
|
263
|
+
return checks.map((c, i) => [c, i]).sort((a, b) => (ORDER.indexOf(a[0].area) - ORDER.indexOf(b[0].area)) || (a[1] - b[1])).map(([c]) => c);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function save(home, result) {
|
|
267
|
+
try {
|
|
268
|
+
const p = L.paths(home);
|
|
269
|
+
fs.mkdirSync(p.state, { recursive: true });
|
|
270
|
+
fs.writeFileSync(path.join(p.state, 'doctor.json'), JSON.stringify({ at: result.at, version: PKG, summary: result.summary, failed: result.checks.filter((c) => c.state === 'fail').map((c) => c.id) }, null, 2) + '\n');
|
|
271
|
+
} catch (_) { /* the saved result is a convenience for the session hook */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function render(result) {
|
|
275
|
+
const icon = { ok: 'ok ', warn: 'warn', fail: 'FAIL', info: 'info' };
|
|
276
|
+
const lines = [];
|
|
277
|
+
let area = null;
|
|
278
|
+
for (const c of result.checks) {
|
|
279
|
+
if (c.area !== area) { area = c.area; lines.push('', `${area}`); }
|
|
280
|
+
lines.push(` ${icon[c.state]} ${c.detail}`);
|
|
281
|
+
if (c.fix && c.state !== 'ok') lines.push(` fix: ${c.fix}`);
|
|
282
|
+
}
|
|
283
|
+
const s = result.summary;
|
|
284
|
+
lines.push('', `${s.fail ? `${s.fail} problem(s)` : 'No problems'} · ${s.warn} warning(s) · ${s.ok} ok · ${s.info} note(s). ${s.fail || s.warn ? `\`${NPX} setup\` (or \`doctor --fix\`) repairs what it may; every other line names its fix.` : ''}`.trim());
|
|
285
|
+
return lines.join('\n').replace(/^\n/, '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { doctor, render, servicesDir, presentAgents, mcpEntryOf, collect, summarise, sortChecks, save, NPX, PKG };
|
package/lib/launcher.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* The PassionCode.ai launcher: installs the vendored payload as one release,
|
|
4
4
|
* gives Claude Code the plugins through a local marketplace, gives every other
|
|
5
|
-
* agent the same skills through the ~/.agents/skills hub, registers the
|
|
6
|
-
*
|
|
5
|
+
* agent the same skills through the ~/.agents/skills hub, registers the MCP servers of
|
|
6
|
+
* the installed PassionCode.ai apps for every agent that is present, and removes what would
|
|
7
7
|
* shadow them — reversibly. Every step is recorded; nothing is deleted.
|
|
8
8
|
*
|
|
9
9
|
* One channel per agent: Claude Code reads plugins, so nothing is written to
|
|
@@ -194,12 +194,26 @@ function marketplaceAddArg(source) {
|
|
|
194
194
|
return source.url || source.repo || source.path || null;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
const
|
|
198
|
-
const MCP_COMMAND = () => process.env.PASSIONCODE_FABRIC_DASHBOARDS_MCP
|
|
199
|
-
|| '/Applications/Fabric Dashboards.app/Contents/Resources/bin/fabric-dashboards-mcp';
|
|
197
|
+
const catalog = require('./catalog');
|
|
200
198
|
|
|
201
199
|
/**
|
|
202
|
-
* The
|
|
200
|
+
* The stdio MCP servers inside the PassionCode.ai app bundles (lib/catalog.js): name, the
|
|
201
|
+
* product's label, the command, and the arguments each agent gets (null: no runner for it).
|
|
202
|
+
*/
|
|
203
|
+
function bundledMcpServers() {
|
|
204
|
+
return catalog.PRODUCTS.filter((pr) => pr.mcp && !pr.mcp.external && pr.mcp.bin).map((pr) => {
|
|
205
|
+
const agentOf = (id) => catalog.AGENTS.find((a) => a.id === id);
|
|
206
|
+
const first = catalog.mcpCommand(pr, agentOf('claude')) || catalog.mcpCommand(pr, agentOf('codex'));
|
|
207
|
+
return {
|
|
208
|
+
name: pr.mcp.name, label: pr.name, product: pr.id, claudePlugin: pr.mcp.claudePlugin || null, preview: !!pr.preview,
|
|
209
|
+
command: first ? first.command : catalog.appPath(pr),
|
|
210
|
+
argsFor: (id) => { const c = catalog.mcpCommand(pr, agentOf(id)); return c ? c.args : null; },
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The agents besides Claude Code the apps' MCP servers are registered for, and
|
|
203
217
|
* the file each keeps its MCP configuration in. An agent is present when its directory
|
|
204
218
|
* is; a missing file under a present agent is created. Claude Code is registered through
|
|
205
219
|
* its own CLI instead: every running session rewrites ~/.claude.json, so a second writer
|
|
@@ -208,9 +222,9 @@ const MCP_COMMAND = () => process.env.PASSIONCODE_FABRIC_DASHBOARDS_MCP
|
|
|
208
222
|
function mcpFileTargets(home) {
|
|
209
223
|
const kimiHome = process.env.KIMI_CODE_HOME || path.join(home, '.kimi-code');
|
|
210
224
|
return [
|
|
211
|
-
{ agent: 'Codex CLI', dir: path.join(home, '.codex'), file: path.join(home, '.codex', 'config.toml'), kind: 'toml' },
|
|
212
|
-
{ agent: 'Gemini CLI', dir: path.join(home, '.gemini'), file: path.join(home, '.gemini', 'settings.json'), kind: 'json' },
|
|
213
|
-
{ agent: 'Kimi Code', dir: kimiHome, file: path.join(kimiHome, 'mcp.json'), kind: 'json' },
|
|
225
|
+
{ id: 'codex', agent: 'Codex CLI', dir: path.join(home, '.codex'), file: path.join(home, '.codex', 'config.toml'), kind: 'toml' },
|
|
226
|
+
{ id: 'gemini', agent: 'Gemini CLI', dir: path.join(home, '.gemini'), file: path.join(home, '.gemini', 'settings.json'), kind: 'json' },
|
|
227
|
+
{ id: 'kimi', agent: 'Kimi Code', dir: kimiHome, file: path.join(kimiHome, 'mcp.json'), kind: 'json' },
|
|
214
228
|
];
|
|
215
229
|
}
|
|
216
230
|
|
|
@@ -248,8 +262,35 @@ function tomlTableCommand(range) {
|
|
|
248
262
|
return null;
|
|
249
263
|
}
|
|
250
264
|
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
/** The `args` array of a TOML table range: an array, [] when absent, null when unreadable. */
|
|
266
|
+
function tomlTableArgs(range) {
|
|
267
|
+
for (let i = range.start + 1; i < range.end; i++) {
|
|
268
|
+
const m = /^\s*args\s*=\s*(\[.*\])\s*(?:#.*)?$/.exec(range.lines[i]);
|
|
269
|
+
if (m) { try { return JSON.parse(m[1]); } catch (_) { return null; } }
|
|
270
|
+
}
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Settings of server `name` left elsewhere in a TOML text: a `[mcp_servers.<name>.…]` sub-table
|
|
276
|
+
* header anywhere, or a `<name>.…` / `<name> =` key inside the `[mcp_servers]` table itself. A key
|
|
277
|
+
* of the same name in any other table is not the server's.
|
|
278
|
+
*/
|
|
279
|
+
function tomlSubSettings(text, name) {
|
|
280
|
+
const lines = text.split('\n');
|
|
281
|
+
const sub = new RegExp(`^\\s*\\[\\s*["']?mcp_servers["']?\\s*\\.\\s*["']?${name}["']?\\s*\\.`);
|
|
282
|
+
const key = new RegExp(`^\\s*["']?${name}["']?\\s*(\\.|=)`);
|
|
283
|
+
let inServers = false;
|
|
284
|
+
for (const line of lines) {
|
|
285
|
+
if (sub.test(line)) return true;
|
|
286
|
+
if (/^\s*\[/.test(line)) { inServers = /^\s*\[\s*["']?mcp_servers["']?\s*\]\s*(?:#.*)?$/.test(line); continue; }
|
|
287
|
+
if (inServers && key.test(line)) return true;
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function mcpTomlTable(name, command, args = []) {
|
|
293
|
+
return `["mcp_servers"."${name}"]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(args).replace(/,/g, ', ')}\n`;
|
|
253
294
|
}
|
|
254
295
|
|
|
255
296
|
/** Two MCP entries are the same when their JSON lines up at every depth, key order aside. */
|
|
@@ -273,7 +314,12 @@ function removeMcpRegistration(record, claude) {
|
|
|
273
314
|
const range = tomlTableRange(text, record.name);
|
|
274
315
|
if (!range) return null;
|
|
275
316
|
if (tomlTableCommand(range) !== record.entry.command) return kept('was changed');
|
|
276
|
-
|
|
317
|
+
if (JSON.stringify(tomlTableArgs(range)) !== JSON.stringify(record.entry.args || [])) return kept('was changed');
|
|
318
|
+
// A sub-table or dotted key added since (e.g. [mcp_servers.<name>.env]) would be left without
|
|
319
|
+
// its command, and Codex refuses such a file: the entry is the person's now.
|
|
320
|
+
const rest = range.lines.slice(0, range.start).concat(range.lines.slice(range.end)).join('\n');
|
|
321
|
+
if (tomlSubSettings(rest, record.name)) return kept('has settings added since');
|
|
322
|
+
writeText(record.file, rest);
|
|
277
323
|
return removed;
|
|
278
324
|
}
|
|
279
325
|
let cfg;
|
|
@@ -323,7 +369,7 @@ function verifyPayload(dir, manifest) {
|
|
|
323
369
|
}
|
|
324
370
|
|
|
325
371
|
/** Plan and apply an update. Returns the steps with their outcomes. */
|
|
326
|
-
function update({ home, dryRun = false, claude = claudeRunner(), log = () => {} } = {}) {
|
|
372
|
+
function update({ home, dryRun = false, includePreview = false, claude = claudeRunner(), log = () => {} } = {}) {
|
|
327
373
|
const p = paths(home);
|
|
328
374
|
const manifest = payloadManifest();
|
|
329
375
|
verifyPayload(PAYLOAD(), manifest);
|
|
@@ -495,88 +541,107 @@ function update({ home, dryRun = false, claude = claudeRunner(), log = () => {}
|
|
|
495
541
|
}
|
|
496
542
|
}
|
|
497
543
|
|
|
498
|
-
// 5. The
|
|
499
|
-
// person or another tool set are never rewritten: a matching entry is
|
|
500
|
-
// different one is reported. What the launcher adds is journalled before it is
|
|
544
|
+
// 5. The MCP servers of the installed apps (ADR-0003, ADR-0004), for every agent that is
|
|
545
|
+
// present. Entries a person or another tool set are never rewritten: a matching entry is
|
|
546
|
+
// left, a different one is reported. What the launcher adds is journalled before it is
|
|
501
547
|
// written, so restore and uninstall can take it out again.
|
|
502
548
|
const registered = [];
|
|
503
|
-
const mcpCommand = MCP_COMMAND();
|
|
504
549
|
const mcpBackup = (file, agent) => {
|
|
505
550
|
if (!lstat(file)) return null;
|
|
506
551
|
fs.mkdirSync(quarantineDir, { recursive: true });
|
|
507
552
|
const backup = path.join(quarantineDir, `${path.basename(file)}.before-mcp-${agent.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`);
|
|
508
|
-
fs.copyFileSync(file, backup);
|
|
553
|
+
if (!lstat(backup)) fs.copyFileSync(file, backup);
|
|
509
554
|
return backup;
|
|
510
555
|
};
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
write()
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
556
|
+
for (const server of bundledMcpServers()) {
|
|
557
|
+
const { name, label, command: mcpCommand } = server;
|
|
558
|
+
const register = (record, write, backupOf = null) => step('mcp', `register ${name} for ${record.agent} in ${record.file}`, () => {
|
|
559
|
+
record.backup = backupOf ? backupOf() : null; // only when it applies: a dry run copies nothing
|
|
560
|
+
recordMove(record);
|
|
561
|
+
write();
|
|
562
|
+
registered.push({ agent: record.agent, file: record.file, name: record.name, entry: record.entry, ...(record.via ? { via: record.via } : {}) });
|
|
563
|
+
});
|
|
564
|
+
// The same entry means the same command AND arguments; anything else is the person's and is kept.
|
|
565
|
+
const existingNote = (agent, existing, args, want) => existing === mcpCommand && JSON.stringify(args || []) === JSON.stringify(want || [])
|
|
566
|
+
? { kind: 'mcp', detail: `${agent} already runs ${name} from ${mcpCommand}`, outcome: 'skipped' }
|
|
567
|
+
: { kind: 'mcp', detail: `${agent} has its own ${name} entry (${existing || 'not a stdio server'}${existing === mcpCommand ? ` with arguments ${JSON.stringify(args || [])}` : ''}); it was kept`, outcome: 'skipped' };
|
|
568
|
+
if (!lstat(mcpCommand)) {
|
|
569
|
+
steps.push({ kind: 'mcp', detail: `no ${label} MCP at ${mcpCommand}; ${name} was not registered`, outcome: 'skipped' });
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (server.preview && !includePreview) {
|
|
573
|
+
steps.push({ kind: 'mcp', detail: `${label} is an early preview: ${name} is registered by \`passioncode setup\`, not by an update`, outcome: 'skipped' });
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
522
576
|
const claudeJson = path.join(p.home, '.claude.json');
|
|
577
|
+
const claudeArgs = server.argsFor('claude');
|
|
523
578
|
if (!hasClaude) {
|
|
524
|
-
steps.push({ kind: 'mcp', detail: `Claude Code CLI not found; ${
|
|
525
|
-
} else {
|
|
579
|
+
steps.push({ kind: 'mcp', detail: `Claude Code CLI not found; ${name} was not registered for it`, outcome: 'skipped' });
|
|
580
|
+
} else if (server.claudePlugin && installedIds().some((pid) => pid.startsWith(server.claudePlugin))) {
|
|
581
|
+
steps.push({ kind: 'mcp', detail: `Claude Code gets ${name} from its ${server.claudePlugin.replace(/@$/, '')} plugin; no second entry was added`, outcome: 'skipped' });
|
|
582
|
+
} else if (claudeArgs) {
|
|
526
583
|
let servers = null;
|
|
527
584
|
try { servers = readSettings(claudeJson).doc.mcpServers || {}; } catch (error) {
|
|
528
|
-
steps.push({ kind: 'mcp', detail: `keep ${claudeJson}: it could not be read`, outcome: 'failed', error: error.message });
|
|
585
|
+
steps.push({ kind: 'mcp', detail: `keep ${claudeJson}: it could not be read; ${name} was not registered`, outcome: 'failed', error: error.message });
|
|
529
586
|
}
|
|
530
|
-
const existing = servers && servers[
|
|
531
|
-
if (existing) steps.push(existingNote('Claude Code', typeof existing === 'object' ? existing.command || existing.url : null));
|
|
587
|
+
const existing = servers && servers[name];
|
|
588
|
+
if (existing) steps.push(existingNote('Claude Code', typeof existing === 'object' ? existing.command || existing.url : null, typeof existing === 'object' ? existing.args : undefined, claudeArgs));
|
|
532
589
|
else if (servers) {
|
|
533
|
-
const entry = { type: 'stdio', command: mcpCommand, args:
|
|
534
|
-
register({ kind: 'mcp', via: 'claude', agent: 'Claude Code', file: claudeJson, name
|
|
535
|
-
const r = claude.run(['mcp', 'add', '--scope', 'user',
|
|
590
|
+
const entry = { type: 'stdio', command: mcpCommand, args: claudeArgs, env: {} };
|
|
591
|
+
register({ kind: 'mcp', via: 'claude', agent: 'Claude Code', file: claudeJson, name, entry, backup: null }, () => {
|
|
592
|
+
const r = claude.run(['mcp', 'add', '--scope', 'user', name, '--', mcpCommand, ...claudeArgs], { cwd: p.state });
|
|
536
593
|
if (r.code !== 0) throw new Error(r.out || 'claude mcp add failed');
|
|
537
594
|
const now = readSettings(claudeJson).doc.mcpServers || {};
|
|
538
|
-
if (!now[
|
|
595
|
+
if (!now[name] || now[name].command !== mcpCommand) throw new Error(`Claude Code did not register ${name} in ${claudeJson}`);
|
|
539
596
|
// What the CLI wrote is what restore and uninstall compare against.
|
|
540
|
-
entry.type = now[
|
|
597
|
+
entry.type = now[name].type; entry.args = now[name].args; entry.env = now[name].env;
|
|
541
598
|
});
|
|
542
599
|
}
|
|
543
600
|
}
|
|
544
601
|
for (const target of mcpFileTargets(p.home)) {
|
|
545
602
|
if (!isDirectory(target.dir)) {
|
|
546
|
-
steps.push({ kind: 'mcp', detail: `${target.agent} not found; ${
|
|
603
|
+
steps.push({ kind: 'mcp', detail: `${target.agent} not found; ${name} was not registered for it`, outcome: 'skipped' });
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const args = server.argsFor(target.id);
|
|
607
|
+
if (!args) {
|
|
608
|
+
steps.push({ kind: 'mcp', detail: `${label} has no ${target.agent} runner; ${name} was not registered for it`, outcome: 'skipped' });
|
|
547
609
|
continue;
|
|
548
610
|
}
|
|
549
611
|
if (target.kind === 'json') {
|
|
550
612
|
let cfg;
|
|
551
613
|
try { cfg = readSettings(target.file); } catch (error) {
|
|
552
|
-
steps.push({ kind: 'mcp', detail: `keep ${target.file}: it could not be read`, outcome: 'failed', error: error.message });
|
|
614
|
+
steps.push({ kind: 'mcp', detail: `keep ${target.file}: it could not be read; ${name} was not registered`, outcome: 'failed', error: error.message });
|
|
553
615
|
continue;
|
|
554
616
|
}
|
|
555
|
-
const existing = (cfg.doc.mcpServers || {})[
|
|
556
|
-
if (existing) { steps.push(existingNote(target.agent, typeof existing === 'object' ? existing.command || existing.url : null)); continue; }
|
|
557
|
-
const entry = { command: mcpCommand, args
|
|
558
|
-
register({ kind: 'mcp', agent: target.agent, file: target.file, name
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
617
|
+
const existing = (cfg.doc.mcpServers || {})[name];
|
|
618
|
+
if (existing) { steps.push(existingNote(target.agent, typeof existing === 'object' ? existing.command || existing.url : null, typeof existing === 'object' ? existing.args : undefined, args)); continue; }
|
|
619
|
+
const entry = { command: mcpCommand, args, env: {} };
|
|
620
|
+
register({ kind: 'mcp', agent: target.agent, file: target.file, name, entry }, () => {
|
|
621
|
+
const fresh = readSettings(target.file).doc; // an earlier server may have written it this run
|
|
622
|
+
fresh.mcpServers = { ...(fresh.mcpServers || {}), [name]: entry };
|
|
623
|
+
writeJson(target.file, fresh);
|
|
624
|
+
}, () => mcpBackup(target.file, target.agent));
|
|
562
625
|
} else {
|
|
563
626
|
let text = '';
|
|
564
627
|
try { text = fs.readFileSync(target.file, 'utf8'); } catch (error) {
|
|
565
628
|
if (error.code !== 'ENOENT') {
|
|
566
|
-
steps.push({ kind: 'mcp', detail: `keep ${target.file}: it could not be read`, outcome: 'failed', error: error.message });
|
|
629
|
+
steps.push({ kind: 'mcp', detail: `keep ${target.file}: it could not be read; ${name} was not registered`, outcome: 'failed', error: error.message });
|
|
567
630
|
continue;
|
|
568
631
|
}
|
|
569
632
|
}
|
|
570
|
-
const range = tomlTableRange(text,
|
|
571
|
-
if (range) { const c = tomlTableCommand(range); steps.push(existingNote(target.agent, c == null ? null : c)); continue; }
|
|
572
|
-
if (tomlMentions(text,
|
|
573
|
-
steps.push({ kind: 'mcp', detail: `${target.agent} declares ${
|
|
633
|
+
const range = tomlTableRange(text, name);
|
|
634
|
+
if (range) { const c = tomlTableCommand(range); steps.push(existingNote(target.agent, c == null ? null : c, tomlTableArgs(range), args)); continue; }
|
|
635
|
+
if (tomlMentions(text, name)) {
|
|
636
|
+
steps.push({ kind: 'mcp', detail: `${target.agent} declares ${name} in ${target.file} in a form the launcher does not edit; it was kept`, outcome: 'skipped' });
|
|
574
637
|
continue;
|
|
575
638
|
}
|
|
576
|
-
const entry = { command: mcpCommand, args
|
|
577
|
-
register({ kind: 'mcp', agent: target.agent, file: target.file, name
|
|
578
|
-
|
|
579
|
-
|
|
639
|
+
const entry = { command: mcpCommand, args };
|
|
640
|
+
register({ kind: 'mcp', agent: target.agent, file: target.file, name, entry }, () => {
|
|
641
|
+
let now = '';
|
|
642
|
+
try { now = fs.readFileSync(target.file, 'utf8'); } catch (_) { now = ''; }
|
|
643
|
+
writeText(target.file, now ? `${now.endsWith('\n') ? now : `${now}\n`}\n${mcpTomlTable(name, mcpCommand, args)}` : mcpTomlTable(name, mcpCommand, args));
|
|
644
|
+
}, () => mcpBackup(target.file, target.agent));
|
|
580
645
|
}
|
|
581
646
|
}
|
|
582
647
|
}
|
|
@@ -789,5 +854,5 @@ module.exports = {
|
|
|
789
854
|
restore: (opts = {}) => withUpdateLock(opts.home, () => restore(opts)),
|
|
790
855
|
uninstall: (opts = {}) => withUpdateLock(opts.home, () => uninstall(opts)),
|
|
791
856
|
status, setConfig, paths, CHANNELS, MARKETPLACE, payloadManifest, marketplaceAddArg,
|
|
792
|
-
acquireUpdateLock, UpdateRunning, staleReleases,
|
|
857
|
+
acquireUpdateLock, UpdateRunning, staleReleases, claudeRunner, bundledMcpServers, mcpFileTargets, readSettings, tomlTableRange, tomlTableCommand, tomlTableArgs, tomlMentions,
|
|
793
858
|
};
|