@passioncode-ai/passioncode 0.1.53 → 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/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 Fabric
6
- * Dashboards MCP server for every agent that is present, and removes what would
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 MCP_NAME = 'fabric-dashboards';
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 agents besides Claude Code the Fabric Dashboards MCP server is registered for, and
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
- function mcpTomlTable(command) {
252
- return `["mcp_servers"."${MCP_NAME}"]\ncommand = ${JSON.stringify(command)}\nargs = []\n`;
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
- writeText(record.file, range.lines.slice(0, range.start).concat(range.lines.slice(range.end)).join('\n'));
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 Fabric Dashboards MCP server, for every agent that is present. Entries a
499
- // person or another tool set are never rewritten: a matching entry is left, a
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
- const register = (record, write) => step('mcp', `register ${MCP_NAME} for ${record.agent} in ${record.file}`, () => {
512
- recordMove(record);
513
- write();
514
- registered.push({ agent: record.agent, file: record.file, name: record.name, entry: record.entry, ...(record.via ? { via: record.via } : {}) });
515
- });
516
- const existingNote = (agent, existing) => existing === mcpCommand
517
- ? { kind: 'mcp', detail: `${agent} already runs ${MCP_NAME} from ${mcpCommand}`, outcome: 'skipped' }
518
- : { kind: 'mcp', detail: `${agent} has its own ${MCP_NAME} entry (${existing || 'not a stdio server'}); it was kept`, outcome: 'skipped' };
519
- if (!lstat(mcpCommand)) {
520
- steps.push({ kind: 'mcp', detail: `no Fabric Dashboards MCP at ${mcpCommand}; nothing was registered`, outcome: 'skipped' });
521
- } else {
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; ${MCP_NAME} was not registered for it`, outcome: 'skipped' });
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[MCP_NAME];
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: [], env: {} };
534
- register({ kind: 'mcp', via: 'claude', agent: 'Claude Code', file: claudeJson, name: MCP_NAME, entry, backup: null }, () => {
535
- const r = claude.run(['mcp', 'add', '--scope', 'user', MCP_NAME, '--', mcpCommand], { cwd: p.state });
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[MCP_NAME] || now[MCP_NAME].command !== mcpCommand) throw new Error(`Claude Code did not register ${MCP_NAME} in ${claudeJson}`);
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[MCP_NAME].type; entry.args = now[MCP_NAME].args; entry.env = now[MCP_NAME].env;
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; ${MCP_NAME} was not registered for it`, outcome: 'skipped' });
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 || {})[MCP_NAME];
556
- if (existing) { steps.push(existingNote(target.agent, typeof existing === 'object' ? existing.command || existing.url : null)); continue; }
557
- const entry = { command: mcpCommand, args: [], env: {} };
558
- register({ kind: 'mcp', agent: target.agent, file: target.file, name: MCP_NAME, entry, backup: mcpBackup(target.file, target.agent) }, () => {
559
- cfg.doc.mcpServers = { ...(cfg.doc.mcpServers || {}), [MCP_NAME]: entry };
560
- writeJson(target.file, cfg.doc);
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, MCP_NAME);
571
- if (range) { const c = tomlTableCommand(range); steps.push(existingNote(target.agent, c == null ? null : c)); continue; }
572
- if (tomlMentions(text, MCP_NAME)) {
573
- steps.push({ kind: 'mcp', detail: `${target.agent} declares ${MCP_NAME} in ${target.file} in a form the launcher does not edit; it was kept`, outcome: 'skipped' });
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: MCP_NAME, entry, backup: mcpBackup(target.file, target.agent) }, () => {
578
- writeText(target.file, text ? `${text.endsWith('\n') ? text : `${text}\n`}\n${mcpTomlTable(mcpCommand)}` : mcpTomlTable(mcpCommand));
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
  };