/** * The Pi extension entry — what a marketplace install (or a host like privateer-agent) * loads. Registers a `/workflow` command that lists, validates, and RUNS declarative * workflow files (YAML or JSON) through the decoupled runner (runner.ts), wired to the * live Pi host via makePiRunnerDeps (piRunner.ts). * * "Feed it YAML": `/workflow run ./triage.yaml` loads and runs a file directly; saved * workflows in the workflows dir (~/.pi/workflows or $PI_WORKFLOWS_DIR) are runnable by * name. Structural Pi typing keeps this decoupled from Pi's exact internal types. */ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { runWorkflow, type RunResult } from "./runner.ts"; import { makePiRunnerDeps, type PiRunnerCtx, type PiRunnerOptions } from "./piRunner.ts"; import { loadWorkflows, findWorkflow, workflowsDir } from "./store.ts"; import { parseWorkflowText, formatForPath } from "./yaml.ts"; import type { Workflow } from "./schema.ts"; // ── structural Pi surface (subset we use) ──────────────────────────────────── interface PiCommandCtx extends PiRunnerCtx { hasUI: boolean; } interface PiExtensionApiLike { registerCommand?( name: string, options: { description?: string; argumentHint?: string; handler: (args: string, ctx: PiCommandCtx) => Promise | void }, ): void; } export interface PiWorkflowOptions extends PiRunnerOptions { // The command name to register. Default "workflow" (invoked as /workflow). commandName?: string; } // A path-ish arg the user is feeding directly (vs a saved workflow name). function looksLikePath(arg: string): boolean { return /[./]/.test(arg) && /\.(ya?ml|json)$/i.test(arg); } // Load a workflow the user named: a file path (fed YAML/JSON) or a saved workflow by // id/name. Returns the parsed Workflow, or an error string for the UI. function resolveNamed(arg: string, cwd: string, dir: string): { wf?: Workflow; error?: string } { if (looksLikePath(arg)) { const full = resolve(cwd, arg); if (!existsSync(full)) return { error: `No such file: ${arg}` }; let raw: string; try { raw = readFileSync(full, "utf8"); } catch (e) { return { error: `Can't read ${arg}: ${(e as Error).message}` }; } const res = parseWorkflowText(raw, formatForPath(full)); return res.ok ? { wf: res.workflow } : { error: `Invalid workflow: ${res.error}` }; } const wf = findWorkflow(loadWorkflows(dir), arg); return wf ? { wf } : { error: `No saved workflow "${arg}". Try /workflow list.` }; } function summarize(wf: Workflow): string { const steps = wf.steps.length + wf.parallel.length + wf.for_each.length; const gates = wf.steps.filter((s) => s.type === "human_gate").length; const scripts = wf.steps.filter((s) => s.type === "script").length; const bits = [`${steps} step${steps === 1 ? "" : "s"}`]; if (gates) bits.push(`${gates} gate${gates === 1 ? "" : "s"}`); if (scripts) bits.push(`${scripts} script${scripts === 1 ? "" : "s"}`); return bits.join(" · "); } function formatResult(name: string, r: RunResult): string { const head = r.status === "success" ? `✔ "${name}" completed` : r.status === "deferred" ? `⏸ "${name}" deferred` : `✖ "${name}" failed`; const why = r.reason ? ` — ${r.reason}` : ""; const keys = Object.keys(r.output); const out = keys.length ? `\n${keys.map((k) => ` ${k}: ${JSON.stringify(r.output[k])}`).join("\n")}` : ""; return `${head}${why}${out}`; } // Parse an optional trailing JSON input object: `run {"since":"12h"}`. function splitNameAndInput(rest: string): { name: string; input: Record } { const brace = rest.indexOf("{"); if (brace === -1) return { name: rest.trim(), input: {} }; const name = rest.slice(0, brace).trim(); try { const parsed = JSON.parse(rest.slice(brace)); return { name, input: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {} }; } catch { return { name, input: {} }; } } export function makePiWorkflowExtension(opts: PiWorkflowOptions = {}) { const dir = opts.workflowsDir ?? workflowsDir(); const commandName = opts.commandName ?? "workflow"; return function piWorkflow(pi: PiExtensionApiLike): void { if (typeof pi.registerCommand !== "function") return; pi.registerCommand(commandName, { description: "List, validate, and run declarative workflows (YAML/JSON)", argumentHint: "[list | run [json] | validate | dir]", handler: async (args, ctx) => { const trimmed = (args ?? "").trim(); const [sub, ...restParts] = trimmed.split(/\s+/); const rest = trimmed.slice(sub.length).trim(); // Bare `/workflow` or `/workflow list` → list saved workflows; offer to run one. if (!sub || sub === "list") { const all = loadWorkflows(dir); if (all.length === 0) { ctx.ui.notify(`No workflows in ${dir}. Feed one with /workflow run .`, "info"); return; } const labels = all.map((w) => `${w.workflow.name} — ${summarize(w)}`); if (!ctx.hasUI) { ctx.ui.notify(all.map((w, i) => `• ${labels[i]}`).join("\n"), "info"); return; } const chosen = await ctx.ui.select("Run a workflow", labels); if (chosen === undefined) return; const wf = all[labels.indexOf(chosen)]; await execute(wf, {}, ctx, opts); return; } if (sub === "dir") { ctx.ui.notify(dir, "info"); return; } if (sub === "validate") { const target = rest; if (!target) return ctx.ui.notify("Usage: /workflow validate ", "warning"); const { wf, error } = resolveNamed(target, ctx.cwd, dir); if (error) return ctx.ui.notify(error, "error"); ctx.ui.notify(`✔ Valid: "${wf!.workflow.name}" (${summarize(wf!)})`, "info"); return; } if (sub === "run") { if (!rest) return ctx.ui.notify("Usage: /workflow run [json-input]", "warning"); const { name, input } = splitNameAndInput(rest); const { wf, error } = resolveNamed(name, ctx.cwd, dir); if (error) return ctx.ui.notify(error, "error"); await execute(wf!, input, ctx, opts); return; } // Unknown subcommand → treat the whole thing as a name/file to run (ergonomic: // `/workflow triage` just runs it). const { name, input } = splitNameAndInput(trimmed); const { wf, error } = resolveNamed(name, ctx.cwd, dir); if (error) return ctx.ui.notify(`Unknown command "${sub}". ${error}`, "warning"); await execute(wf!, input, ctx, opts); }, }); }; } async function execute(wf: Workflow, input: Record, ctx: PiCommandCtx, opts: PiWorkflowOptions): Promise { ctx.ui.notify(`Running "${wf.workflow.name}" (${summarize(wf)})…`, "info"); const deps = makePiRunnerDeps(ctx, opts); try { const result = await runWorkflow(wf, input, deps); ctx.ui.setStatus?.("pi-workflow", undefined); ctx.ui.notify(formatResult(wf.workflow.name, result), result.status === "success" ? "info" : result.status === "deferred" ? "warning" : "error"); } catch (err) { ctx.ui.setStatus?.("pi-workflow", undefined); ctx.ui.notify(`✖ "${wf.workflow.name}" crashed: ${err instanceof Error ? err.message : String(err)}`, "error"); } } // Default export: the marketplace-installable extension with default options. export default makePiWorkflowExtension();