/** * commands.ts — pygienium slash-command handlers. * * Keeps `index.ts` thin: `index.ts` only binds these handlers to pi command * names. Each handler accepts the narrow context slice it needs (`cwd`, `mode`, * `hasUI`, `ui`) so they are unit-testable without a full pi runtime — tests * construct a minimal `PygieniumCtx`. * * @module pygienium/commands */ import type { AgentSessionEvent, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { resolve } from "node:path"; import { getAllChecks, getCheck, type CheckDefinition, } from "./checks/registry.js"; import { runCheck, parseCheckArgs, type CheckRunOutcome, } from "./modes/check-runner.js"; import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js"; import { buildPygieniumHelpLines } from "./help.js"; import { loadRunState, runStatePath, saveRunState, markRunStatus, reconcileRunStatus, resetCheckEntry, isCheckTerminal, shouldRunOnResume, } from "./run-state.js"; import { formatRunStatus } from "./status.js"; import { exportRun, parseExportFilters, exportBundlePath, type ExportFilters, } from "./export.js"; import type { SendChatMessage } from "./phases.js"; /** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */ export type PygieniumCtx = Pick< ExtensionCommandContext, "cwd" | "mode" | "hasUI" | "ui" > & { /** Optional callback to post messages to the chat window. */ sendChatMessage?: SendChatMessage; /** Optional callback forwarding raw sub-agent events to the chat stream. */ onAgentEvent?: (phase: string, event: AgentSessionEvent) => void; /** Optional callback to emit synthetic progress lines (verify/cleanup/ * recon phases that don't run agents) into the chat stream. */ sendPhaseLine?: (phase: string, text: string) => void; }; function print(ctx: PygieniumCtx, line: string): void { // In TUI mode, also surface the first line as a notification. if (ctx.mode === "tui" && ctx.ui?.notify) { ctx.ui.notify(line, "info"); } process.stdout.write(`${line}\n`); } /** Resolve an optional `[path]` argument to an absolute cwd. */ function resolveCwd(args: string, ctxCwd: string): string { const tok = args.trim().split(/\s+/)[0]; if (!tok || tok.startsWith("--")) return ctxCwd; return resolve(ctxCwd, tok); } /** * Strip leading flag tokens (`--fix`, `--fresh`, `--no-gitignore`) from args, * returning the remainder (the positional `[path]`). */ function splitFlags(args: string): { fix: boolean; fresh: boolean; rest: string; noGitignore: boolean; } { const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const fix = tokens.includes("--fix"); const fresh = tokens.includes("--fresh"); const noGitignore = tokens.includes("--no-gitignore"); const rest = tokens .filter((t) => t !== "--fix" && t !== "--fresh" && t !== "--no-gitignore") .join(" "); return { fix, fresh, rest, noGitignore }; } /** * Parse `/pygienium-resume` args: an optional `[path]` positional plus the * `--fresh` flag.` returns the resolved cwd and whether a fresh re-dispatch * is requested. */ function parseResumeArgs( args: string, ctxCwd: string, ): { cwd: string; fresh: boolean; gitignore: boolean } { const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const fresh = tokens.includes("--fresh"); const gitignore = !tokens.includes("--no-gitignore"); const positional = tokens.find((t) => !t.startsWith("--")); const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd; return { cwd, fresh, gitignore }; } /** `/pygienium-help` */ export async function handleHelpCommand( _args: string, _ctx: PygieniumCtx, ): Promise { for (const line of buildPygieniumHelpLines()) { process.stdout.write(`${line}\n`); } } /** * `/pygienium- [path] [--fix]` — the per-check command handler. * Exported so `index.ts` can bind one per registered `CheckDefinition` and so * tests can invoke it directly with a stub context. */ /** * `/pygienium- [path] [--fix] [--fresh] [--no-gitignore]` — the * per-check command handler. Exported so `index.ts` binds one per registered * `CheckDefinition` and tests invoke it directly with a stub context. * * Resume-aware (parity with `/pygienium-all` and `/pygienium-resume`): a check * already terminal (`complete`/`skipped`) is NOT re-dispatched unless `--fresh` * resets its run-state entry. A failed/pending/in-progress check is re-run from * analysis — recovering the exact failure mode the MagniFluo run exposed * (sub-agent returns ok with no output → verify now fails loudly → resume * re-runs the analysis and the artifact lands). */ export async function handleCheckCommand( check: CheckDefinition, args: string, ctx: PygieniumCtx, ): Promise { const { fix, fresh, rest, noGitignore } = splitFlags(args); const target = resolveCwd(rest, ctx.cwd); const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd); // Resume semantics: skip an already-terminal check unless --fresh forces a // reset. This mirrors the all/resume skip predicate so running the same // per-check command again after a success is a no-op (use --fresh to // re-scan deliberately). const existing = await loadRunState(ctx.cwd); const entry = existing?.checks[check.name]; if (entry && isCheckTerminal(entry) && !fresh) { print( ctx, `pygienium ${check.label}: already ${entry.status} (use --fresh to re-run)`, ); return; } // Reset the entry when --fresh, or when the fix flag changed since the prior // run: the phase skeleton (fix phase present only with --fix) must match // the requested mode, otherwise re-running analysis wouldn't record a fix // phase entry on a scan-only→--fix transition (and vice versa). if (existing && entry && (fresh || entry.fix !== fix)) { resetCheckEntry(existing, check.name, fix); await saveRunState(existing); } const outcome = await runCheck({ check, cwd: ctx.cwd, scope: { ...scope, cwd: ctx.cwd, target, fix }, existingState: existing, ui: ctx.ui, hasUI: ctx.hasUI, sendChatMessage: ctx.sendChatMessage, onAgentEvent: ctx.onAgentEvent, sendPhaseLine: ctx.sendPhaseLine, gitignore: !noGitignore, }); const giNote = outcome.gitignoreAppended ? " · .pygienium/ added to .gitignore" : ""; print( ctx, `pygienium ${check.label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}${giNote}`, ); } /** * `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every * registered check in sequence under a unified status strip, writing * `.pygienium/all-summary.md`. Delegates to {@link runAllChecks}. */ export async function handleAllCommand( args: string, ctx: PygieniumCtx, ): Promise { const checks = getAllChecks(); if (checks.length === 0) { print(ctx, "pygienium: no checks registered."); return; } const parsed = parseAllArgs(args, ctx.cwd); const outcome = await runAllChecks({ cwd: ctx.cwd, target: parsed.target, fix: parsed.fix, fresh: parsed.fresh, gitignore: parsed.gitignore, only: parsed.only, ui: ctx.ui, hasUI: ctx.hasUI, sendChatMessage: ctx.sendChatMessage, onAgentEvent: ctx.onAgentEvent, sendPhaseLine: ctx.sendPhaseLine, }); const giNote = outcome.gitignoreAppended ? " · .pygienium/ added to .gitignore" : ""; print( ctx, `pygienium: all-run ${outcome.status} — ${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})${giNote}`, ); } /** `/pygienium-status [path]` — print run-state progress as a line list. */ export async function handleStatusCommand( args: string, ctx: PygieniumCtx, ): Promise { const cwd = resolveCwd(args, ctx.cwd); const state = await loadRunState(cwd); if (!state) { print(ctx, "pygienium: no run state found."); return; } for (const line of formatRunStatus(state)) { print(ctx, line); } } /** * `/pygienium-resume [path] [--fresh]` — resume the most recent non-complete * run by re-dispatching every check that isn't terminal (`complete`/`skipped`). * Pass `--fresh` to re-dispatch even completed checks (their run-state entries * are reset and the check re-runs analysis → fix → verify → cleanup fresh). */ export async function handleResumeCommand( args: string, ctx: PygieniumCtx, ): Promise { const { cwd, fresh, gitignore } = parseResumeArgs(args, ctx.cwd); let state = await loadRunState(cwd); if (!state) { print(ctx, "pygienium: no run state to resume."); return; } // Pick the latest resumable run — with the single-file run-state model this // is the loaded run unless it's already fully complete AND --fresh wasn't set. const resumable = Object.values(state.checks).some((c) => shouldRunOnResume(c, fresh), ); if (!resumable) { print( ctx, `pygienium: run already ${state.status}; nothing to resume (use --fresh to re-run).`, ); return; } // Re-dispatch this run's checks in stored order, skipping terminal ones // unless --fresh. let ran = 0; let skipped = 0; let giAppended = false; for (const entry of Object.values(state.checks)) { const def = getCheck(entry.name); if (!def) { print( ctx, `pygienium: check "${entry.name}" is no longer registered; skipping.`, ); skipped++; continue; } if (!shouldRunOnResume(entry, fresh)) { skipped++; continue; } if (fresh) { resetCheckEntry(state, entry.name); } print(ctx, `pygienium: resuming ${def.label}…`); const outcome: CheckRunOutcome = await runCheck({ check: def, cwd, scope: { cwd, target: cwd, fix: entry.fix, rest: [] }, ui: ctx.ui, hasUI: ctx.hasUI, existingState: state, sendChatMessage: ctx.sendChatMessage, onAgentEvent: ctx.onAgentEvent, sendPhaseLine: ctx.sendPhaseLine, gitignore, }); state = outcome.state; giAppended = giAppended || outcome.gitignoreAppended === true; ran++; print(ctx, `pygienium ${def.label}: ${outcome.status}`); } markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); const giNote = giAppended ? " · .pygienium/ added to .gitignore" : ""; print( ctx, `pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})${giNote}`, ); } /** * `/pygienium-export [path] [--check=[,]] [--status=[,]] [--out=md|json]` * — collect every check's `findings.md`/`changes.md` artifacts from * `.pygienium/checks//`, apply filters, and write a single bundle to * `.pygienium/export.{md|json}`. */ export async function handleExportCommand( args: string, ctx: PygieniumCtx, ): Promise { const cwd = resolveCwd(args, ctx.cwd); const filters: ExportFilters = parseExportFilters(args); const state = await loadRunState(cwd); const result = await exportRun(cwd, state, filters); if (result.entries.length === 0) { print( ctx, `pygienium: nothing to export (no findings.md/changes.md under ${cwd}/.pygienium/checks/).`, ); return; } const filterDesc = [ filters.check ? `check=${filters.check.join(",")}` : null, filters.status ? `status=${filters.status.join(",")}` : null, ] .filter(Boolean) .join(" "); const suffix = filterDesc ? ` [${filterDesc}]` : ""; print( ctx, `pygienium export: ${result.entries.length} check(s) → ${exportBundlePath(cwd, result.format)}${suffix}`, ); } /** A minimal command-registration callback shape (matches `pi.registerCommand`). */ export type RegisterCommandFn = ( name: string, options: { description?: string; handler: (args: string, ctx: PygieniumCtx) => Promise; }, ) => void; /** * Auto-register `/pygienium-help` plus one `/pygienium-` per registered * `CheckDefinition`, plus the `all`/`resume`/`status`/`export` commands. * Called from `index.ts` so that adding a check never requires editing command * wiring. */ export function registerPygieniumCommands(register: RegisterCommandFn): void { register("pygienium-help", { description: "Show pygienium commands, checks, and usage.", handler: handleHelpCommand, }); for (const def of getAllChecks()) { register(`pygienium-${def.name}`, { description: def.description, handler: (args, ctx) => handleCheckCommand(def, args, ctx), }); } register("pygienium-all", { description: "Run every registered pygienium check in sequence.", handler: handleAllCommand, }); register("pygienium-resume", { description: "Resume the most recent in-progress or failed pygienium run.", handler: handleResumeCommand, }); register("pygienium-status", { description: "Show progress of the current or latest pygienium run.", handler: handleStatusCommand, }); register("pygienium-export", { description: "Export finalized findings and changes for a pygienium run.", handler: handleExportCommand, }); }