@sbains2/lifeos 0.1.3 → 0.2.1

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/README.md CHANGED
@@ -46,14 +46,22 @@ See [../docs/TRUST.md](../docs/TRUST.md) for the full commitments.
46
46
 
47
47
  After the wizard prints `Done in Xm Ys.`, here's the path from "scaffold exists" to "council convenes on real artifacts":
48
48
 
49
- ### 1. Wire your MCP servers (10-30 min the first time)
49
+ ### 1. Set env vars + restart Claude (~3-5 min the first time)
50
50
 
51
- Open `.lifeos/INSTALL_MCPS.md` in the scaffolded directory. For each MCP listed:
51
+ As of v0.1.5, the wizard does the heavy lifting for you:
52
52
 
53
- - Read the install hint (most point to a Smithery search or specific GitHub repo)
54
- - Install the MCP via the recommended method (usually `npx` or `uvx` no Docker for v0.1 picks)
55
- - Set the auth env var in your shell (`export GITHUB_PERSONAL_ACCESS_TOKEN=...`, etc.see each MCP's docs)
56
- - Verify the MCP shows up in your Claude Code config (`~/.claude/settings.json` or your IDE's MCP settings)
53
+ - **`.mcp.json` is already written** in your scaffold dir Claude Code, Cursor, and Cline auto-detect this
54
+ - **Claude Desktop config was offered for merge** (with backup) if you said yes
55
+ - **Per-MCP setup status was already printed** at the end of the wizard read it carefully
56
+
57
+ What you still need to do, listed in the wizard's status report:
58
+
59
+ - **For `needs_env` MCPs:** set the env var in your shell (`export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_...`) — the wizard surfaced exactly which env vars + the URL to get each one. Add to your `~/.zshrc` or `~/.bashrc` for persistence.
60
+ - **For `needs_oauth` MCPs:** Claude will trigger the OAuth flow on first invocation — usually opens a browser, you click Authorize, done.
61
+ - **For `manual` MCPs:** read `.lifeos/INSTALL_MCPS.md` for install hints; these are MCPs without a single-command auto-install (e.g., Canvas, Discord, Apple Notes). One-time clone + build per repo.
62
+ - **For `risk` MCPs (only LinkedIn at v0.1.5):** if you said "yes, include in auto-config" the entry was wired; if "no" the MCP is in your `lifeos.config.json` but skipped from `.mcp.json` — install manually.
63
+
64
+ **Then restart Claude Desktop** (Cmd+Q, then reopen) so the new MCPs load. For Claude Code, restart your terminal or run `claude /mcp` to see the wired list.
57
65
 
58
66
  ### 2. Open the folder in Claude Code
59
67
 
package/bin/lifeos.js CHANGED
@@ -1,41 +1,75 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from '../src/index.js';
3
+ import { runDoctorCommand } from '../src/commands/doctor.js';
3
4
 
4
- // Parse argv: positional target dir + flags
5
- // Usage: lifeos [target-dir] [--minimal]
5
+ // Parse argv: optional subcommand + positional target dir + flags
6
+ // Usage:
7
+ // lifeos [target-dir] [--minimal] # wizard (default)
8
+ // lifeos doctor [target-dir] [--json] [--quiet] [--strict]
6
9
  const args = process.argv.slice(2);
7
10
  const flags = new Set(args.filter((a) => a.startsWith('--')));
8
- const positional = args.filter((a) => !a.startsWith('--'));
11
+ const positional = args.filter((a) => !a.startsWith('--') && !a.startsWith('-'));
9
12
 
10
- if (flags.has('--help') || flags.has('-h')) {
11
- console.log(`lifeos — scaffold a personal LifeOS
13
+ if (flags.has('--help') || flags.has('-h') || positional[0] === 'help') {
14
+ console.log(`lifeos — scaffold and verify a personal LifeOS
12
15
 
13
16
  Usage:
14
- lifeos [target-dir] [options]
17
+ lifeos [target-dir] [options] # run the wizard
18
+ lifeos doctor [target-dir] [options] # verify scaffold health
19
+ lifeos --help # show this
15
20
 
16
- Options:
21
+ Wizard options:
17
22
  --minimal Skip optional substantive prompts (longer-arc focus,
18
23
  quadrant/council/MCP confirmations). Uses template
19
24
  defaults everywhere. Target install time: < 60 seconds.
20
- --help, -h Show this help
25
+
26
+ Doctor options:
27
+ --fix Apply deterministic fixes (mkdir folders, copy missing
28
+ council files, generate writing_style stub, generate
29
+ .env.example with help URLs). Never touches secrets or
30
+ shell rc. Re-runs doctor after to show before/after.
31
+ --json Emit machine-readable JSON (for CI / scripting)
32
+ --quiet Suppress ✓ lines, only show warnings and errors
33
+ --strict Exit code 1 even on warnings (default: warnings are 0)
21
34
 
22
35
  Arguments:
23
- target-dir Directory to scaffold into (default: current working dir)
36
+ target-dir Directory to scaffold into / verify (default: current working dir)
24
37
 
25
38
  Examples:
26
- lifeos # scaffold into current dir, full wizard
27
- lifeos ~/my-lifeos # scaffold into ~/my-lifeos
28
- lifeos ~/my-lifeos --minimal # speed-optimized install
39
+ lifeos # scaffold into current dir, full wizard
40
+ lifeos ~/my-lifeos # scaffold into ~/my-lifeos
41
+ lifeos ~/my-lifeos --minimal # speed-optimized install
42
+ lifeos doctor # verify current dir
43
+ lifeos doctor ~/my-lifeos # verify ~/my-lifeos
44
+ lifeos doctor --json | jq '.summary' # programmatic check
29
45
  `);
30
46
  process.exit(0);
31
47
  }
32
48
 
33
- const targetDir = positional[0] ?? process.cwd();
34
- const options = {
35
- minimal: flags.has('--minimal'),
36
- };
49
+ // Subcommand dispatch
50
+ const subcommand = ['doctor', 'brain', 'migrate'].includes(positional[0]) ? positional[0] : null;
51
+ const remainingPositional = subcommand ? positional.slice(1) : positional;
52
+ const targetDir = remainingPositional[0] ?? process.cwd();
53
+
54
+ async function main() {
55
+ if (subcommand === 'doctor') {
56
+ const exitCode = await runDoctorCommand(targetDir, {
57
+ json: flags.has('--json'),
58
+ quiet: flags.has('--quiet'),
59
+ strict: flags.has('--strict'),
60
+ fix: flags.has('--fix'),
61
+ });
62
+ process.exit(exitCode);
63
+ }
64
+ if (subcommand === 'brain' || subcommand === 'migrate') {
65
+ console.error(`\nNot yet implemented: \`lifeos ${subcommand}\`. See docs/COMMANDS.md for the design.`);
66
+ process.exit(2);
67
+ }
68
+ // Default: run the wizard
69
+ await run(targetDir, { minimal: flags.has('--minimal') });
70
+ }
37
71
 
38
- run(targetDir, options).catch((err) => {
72
+ main().catch((err) => {
39
73
  // Inquirer throws ExitPromptError when user hits Ctrl+C — handle gracefully
40
74
  if (err?.name === 'ExitPromptError') {
41
75
  console.log('\nCancelled.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sbains2/lifeos",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Scaffold a personal LifeOS — life-quadrant folders, a council of AI agent voices, and curated MCP wiring. Generates a working lifeos.config.json + .claude/agents/council/ in 2-4 minutes (or <60s with --minimal). BETA — early seed release.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `lifeos doctor` subcommand orchestrator.
3
+ *
4
+ * Runs the doctor checks against a target directory, prints a per-check report,
5
+ * and exits with code 0 (all green or warnings only) or 1 (any error).
6
+ *
7
+ * Supports:
8
+ * --json — emit machine-readable JSON instead of formatted output
9
+ * --quiet — suppress ✓ lines, only show warnings + errors
10
+ * --strict — exit code 1 even on warnings (default treats warnings as 0)
11
+ */
12
+
13
+ import { runDoctor } from '../doctor.js';
14
+ import { applyDoctorFixes } from '../remedy.js';
15
+ import { c } from '../utils/colors.js';
16
+
17
+ export async function runDoctorCommand(targetDir, options = {}) {
18
+ const { json = false, quiet = false, strict = false, fix = false } = options;
19
+
20
+ const result = runDoctor(targetDir);
21
+
22
+ if (json && !fix) {
23
+ console.log(JSON.stringify(result, null, 2));
24
+ if (result.summary.error > 0) return 1;
25
+ if (strict && result.summary.warn > 0) return 1;
26
+ return 0;
27
+ }
28
+
29
+ if (!fix) {
30
+ printHumanReport(result, { quiet });
31
+ if (result.summary.error > 0) return 1;
32
+ if (strict && result.summary.warn > 0) return 1;
33
+ return 0;
34
+ }
35
+
36
+ // --fix mode: print initial state, apply remedies, re-run doctor, print final state
37
+ console.log('');
38
+ console.log(c.bold('lifeos doctor --fix') + c.dim(' — initial state'));
39
+ console.log('');
40
+ printSummaryLine(result.summary);
41
+
42
+ const remedy = applyDoctorFixes(targetDir);
43
+ console.log('');
44
+ console.log(c.bold('Applying deterministic fixes...'));
45
+ if (remedy.applied.length === 0) {
46
+ console.log(c.dim(` ${c.dot} nothing to auto-fix (issues remaining need user input — see suggestions below)`));
47
+ } else {
48
+ for (const fix of remedy.applied) {
49
+ console.log(` ${c.check} ${fix.id}: ${fix.message}`);
50
+ }
51
+ }
52
+ if (remedy.skipped.length > 0) {
53
+ console.log(c.dim(` Skipped (need manual action):`));
54
+ for (const s of remedy.skipped) {
55
+ console.log(c.dim(` ${c.dot} ${s.id}: ${s.reason}`));
56
+ }
57
+ }
58
+
59
+ // Re-run doctor to show new state
60
+ console.log('');
61
+ console.log(c.bold('Re-running doctor...'));
62
+ console.log('');
63
+ const after = runDoctor(targetDir);
64
+ printHumanReport(after, { quiet });
65
+
66
+ if (json) {
67
+ console.log('');
68
+ console.log(c.dim('--json + --fix combined: emitting after-state JSON'));
69
+ console.log(JSON.stringify(after, null, 2));
70
+ }
71
+
72
+ if (after.summary.error > 0) return 1;
73
+ if (strict && after.summary.warn > 0) return 1;
74
+ return 0;
75
+ }
76
+
77
+ function printSummaryLine(summary) {
78
+ const { ok, warn, error } = summary;
79
+ const parts = [
80
+ `${c.ok(`${ok} ok`)}`,
81
+ warn > 0 ? c.warn(`${warn} warning${warn === 1 ? '' : 's'}`) : null,
82
+ error > 0 ? c.err(`${error} error${error === 1 ? '' : 's'}`) : null,
83
+ ].filter(Boolean);
84
+ console.log(' ' + parts.join(', '));
85
+ }
86
+
87
+ function printHumanReport(result, { quiet }) {
88
+ console.log('');
89
+ console.log(c.bold('lifeos doctor') + c.dim(' — checking your scaffold'));
90
+ console.log('');
91
+
92
+ for (const check of result.checks) {
93
+ if (quiet && check.status === 'ok') continue;
94
+ const icon =
95
+ check.status === 'ok'
96
+ ? c.ok('✓')
97
+ : check.status === 'warn'
98
+ ? c.warn('⚠')
99
+ : c.err('✗');
100
+ const label = check.status === 'ok' ? c.dim(check.name) : check.name;
101
+ console.log(` ${icon} ${label}`);
102
+ if (check.message && check.status !== 'ok') {
103
+ // Wrap long messages
104
+ const lines = check.message.split('\n');
105
+ for (const line of lines) console.log(c.dim(` ${line}`));
106
+ }
107
+ if (check.fix) {
108
+ const fixLines = check.fix.split('\n');
109
+ console.log(c.cyan(` → fix: ${fixLines[0]}`));
110
+ for (let i = 1; i < fixLines.length; i++) {
111
+ console.log(c.cyan(` ${fixLines[i].replace(/^\s+/, '')}`));
112
+ }
113
+ }
114
+ }
115
+
116
+ console.log('');
117
+ const { ok, warn, error } = result.summary;
118
+ const summaryParts = [
119
+ `${c.ok(`${ok} ok`)}`,
120
+ warn > 0 ? c.warn(`${warn} warning${warn === 1 ? '' : 's'}`) : null,
121
+ error > 0 ? c.err(`${error} error${error === 1 ? '' : 's'}`) : null,
122
+ ].filter(Boolean);
123
+ console.log(c.bold('Summary: ') + summaryParts.join(', '));
124
+ if (error === 0 && warn === 0) {
125
+ console.log(c.dim('Your scaffold looks healthy. Open it in Claude Code and convene the council.'));
126
+ } else if (error === 0) {
127
+ console.log(c.dim('Mostly good — warnings are non-blocking but worth addressing when you have a moment.'));
128
+ } else {
129
+ console.log(c.dim('Errors above will block convening. Apply the fixes shown.'));
130
+ }
131
+ console.log('');
132
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * `lifeos doctor` — verifies post-install health of a LifeOS scaffold.
3
+ *
4
+ * Loads the user's `lifeos.config.json` and runs 8 checks:
5
+ * 1. Config exists + parses
6
+ * 2. Schema version matches CLI's supported version
7
+ * 3. Schema cross-refs (reuses validate.js — same checks the wizard runs at write time)
8
+ * 4. Council files present at the paths config references
9
+ * 5. Quadrant folders exist for every active quadrant
10
+ * 6. Env vars set for every wired MCP that needs auth (read-only check —
11
+ * never reads the value, only checks `process.env[name]` is truthy)
12
+ * 7. Writing-style file present (if `writing_style_path` is set)
13
+ * 8. Brain artifact populated (warning if `.lifeos/brain/` is empty)
14
+ *
15
+ * Plus a bonus check (added for v0.2.0):
16
+ * 9. MCP runtimes available (npx / uvx / docker — checks `which <runtime>` for
17
+ * each unique runtime referenced by wired MCPs)
18
+ *
19
+ * Each check returns { id, name, status: 'ok'|'warn'|'error', message, fix? }.
20
+ * Caller decides how to render and what exit code to use.
21
+ */
22
+
23
+ import { existsSync, readFileSync, accessSync, readdirSync, constants } from 'node:fs';
24
+ import { join, dirname, isAbsolute } from 'node:path';
25
+ import { execSync } from 'node:child_process';
26
+ import { listCouncilFiles, loadRegistry } from './templates-loader.js';
27
+ import { validate } from './validate.js';
28
+
29
+ const SUPPORTED_VERSION = '0.2';
30
+
31
+ /**
32
+ * Run all checks against a directory. Returns { checks: [], summary: {ok, warn, error} }.
33
+ */
34
+ export function runDoctor(targetDir) {
35
+ const checks = [];
36
+
37
+ // Check 1: config exists + parses
38
+ const configPath = join(targetDir, 'lifeos.config.json');
39
+ let config = null;
40
+ if (!existsSync(configPath)) {
41
+ checks.push({
42
+ id: 'config_exists',
43
+ name: 'lifeos.config.json present',
44
+ status: 'error',
45
+ message: `File not found at ${configPath}`,
46
+ fix: `Run \`npx @sbains2/lifeos ${targetDir}\` to scaffold a fresh config.`,
47
+ });
48
+ return finalize(checks);
49
+ }
50
+
51
+ try {
52
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
53
+ checks.push({
54
+ id: 'config_exists',
55
+ name: 'lifeos.config.json present + parses',
56
+ status: 'ok',
57
+ message: `Loaded config (schema v${config.version})`,
58
+ });
59
+ } catch (err) {
60
+ checks.push({
61
+ id: 'config_exists',
62
+ name: 'lifeos.config.json present + parses',
63
+ status: 'error',
64
+ message: `JSON parse failed: ${err.message}`,
65
+ fix: `Open ${configPath} in an editor and fix the JSON syntax.`,
66
+ });
67
+ return finalize(checks);
68
+ }
69
+
70
+ // Check 2: schema version
71
+ if (config.version !== SUPPORTED_VERSION) {
72
+ checks.push({
73
+ id: 'schema_version',
74
+ name: `Schema version matches (cli wants v${SUPPORTED_VERSION})`,
75
+ status: 'error',
76
+ message: `Config is at v${config.version}, but this CLI was built for v${SUPPORTED_VERSION}`,
77
+ fix: `Either upgrade your config (when \`lifeos migrate\` ships) or pin to a CLI version compatible with v${config.version}.`,
78
+ });
79
+ } else {
80
+ checks.push({
81
+ id: 'schema_version',
82
+ name: `Schema version matches (v${SUPPORTED_VERSION})`,
83
+ status: 'ok',
84
+ });
85
+ }
86
+
87
+ // Check 3: schema cross-refs (reuse validate.js)
88
+ const validateResult = validate(config, { targetDir });
89
+ if (validateResult.ok) {
90
+ checks.push({
91
+ id: 'schema_cross_refs',
92
+ name: 'Schema cross-refs resolve (council ↔ quadrants ↔ MCPs)',
93
+ status: 'ok',
94
+ });
95
+ } else {
96
+ checks.push({
97
+ id: 'schema_cross_refs',
98
+ name: 'Schema cross-refs resolve',
99
+ status: 'error',
100
+ message: validateResult.errors.join('; '),
101
+ fix: 'Re-run the wizard with `npx @sbains2/lifeos` to regenerate from a clean template, or fix the references manually in lifeos.config.json.',
102
+ });
103
+ }
104
+ for (const w of validateResult.warnings) {
105
+ checks.push({
106
+ id: 'schema_warning',
107
+ name: 'Schema warning',
108
+ status: 'warn',
109
+ message: w,
110
+ });
111
+ }
112
+
113
+ // Check 4: council files present
114
+ const councilFilesMissing = [];
115
+ for (const member of config.council ?? []) {
116
+ if (!member.system_prompt_path) continue;
117
+ const filename = member.system_prompt_path.split('/').pop();
118
+ const installedPath = join(targetDir, '.claude', 'agents', 'council', filename);
119
+ if (!existsSync(installedPath)) {
120
+ councilFilesMissing.push({ id: member.id, expected: installedPath });
121
+ }
122
+ }
123
+ if (councilFilesMissing.length === 0) {
124
+ checks.push({
125
+ id: 'council_files',
126
+ name: `Council files present (${(config.council ?? []).length} members)`,
127
+ status: 'ok',
128
+ });
129
+ } else {
130
+ checks.push({
131
+ id: 'council_files',
132
+ name: 'Council files present',
133
+ status: 'error',
134
+ message: `Missing files for: ${councilFilesMissing.map((c) => c.id).join(', ')}`,
135
+ fix: `Re-run \`npx @sbains2/lifeos ${targetDir}\` to copy the bundled council prompts.`,
136
+ });
137
+ }
138
+
139
+ // Check 5: quadrant folders exist (active only)
140
+ const missingFolders = [];
141
+ for (const q of config.quadrants ?? []) {
142
+ if (!q.active) continue;
143
+ const fullPath = isAbsolute(q.path) ? q.path : join(targetDir, q.path);
144
+ if (!existsSync(fullPath)) {
145
+ missingFolders.push({ id: q.id, expected: fullPath });
146
+ }
147
+ }
148
+ const activeQuadrantCount = (config.quadrants ?? []).filter((q) => q.active).length;
149
+ if (missingFolders.length === 0) {
150
+ checks.push({
151
+ id: 'quadrant_folders',
152
+ name: `Active quadrant folders exist (${activeQuadrantCount} active)`,
153
+ status: 'ok',
154
+ });
155
+ } else {
156
+ checks.push({
157
+ id: 'quadrant_folders',
158
+ name: 'Active quadrant folders exist',
159
+ status: 'warn',
160
+ message: `Missing: ${missingFolders.map((m) => m.id).join(', ')}`,
161
+ fix: `mkdir -p ${missingFolders.map((m) => m.expected).join(' ')}`,
162
+ });
163
+ }
164
+
165
+ // Check 6: env vars set for wired MCPs (read-only — never reads the value)
166
+ let registry = null;
167
+ try {
168
+ registry = loadRegistry();
169
+ } catch {
170
+ /* registry not loadable — skip env-var check */
171
+ }
172
+ const missingEnv = [];
173
+ if (registry) {
174
+ const byId = new Map(registry.servers.map((s) => [s.id, s]));
175
+ for (const m of config.mcp_servers ?? []) {
176
+ const entry = byId.get(m.id);
177
+ if (!entry?.auth?.env_var) continue;
178
+ const envName = entry.auth.env_var;
179
+ if (!process.env[envName]) {
180
+ missingEnv.push({ id: m.id, env_var: envName, help_url: entry.auth.help_url });
181
+ }
182
+ }
183
+ }
184
+ if (missingEnv.length === 0) {
185
+ const wiredAuthCount = (config.mcp_servers ?? []).filter((m) => {
186
+ const entry = registry?.servers.find((s) => s.id === m.id);
187
+ return entry?.auth?.env_var;
188
+ }).length;
189
+ checks.push({
190
+ id: 'env_vars',
191
+ name: `Auth env vars set (${wiredAuthCount} wired MCPs need credentials)`,
192
+ status: 'ok',
193
+ });
194
+ } else {
195
+ checks.push({
196
+ id: 'env_vars',
197
+ name: 'Auth env vars set for wired MCPs',
198
+ status: 'error',
199
+ message: missingEnv
200
+ .map((m) => `${m.id} needs ${m.env_var}${m.help_url ? ` (get one: ${m.help_url})` : ''}`)
201
+ .join('; '),
202
+ fix: `Set the missing env vars in your shell rc (~/.zshrc or ~/.bashrc), e.g.:\n ${missingEnv
203
+ .map((m) => `export ${m.env_var}=...`)
204
+ .join('\n ')}\n Then reload your shell + restart Claude.`,
205
+ });
206
+ }
207
+
208
+ // Check 7: writing_style file present (if path set)
209
+ if (config.writing_style_path) {
210
+ const stylePath = isAbsolute(config.writing_style_path)
211
+ ? config.writing_style_path
212
+ : join(targetDir, config.writing_style_path);
213
+ if (existsSync(stylePath)) {
214
+ checks.push({
215
+ id: 'writing_style',
216
+ name: 'writing_style.md present',
217
+ status: 'ok',
218
+ });
219
+ } else {
220
+ checks.push({
221
+ id: 'writing_style',
222
+ name: 'writing_style.md present',
223
+ status: 'warn',
224
+ message: `Path set but file not found: ${stylePath}`,
225
+ fix: `Either create the file (with a sample of your writing) or remove writing_style_path from lifeos.config.json.`,
226
+ });
227
+ }
228
+ }
229
+
230
+ // Check 8: brain artifact populated (warning only)
231
+ const brainPath = join(targetDir, config.brain?.graphify?.output ?? '.lifeos/brain');
232
+ if (existsSync(brainPath)) {
233
+ let brainEmpty = true;
234
+ try {
235
+ brainEmpty = readdirSync(brainPath).length === 0;
236
+ } catch {
237
+ /* permissions issue, skip */
238
+ }
239
+ if (brainEmpty) {
240
+ checks.push({
241
+ id: 'brain',
242
+ name: 'Brain index populated',
243
+ status: 'warn',
244
+ message: `${brainPath} exists but is empty`,
245
+ fix: `Run \`npx @sbains2/lifeos brain\` to populate the brain (when that command ships in v0.2.x).`,
246
+ });
247
+ } else {
248
+ checks.push({
249
+ id: 'brain',
250
+ name: 'Brain index populated',
251
+ status: 'ok',
252
+ });
253
+ }
254
+ }
255
+
256
+ // Check 9: MCP runtimes available (npx / uvx / docker)
257
+ const wiredRuntimes = new Set();
258
+ if (registry) {
259
+ const byId = new Map(registry.servers.map((s) => [s.id, s]));
260
+ for (const m of config.mcp_servers ?? []) {
261
+ const entry = byId.get(m.id);
262
+ if (entry?.runtime && entry.runtime !== 'manual' && entry.runtime !== 'remote') {
263
+ wiredRuntimes.add(entry.runtime);
264
+ }
265
+ }
266
+ }
267
+ // Map our runtime enum to the actual binary to check
268
+ const runtimeBinaries = {
269
+ npx: 'npx',
270
+ uvx: 'uvx',
271
+ 'pip-install': 'pip',
272
+ 'git+pip': 'pip',
273
+ docker: 'docker',
274
+ };
275
+ const missingRuntimes = [];
276
+ for (const rt of wiredRuntimes) {
277
+ const binary = runtimeBinaries[rt] ?? rt;
278
+ try {
279
+ execSync(`command -v ${binary}`, { stdio: 'ignore' });
280
+ } catch {
281
+ missingRuntimes.push({ runtime: rt, binary });
282
+ }
283
+ }
284
+ if (wiredRuntimes.size === 0) {
285
+ // No runtime needed — all MCPs are manual / remote
286
+ checks.push({
287
+ id: 'runtimes',
288
+ name: 'MCP runtimes available',
289
+ status: 'ok',
290
+ message: 'No automated runtimes wired (all MCPs are manual or remote)',
291
+ });
292
+ } else if (missingRuntimes.length === 0) {
293
+ checks.push({
294
+ id: 'runtimes',
295
+ name: `MCP runtimes available (${[...wiredRuntimes].join(', ')})`,
296
+ status: 'ok',
297
+ });
298
+ } else {
299
+ const fixes = missingRuntimes
300
+ .map((m) => {
301
+ if (m.binary === 'npx') return 'Install Node.js 20+ from https://nodejs.org';
302
+ if (m.binary === 'uvx') return 'Install uv from https://docs.astral.sh/uv/getting-started/installation/';
303
+ if (m.binary === 'docker') return 'Install Docker Desktop from https://docker.com';
304
+ if (m.binary === 'pip') return 'Install Python 3.10+ + pip from https://python.org';
305
+ return `Install ${m.binary}`;
306
+ })
307
+ .join('; ');
308
+ checks.push({
309
+ id: 'runtimes',
310
+ name: 'MCP runtimes available',
311
+ status: 'warn',
312
+ message: `Missing: ${missingRuntimes.map((m) => m.binary).join(', ')} (needed for: ${missingRuntimes
313
+ .map((m) => m.runtime)
314
+ .join(', ')})`,
315
+ fix: fixes,
316
+ });
317
+ }
318
+
319
+ return finalize(checks);
320
+ }
321
+
322
+ function finalize(checks) {
323
+ const summary = {
324
+ ok: checks.filter((c) => c.status === 'ok').length,
325
+ warn: checks.filter((c) => c.status === 'warn').length,
326
+ error: checks.filter((c) => c.status === 'error').length,
327
+ };
328
+ return { checks, summary };
329
+ }