/** * 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 { ExtensionCommandContext, ExtensionUIContext, } from "@earendil-works/pi-coding-agent"; import { resolve } from "node:path"; import { getAllChecks, getCheck, type CheckDefinition, } from "./checks/registry.js"; import { runCheck, parseCheckArgs } 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, shouldRunOnResume, } from "./run-state.js"; import { formatRunStatus } from "./status.js"; import { exportRun, parseExportFilters, exportBundlePath, type ExportFilters, } from "./export.js"; /** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */ export type PygieniumCtx = Pick< ExtensionCommandContext, "cwd" | "mode" | "hasUI" | "ui" >; 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 a leading flag token (`--fix`) from args, returning the remainder. */ function splitFlags(args: string): { fix: boolean; rest: string } { const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const fix = tokens.includes("--fix"); const rest = tokens.filter((t) => t !== "--fix").join(" "); return { fix, rest }; } /** * 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 } { const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const fresh = tokens.includes("--fresh"); const positional = tokens.find((t) => !t.startsWith("--")); const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd; return { cwd, fresh }; } /** `/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. */ export async function handleCheckCommand( check: CheckDefinition, args: string, ctx: PygieniumCtx, ): Promise { const { fix, rest } = splitFlags(args); const target = resolveCwd(rest, ctx.cwd); const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd); const outcome = await runCheck({ check, cwd: ctx.cwd, scope: { ...scope, cwd: ctx.cwd, target }, ui: ctx.ui, hasUI: ctx.hasUI, }); print( ctx, `pygienium ${check.label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`, ); } /** * `/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, only: parsed.only, ui: ctx.ui, hasUI: ctx.hasUI, }); print( ctx, `pygienium: all-run ${outcome.status} — ${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})`, ); } /** `/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 } = 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; 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 = await runCheck({ check: def, cwd, scope: { cwd, target: cwd, fix: entry.fix, rest: [] }, ui: ctx.ui, hasUI: ctx.hasUI, existingState: state, }); state = outcome.state; ran++; print(ctx, `pygienium ${def.label}: ${outcome.status}`); } markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); print( ctx, `pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})`, ); } /** * `/pygienium-export [path] [--check=[,]] [--status=[,]] [--out=md|json]` * — collect every check's `findings.md`/`changes.md` artifacts from * `pygienium/checks//` (and the legacy `.pygienium/checks/` root), 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, }); } /** Re-export for index.ts convenience. */ export type { ExtensionUIContext };