326 lines
9.4 KiB
TypeScript
326 lines
9.4 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
for (const line of buildPygieniumHelpLines()) {
|
|
process.stdout.write(`${line}\n`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `/pygienium-<check> [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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
|
|
* — collect every check's `findings.md`/`changes.md` artifacts from
|
|
* `pygienium/checks/<name>/` (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<void> {
|
|
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>;
|
|
},
|
|
) => void;
|
|
|
|
/**
|
|
* Auto-register `/pygienium-help` plus one `/pygienium-<check>` 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 };
|