Initial commit: pygenium as git submodule
This commit is contained in:
325
src/commands.ts
Normal file
325
src/commands.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user