Initial commit: pygenium as git submodule

This commit is contained in:
2026-08-07 14:54:45 -04:00
commit 581436ed23
61 changed files with 9331 additions and 0 deletions

325
src/commands.ts Normal file
View File

@@ -0,0 +1,325 @@
/**
* 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 };