Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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`). |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
12 changes: 12 additions & 0 deletions bin/imp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 27 additions & 1 deletion src/lib/pi-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -318,6 +318,32 @@ function readPiSettings() {
}
}

/**
* State of each of OUR extension packages in Pi's user settings, for
* `imp doctor`: 'pinned <x.y.z>' (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 = {};
Expand Down
266 changes: 266 additions & 0 deletions src/steps/doctor.js
Original file line number Diff line number Diff line change
@@ -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<boolean>} 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;
}
1 change: 1 addition & 0 deletions src/steps/finish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading