diff --git a/DOCS.md b/DOCS.md index cbcd4ea..3e62a12 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1891,6 +1891,7 @@ impactus`) is a thin brand wrapper over the real `pi` binary — NOT a fork: | `imp init [flags]` | The full impactus installer in place — every flag works (`imp init --harness-only -y`, `imp init --verify`, …). | | `imp update` | `npm install -g impactus@latest` + `pi update` (or install) + re-pin of the three Pi extension packages. Exit code keyed to the impactus self-update; the extension refresh is best-effort. | | `imp tui [args]` | Runs the project-stamped `imp/scripts/fia-tui.mjs` (errors with a `imp init` hint when the runtime is absent); `imp tui --once` passes through. | +| `imp doctor [--json]` | Read-only checkup — detection only, fixes nothing. Four sections: engines/subscriptions (Claude Code on PATH, the Codex login inside Pi, the Cursor CLI — all informative, never required), core CLIs (node floor, git, npm; gh/vercel as optional), Pi & imp (Pi version, the three pinned extension packages, the same update probe the launcher uses, timeboxed at 4 s), and — when run inside a project — the install: FIA runtime present, `.mcp.json` hygiene (an npx server without `-y` dies on a cold cache with "Connection closed") and a summarized `--verify` audit (full report stays in `npx impactus --verify`). Every finding ends in the exact command that fixes it. Exit 0 = no error-level finding; `--json` prints `{ ok, sections }` with no banner. | | `imp handoff [args]` | Runs the project-stamped `imp/scripts/handoff.mjs`: hands the newest interactive Pi conversation to the `claude` CLI with a continuation prompt pointing at the session transcript (same preamble the FDA relay uses). Works while Codex is down — that is the point. `--list` picks a session, `--session ` targets one, `--full` asks for a full transcript read, `--print` prints the prompt without launching. Also `npm run handoff`. | | `imp help` / `imp --version` | Help / bare version. | | anything else | Straight through to `pi` (e.g. `imp -p "prompt"`, `imp --continue`). | @@ -2009,6 +2010,7 @@ prompts (§6.1) fetch keys for you. ### 15.7 Keep an installed project current ```bash +imp doctor # read-only checkup: subscriptions, CLIs, Pi, project imp update # impactus + Pi + the pinned extension packages npx impactus --update-runtime --dir . # new FDAs/gates/prompts into imp/ + .pi/ npx impactus --verify --dir . # audit that everything is still intact diff --git a/README.md b/README.md index 8b2122e..105ae21 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ imp init # install into the current folder (same as npx im imp # open Pi here (installs Pi if it's missing) imp update # update impactus + Pi + the pinned Pi extensions imp tui # the project dashboard in the terminal +imp doctor # read-only checkup: subscriptions, CLIs, Pi, project imp handoff # continue the newest Pi conversation in `claude` # (Codex outage? your work keeps moving) ``` diff --git a/bin/imp.js b/bin/imp.js index ab8651d..4da41eb 100755 --- a/bin/imp.js +++ b/bin/imp.js @@ -61,6 +61,8 @@ Usage: all impactus flags work — see \`imp init --help\`) imp update Update impactus, Pi and the Pi extension packages imp tui Terminal dashboard — tasks, specs and runs (same as npm run tui) + imp doctor Read-only checkup: subscriptions (Claude/Codex/Cursor), + CLIs, Pi and this project (--json for machine output) imp handoff Continue the newest Pi conversation in the \`claude\` CLI (works while Codex is down; --list picks a session) imp help Show this help @@ -139,6 +141,16 @@ if (cmd === 'update') { process.exit(up.ok ? 0 : 1); } +if (cmd === 'doctor') { + // Read-only by contract — see src/steps/doctor.js. `--json` keeps stdout + // machine-readable (no banner), same convention as `--version`. + const json = rest.includes('--json'); + if (!json) banner(); + const { runDoctor } = await import('../src/steps/doctor.js'); + const healthy = await runDoctor({ json }, pkg.version); + process.exit(healthy ? 0 : 1); +} + if (cmd === 'tui') { // The dashboard is stamped per project (imp/scripts/), not bundled here — // it must version-match the readers it depends on (decision record: diff --git a/src/lib/pi-auth.js b/src/lib/pi-auth.js index 61853a2..3e0b81f 100644 --- a/src/lib/pi-auth.js +++ b/src/lib/pi-auth.js @@ -120,7 +120,7 @@ async function updatePiIfOutdated() { } } -async function piVersion() { +export async function piVersion() { const r = await run('pi', ['--version'], { timeout: 15_000 }); return r.ok ? parseSemver(r.stdout || r.stderr) : null; } @@ -318,6 +318,32 @@ function readPiSettings() { } } +/** + * State of each of OUR extension packages in Pi's user settings, for + * `imp doctor`: 'pinned ' (ours, exact-pinned), 'custom' (the student + * pointed it elsewhere — never touched) or 'missing' (not installed yet). + */ +export function piPackageStatus() { + const settings = readPiSettings(); + const plan = piPackageRefreshPlan(settings); + const pins = readPiPackagePins(); + const entries = Array.isArray(settings?.packages) ? settings.packages : []; + const installed = new Set( + entries + .map((entry) => sourcePackageIdentity(String((typeof entry === 'string' ? entry : entry?.source) ?? ''))) + .filter((name) => PI_PACKAGES.includes(name)), + ); + return Object.fromEntries( + PI_PACKAGES.map((name) => { + if (plan[name] === 'custom') return [name, 'custom']; + if (pins[name]) return [name, `pinned ${pins[name]}`]; + // Present but unpinned: the offline fallback of installPiPackages — + // installed, just waiting for the next online `imp update` to pin it. + return [name, installed.has(name) ? 'unpinned' : 'missing']; + }), + ); +} + /** The exact-version pins of our packages in Pi's user settings, name → pin. */ function readPiPackagePins() { const pins = {}; diff --git a/src/steps/doctor.js b/src/steps/doctor.js new file mode 100644 index 0000000..979ab0a --- /dev/null +++ b/src/steps/doctor.js @@ -0,0 +1,266 @@ +// `imp doctor` — a read-only checkup of the machine and, when run inside a +// project, of the install. DETECTION ONLY by design: doctor never installs, +// never opens a login and never rewrites a file — every finding ends in the +// exact command the student runs to fix it. (The remediating siblings each +// keep their own consent flow: `imp init`, `imp update`, `npx impactus +// --update-runtime`, `npx impactus --verify`.) +// +// Sections: +// 1. Engines (subscriptions) — Claude Code on PATH, the Codex login inside +// Pi (~/.pi/agent/auth.json) and the Cursor CLI. Informative, never an +// error: which subscriptions to use is the professional's call. There is +// deliberately no `claude` login probe (same rationale as the preflight: +// no heuristic is reliable, and `claude` walks the user through login on +// first run). +// 2. Core CLIs — node/git/npm (required) and gh/vercel (optional). +// 3. Pi & imp — Pi installed + the three pinned extension packages, plus +// the same update probe `imp` prints after a session. +// 4. Project — only when the folder looks like an IAI project: FIA runtime +// present, .mcp.json hygiene (npx without -y dies on a cold cache — the +// "Connection closed" lesson) and a summary of the full --verify audit. +// +// `--json` swaps the report for `{ ok, sections }` on stdout. Exit code: +// 0 = no error-level finding (warnings included), 1 otherwise. + +import process from 'node:process'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import pc from 'picocolors'; +import { has } from '../lib/proc.js'; +import { osKind } from '../lib/platform.js'; +import { + hasPi, + piCodexReady, + piPackageStatus, + piVersion, + collectUpdateNotices, + PI_PACKAGES, +} from '../lib/pi-auth.js'; +import { CLAUDE_INSTALL_HINT } from './preflight.js'; +import { collectFindings } from './verify.js'; + +const NODE_FLOOR = [22, 12]; + +// How long the network update probe may hold the report. Offline (or slow) +// just skips the section's update rows — doctor never blocks on the network. +const UPDATE_PROBE_MS = 4000; + +// Cap on inline project-audit rows; the full list lives in --verify. +const MAX_AUDIT_ROWS = 8; + +const ok = (msg) => ({ level: 'ok', msg }); +const info = (msg) => ({ level: 'info', msg }); +const warn = (msg) => ({ level: 'warn', msg }); +const error = (msg) => ({ level: 'error', msg }); + +/** + * `.mcp.json` hygiene: every npx-launched MCP server needs `-y` — on a cold + * npx cache the "Ok to proceed?" prompt lands on the MCP stdio channel and + * the server dies before the handshake ("Connection closed"; the classic + * first-run-on-Windows report). Pure — unit-testable on a parsed JSON. + * @returns {{level:string,msg:string}[]} one warn per offending server. + */ +export function mcpNpxFindings(mcpJson) { + const servers = mcpJson?.mcpServers; + if (!servers || typeof servers !== 'object') return []; + const rows = []; + for (const [name, cfg] of Object.entries(servers)) { + const args = Array.isArray(cfg?.args) ? cfg.args : []; + if (cfg?.command !== 'npx') continue; + if (args.includes('-y') || args.includes('--yes')) continue; + rows.push( + warn( + `MCP "${name}": npx without -y in .mcp.json — with a cold npx cache the server dies before the handshake ` + + '("Connection closed"). Add "-y" as the first item of its "args".', + ), + ); + } + return rows; +} + +async function enginesSection() { + const rows = []; + const claude = await has('claude'); + rows.push( + claude + ? ok('Claude Code (Claude Pro/Max) — installed. Not logged in yet? Run `claude` once and finish in the browser.') + : info( + `Claude Code (Claude Pro/Max) — not installed.\n Install: ${CLAUDE_INSTALL_HINT[osKind()]}\n Then run \`claude\` once to log in.`, + ), + ); + rows.push( + piCodexReady() + ? ok('Codex (ChatGPT Plus/Pro, via Pi) — logged in.') + : info( + 'Codex (ChatGPT Plus/Pro, via Pi) — login pending.\n Log in: run `imp` and type /login openai-codex (only that one — Anthropic stays on the `claude` CLI).', + ), + ); + rows.push( + (await has('cursor-agent')) + ? ok('Cursor CLI (cursor-agent) — installed (Cursor subscription).') + : info('Cursor CLI (cursor-agent) — not installed (optional; see https://cursor.com/cli).'), + ); + rows.push(info('None is mandatory — doctor only reports; which subscriptions to use is your call.')); + return { title: 'Engines (subscriptions)', rows }; +} + +async function clisSection() { + const rows = []; + const [major, minor] = process.versions.node.split('.').map(Number); + const nodeOk = major > NODE_FLOOR[0] || (major === NODE_FLOOR[0] && minor >= NODE_FLOOR[1]); + rows.push( + nodeOk + ? ok(`Node.js ${process.versions.node} (floor: ${NODE_FLOOR.join('.')})`) + : error(`Node.js ${process.versions.node} is below the ${NODE_FLOOR.join('.')} floor — update at https://nodejs.org.`), + ); + for (const cmd of ['git', 'npm']) { + rows.push( + (await has(cmd)) + ? ok(`${cmd} — installed.`) + : warn(`${cmd} — not found. Run \`imp init\`: the installer sets it up for you (or records the pending step).`), + ); + } + for (const cmd of ['gh', 'vercel']) { + rows.push( + (await has(cmd)) + ? ok(`${cmd} — installed (optional).`) + : info(`${cmd} — not installed (optional; the installer offers it when a step needs it).`), + ); + } + return { title: 'Core CLIs', rows }; +} + +async function piSection(impactusVersion) { + const rows = []; + if (await hasPi()) { + const version = await piVersion(); + rows.push(ok(`Pi — installed${version ? ` (v${version})` : ''}.`)); + const status = piPackageStatus(); + const missing = PI_PACKAGES.filter((name) => status[name] === 'missing'); + const custom = PI_PACKAGES.filter((name) => status[name] === 'custom'); + const unpinned = PI_PACKAGES.filter((name) => status[name] === 'unpinned'); + if (missing.length === 0) { + const notes = [ + custom.length ? `${custom.join(', ')} customized by you — left alone` : null, + unpinned.length ? `${unpinned.join(', ')} unpinned — the next online \`imp update\` pins them` : null, + ].filter(Boolean); + rows.push(ok(`Pi extension packages — ${PI_PACKAGES.length} present${notes.length ? ` (${notes.join('; ')})` : ''}.`)); + } else { + rows.push(warn(`Pi extension packages missing: ${missing.join(', ')} — run \`imp update\` to (re)install them.`)); + } + } else { + rows.push(warn('Pi — not installed. `imp` installs it on first launch (or run `imp update`).')); + } + + // Same probe the launcher prints after a session — timeboxed so an offline + // or slow registry never holds the report. + const notices = await Promise.race([ + collectUpdateNotices(impactusVersion).catch(() => []), + new Promise((res) => setTimeout(res, UPDATE_PROBE_MS, [])), + ]); + if (notices.length > 0) { + for (const line of notices) rows.push(warn(`Update available: ${line}`)); + rows.push(warn('Run `imp update` to bring everything current.')); + } else { + rows.push(ok('No pending updates found (or the registry was unreachable — checked best-effort).')); + } + return { title: 'Pi & imp', rows }; +} + +async function projectSection(cwd) { + const rows = []; + const inProject = + existsSync(join(cwd, 'imp')) || existsSync(join(cwd, 'ai-docs')) || existsSync(join(cwd, '.agents')); + if (!inProject) { + rows.push(info('No IAI project detected in this folder — run `imp doctor` inside a project for the install audit (create one with `imp init`).')); + return { title: 'Project', rows }; + } + + if (existsSync(join(cwd, 'imp'))) { + const runtimeOk = existsSync(join(cwd, 'imp', 'scripts', 'fia-tui.mjs')); + const rosterOk = existsSync(join(cwd, 'imp', 'fia.config.yaml')); + rows.push( + runtimeOk && rosterOk + ? ok('FIA runtime present (imp/scripts + imp/fia.config.yaml).') + : warn('FIA runtime incomplete (imp/ exists but scripts or fia.config.yaml are missing) — run `npx impactus --update-runtime`.'), + ); + } else { + rows.push(info('FIA not installed here (harness-only install) — `imp init` adds it.')); + } + + const mcpPath = join(cwd, '.mcp.json'); + if (existsSync(mcpPath)) { + try { + const mcp = JSON.parse(await readFile(mcpPath, 'utf8')); + const hygiene = mcpNpxFindings(mcp); + const count = Object.keys(mcp?.mcpServers || {}).length; + rows.push(...(hygiene.length ? hygiene : [ok(`.mcp.json — ${count} server(s), npx entries all carry -y.`)])); + } catch { + rows.push(error('.mcp.json is not valid JSON — no MCP server will load until it parses again.')); + } + } else { + rows.push(info('.mcp.json not found — no MCP servers registered for this project.')); + } + + // Full install audit, summarized: the detailed report stays in --verify. + try { + const findings = await collectFindings(cwd); + const errors = findings.filter((f) => f.level === 'error'); + const warns = findings.filter((f) => f.level === 'warn'); + const shown = [...errors, ...warns].slice(0, MAX_AUDIT_ROWS); + for (const f of shown) rows.push(f.level === 'error' ? error(f.msg) : warn(f.msg)); + const hidden = errors.length + warns.length - shown.length; + if (hidden > 0) rows.push(info(`… and ${hidden} more — full report: npx impactus --verify`)); + if (errors.length === 0 && warns.length === 0) { + rows.push(ok(`Install audit — ${findings.length} check(s), all good (same audit as \`npx impactus --verify\`).`)); + } + } catch (err) { + rows.push(warn(`Install audit could not run here: ${err?.message || err}`)); + } + return { title: 'Project', rows }; +} + +/** + * Probe everything and return the report (no printing — unit-testable). + * @returns {Promise<{ok: boolean, sections: {title: string, rows: {level:string,msg:string}[]}[]}>} + */ +export async function collectDoctorReport({ cwd = process.cwd(), impactusVersion = '0.0.0' } = {}) { + const sections = [ + await enginesSection(), + await clisSection(), + await piSection(impactusVersion), + await projectSection(resolve(cwd)), + ]; + return { ok: !sections.some((s) => s.rows.some((r) => r.level === 'error')), sections }; +} + +const ICONS = { ok: '✅', info: '○', warn: '⚠', error: '✖' }; +const PAINT = { ok: (s) => s, info: pc.dim, warn: pc.yellow, error: pc.red }; + +/** Entry point of `imp doctor`. @returns {Promise} true = no errors. */ +export async function runDoctor(flags = {}, impactusVersion = '0.0.0') { + const report = await collectDoctorReport({ impactusVersion }); + if (flags.json) { + console.log(JSON.stringify(report, null, 2)); + return report.ok; + } + console.log(pc.bold('imp doctor — read-only checkup (nothing is installed or changed)')); + for (const section of report.sections) { + console.log(''); + console.log(pc.bold(pc.cyan(section.title))); + for (const row of section.rows) { + const paint = PAINT[row.level] || ((s) => s); + const [first, ...cont] = row.msg.split('\n'); + console.log(paint(` ${ICONS[row.level] || ' '} ${first}`)); + for (const line of cont) console.log(paint(` ${line}`)); + } + } + console.log(''); + console.log( + report.ok + ? 'Everything the system needs is in place (○/⚠ items are optional or have their command above).' + : pc.red('Problems found — each ✖ above ends with the command that fixes it.'), + ); + return report.ok; +} diff --git a/src/steps/finish.js b/src/steps/finish.js index ae72804..1a392c7 100644 --- a/src/steps/finish.js +++ b/src/steps/finish.js @@ -205,6 +205,7 @@ export async function finish(ctx) { ctx.fiaInstalled ? 'but Pi also accepts other providers/models — type /login inside Pi to see them.' : 'but you can also work through Cursor or add engines later at any time.', + 'Re-check this roster (and the whole setup) anytime with `imp doctor`.', ] .filter(Boolean) .join('\n'), diff --git a/test/doctor.test.js b/test/doctor.test.js new file mode 100644 index 0000000..88421f0 --- /dev/null +++ b/test/doctor.test.js @@ -0,0 +1,88 @@ +// `imp doctor` — the pure .mcp.json hygiene rule and the report contract +// (four sections, read-only, exit keyed to error-level findings only). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mcpNpxFindings, collectDoctorReport } from '../src/steps/doctor.js'; + +test('mcpNpxFindings: npx server without -y is flagged; -y/--yes/non-npx are not', () => { + const rows = mcpNpxFindings({ + mcpServers: { + playwright: { command: 'npx', args: ['@playwright/mcp@latest'] }, + convex: { command: 'npx', args: ['-y', 'convex@latest', 'mcp', 'start'] }, + other: { command: 'npx', args: ['--yes', 'something'] }, + local: { command: 'node', args: ['server.mjs'] }, + }, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].level, 'warn'); + assert.match(rows[0].msg, /"playwright"/); + assert.match(rows[0].msg, /Connection closed/); +}); + +test('mcpNpxFindings: missing/odd shapes never throw and report nothing', () => { + assert.deepEqual(mcpNpxFindings(null), []); + assert.deepEqual(mcpNpxFindings({}), []); + assert.deepEqual(mcpNpxFindings({ mcpServers: 'nope' }), []); + assert.deepEqual(mcpNpxFindings({ mcpServers: { x: { command: 'npx' } } }).length, 1); +}); + +test('collectDoctorReport: four sections; outside a project the Project section only points at imp init', async () => { + const dir = mkdtempSync(join(tmpdir(), 'doctor-empty-')); + process.env.PI_OFFLINE = '1'; // keep the update probe off the network + try { + const report = await collectDoctorReport({ cwd: dir, impactusVersion: '0.0.0' }); + assert.deepEqual( + report.sections.map((s) => s.title), + ['Engines (subscriptions)', 'Core CLIs', 'Pi & imp', 'Project'], + ); + const project = report.sections.at(-1); + assert.equal(project.rows.length, 1); + assert.equal(project.rows[0].level, 'info'); + assert.match(project.rows[0].msg, /imp init/); + // Engines are informative — a bare machine must never produce an error there. + for (const row of report.sections[0].rows) assert.notEqual(row.level, 'error'); + } finally { + delete process.env.PI_OFFLINE; + } +}); + +test('collectDoctorReport: inside a project it audits the FIA runtime and .mcp.json hygiene', async () => { + const dir = mkdtempSync(join(tmpdir(), 'doctor-project-')); + mkdirSync(join(dir, 'ai-docs')); + mkdirSync(join(dir, 'imp', 'scripts'), { recursive: true }); + writeFileSync(join(dir, 'imp', 'scripts', 'fia-tui.mjs'), '// stub'); + writeFileSync(join(dir, 'imp', 'fia.config.yaml'), 'agents: {}\n'); + writeFileSync( + join(dir, '.mcp.json'), + JSON.stringify({ mcpServers: { playwright: { command: 'npx', args: ['@playwright/mcp@latest'] } } }), + ); + process.env.PI_OFFLINE = '1'; + try { + const report = await collectDoctorReport({ cwd: dir, impactusVersion: '0.0.0' }); + const rows = report.sections.at(-1).rows; + assert.ok(rows.some((r) => r.level === 'ok' && /FIA runtime present/.test(r.msg))); + assert.ok(rows.some((r) => r.level === 'warn' && /"playwright"/.test(r.msg))); + // Hygiene warnings alone never fail the checkup (exit stays 0). + assert.ok(!rows.some((r) => r.level === 'error')); + } finally { + delete process.env.PI_OFFLINE; + } +}); + +test('collectDoctorReport: a broken .mcp.json is an error (nothing loads until it parses)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'doctor-badmcp-')); + mkdirSync(join(dir, 'ai-docs')); + writeFileSync(join(dir, '.mcp.json'), '{ not json'); + process.env.PI_OFFLINE = '1'; + try { + const report = await collectDoctorReport({ cwd: dir, impactusVersion: '0.0.0' }); + const rows = report.sections.at(-1).rows; + assert.ok(rows.some((r) => r.level === 'error' && /\.mcp\.json/.test(r.msg))); + assert.equal(report.ok, false); + } finally { + delete process.env.PI_OFFLINE; + } +}); diff --git a/test/imp-smoke.test.js b/test/imp-smoke.test.js index 10c5b70..b092481 100644 --- a/test/imp-smoke.test.js +++ b/test/imp-smoke.test.js @@ -33,6 +33,7 @@ test('imp: help exits 0 and prints the brand + usage', async () => { assert.match(res.stdout, /Usage:/); assert.match(res.stdout, /imp init/); assert.match(res.stdout, /imp update/); + assert.match(res.stdout, /imp doctor/); assert.match(res.stdout, /login openai-codex/); });