UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

1,350 lines (1,343 loc) 49.5 kB
#!/usr/bin/env node import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import matter from "gray-matter"; import { CliRunRenderer } from "./cliRunRenderer.js"; import { TuiRunRenderer } from "./tui/tuiRunRenderer.js"; import { startRunnerApiServer } from "./runnerApi.js"; import { RunnerSessionStore } from "./runnerSession.js"; import { parseWorkflowFile, validateWorkflow } from "./parser.js"; import { MAN_PAGE_SOURCE } from "./manPage.js"; import { promptForApprovalDecision, runWorkflow } from "./engine.js"; import { SUPPORTED_ADAPTERS } from "./adapters.js"; import { runJudge } from "./judge.js"; import { renderJudgeReport } from "./judgeReport.js"; import { cmdAuth, cmdPublish, cmdPull, cmdRemoteInfo, cmdSearch } from "./remote/commands.js"; import { readSessionFile, writeSessionFile } from "./sessionFile.js"; import { emitRunTelemetryBestEffort } from "./remote/telemetry.js"; import { BUNDLED_SKILLS } from "./generated/bundledSkills.js"; import { adapterImplementationStatuses, adapterMockFallbackWarnings, runtimeDoctorChecks, validateRuntimeRequirements, } from "./runtimePreflight.js"; function resolveVersion() { if (typeof WFM_VERSION === "string" && WFM_VERSION) { return WFM_VERSION; } const modulePath = fileURLToPath(import.meta.url); const candidates = [ path.join(path.dirname(path.dirname(modulePath)), "package.json"), path.resolve("./package.json"), ]; for (const candidate of candidates) { try { const parsed = JSON.parse(fs.readFileSync(candidate, "utf-8")); if (parsed.version) return parsed.version; } catch { // try the next candidate } } return "unknown"; } function cliDisplayName() { const invokedAs = path.basename(process.argv[1] ?? ""); if (invokedAs === "workflow-manager" || invokedAs === "wfm") { return invokedAs; } return "wfm"; } function usage() { const cli = cliDisplayName(); const row = (command, description) => ` ${command.padEnd(28)}${description}`; console.log([ `${cli} — run multi-step AI agent workflows from the CLI`, "", `Usage: ${cli} <command> [options]`, "", "Author and run workflows", row("scaffold [path]", "Write a starter workflow file (--template agent-validated for validator example)"), row("validate <file>", "Check a workflow for errors"), row("judge <file>", "LLM-judge a workflow: model right-sizing + complexity (--json, --adapter, --model)"), row("doctor [file]", "Check host setup; with a file, preflight it"), row("run <file>", "Run a workflow with live progress (--ui for full-screen)"), "", "Control or observe a running workflow", row("approve", "Approve a step waiting for review"), row("resume", "Resume a step waiting on external input"), row("cancel", "Cancel a waiting step"), row("status [--step <key>]", "Print the run (or step) snapshot as JSON"), row("logs [--step <key>]", "Print buffered agent logs as JSON"), row("events [--since <seq>]", "Print run events as JSON (one-shot poll)"), " Connect with --url/--token, --session-file <path> (written by run --session-file),", " or WFM_RUNNER_URL/WFM_RUNNER_TOKEN.", "", "Share workflows (remote registry)", row("auth <login|whoami|logout>", "Sign in, check, or sign out"), row("search [query]", "Find shared workflows"), row("publish <file>", "Publish a workflow"), row("pull <owner/slug>", "Download a shared workflow"), row("remote info <owner/slug>", "Show a shared workflow's details"), "", "Agent skills and help", row("skill list", "List skills bundled with wfm"), row("skill install [name ...]", "Install skills into an agent (Claude Code, opencode)"), row("man", "Full manual with every option and examples"), row("--version", "Print the installed wfm version"), "", `Run \`${cli} man\` for all flags and examples.`, ].join("\n")); } function getFlag(name) { const idx = process.argv.indexOf(name); if (idx >= 0 && idx + 1 < process.argv.length) return process.argv[idx + 1]; return undefined; } function hasFlag(name) { return process.argv.includes(name); } function getFlagFromArgs(args, name) { const idx = args.indexOf(name); if (idx >= 0 && idx + 1 < args.length) return args[idx + 1]; return undefined; } function combineRunObservers(...observers) { const active = observers.filter((observer) => observer !== undefined); if (active.length === 0) { return undefined; } return { onEvent(event) { for (const observer of active) { observer.onEvent(event); } }, onSnapshot(snapshot, stepDetails) { for (const observer of active) { observer.onSnapshot(snapshot, stepDetails); } }, onLog(log) { for (const observer of active) { observer.onLog(log); } }, }; } const WORKFLOW_SCAFFOLD_JSON = { key: "workflow-manager-sample", title: "Workflow Manager Sample", description: "Workflow definition with per-step objectives and confirmations", objectives: ["deliver a working implementation", "ensure validation and approvals are explicit"], inputSchema: { type: "object", properties: { ticket: { type: "string" }, }, }, outputSchema: { type: "object", }, defaultRetryPolicy: { maxAttempts: 2, }, steps: [ { key: "discover", kind: "task", objective: "Understand requirements and constraints", dependsOn: [], validation: { mode: "human", required: true, autoConfirm: false }, taskSpec: { init: { context: { repo: "example/repo" }, skills: ["architecture", "planning"], mcps: ["mcp://github", "mcp://docs"], systemPrompts: ["Focus on architecture trade-offs"], model: "openrouter/anthropic/claude-sonnet-4", }, payload: { mockResult: "success" }, }, }, { key: "qa_gate", kind: "approval", objective: "Human product review approval", dependsOn: ["discover"], approvalSpec: { autoApprove: false, validation: { mode: "human", required: true, autoConfirm: false }, }, }, { key: "implement", kind: "task", objective: "Implement agreed changes", dependsOn: ["qa_gate"], validation: { mode: "external", required: true, autoConfirm: false }, retryPolicy: { maxAttempts: 2 }, taskSpec: { adapterKey: "codex", init: { context: { language: "typescript" }, skills: ["coding", "testing"], mcps: ["mcp://repo", "mcp://ci"], systemPrompts: ["Write tests with implementation"], }, payload: { mockResult: "success" }, }, }, { key: "hardening", kind: "task", objective: "Final hardening checks with Claude Code", dependsOn: ["implement"], validation: { mode: "external", required: true, autoConfirm: false }, taskSpec: { adapterKey: "claude-code", init: { context: { quality: "high" }, skills: ["security-review", "refactoring"], mcps: ["mcp://security"], systemPrompts: ["Prioritize correctness and readability"], }, payload: { mockResult: "success" }, }, }, { key: "acp_review", kind: "task", objective: "Example ACP step: set useRealAdapter + acpAgent to run a real agent; mocks otherwise", dependsOn: ["hardening"], validation: { mode: "none", required: false, autoConfirm: true }, taskSpec: { adapterKey: "acp", init: { systemPrompts: ["Review the implementation"] }, payload: { mockResult: "success" }, }, }, ], }; const WORKFLOW_SCAFFOLD_MARKDOWN = `--- key: workflow-manager-sample title: Workflow Manager Sample description: Workflow definition with per-step objectives and confirmations objectives: - deliver a working implementation - ensure validation and approvals are explicit inputSchema: type: object properties: ticket: type: string outputSchema: type: object defaultRetryPolicy: maxAttempts: 2 steps: - key: discover kind: task objective: Understand requirements and constraints dependsOn: [] validation: mode: human required: true autoConfirm: false taskSpec: init: context: repo: example/repo skills: [architecture, planning] mcps: [mcp://github, mcp://docs] systemPrompts: [Focus on architecture trade-offs] model: openrouter/anthropic/claude-sonnet-4 payload: mockResult: success - key: qa_gate kind: approval objective: Human product review approval dependsOn: [discover] approvalSpec: autoApprove: false validation: mode: human required: true autoConfirm: false - key: implement kind: task objective: Implement agreed changes dependsOn: [qa_gate] validation: mode: external required: true autoConfirm: false retryPolicy: maxAttempts: 2 taskSpec: adapterKey: codex init: context: language: typescript skills: [coding, testing] mcps: [mcp://repo, mcp://ci] systemPrompts: [Write tests with implementation] payload: mockResult: success - key: hardening kind: task objective: Final hardening checks with Claude Code dependsOn: [implement] validation: mode: external required: true autoConfirm: false taskSpec: adapterKey: claude-code init: context: quality: high skills: [security-review, refactoring] mcps: [mcp://security] systemPrompts: [Prioritize correctness and readability] payload: mockResult: success - key: acp_review kind: task objective: "Example ACP step: set useRealAdapter + acpAgent to run a real agent; mocks otherwise" dependsOn: [hardening] validation: mode: none required: false autoConfirm: true taskSpec: adapterKey: acp init: systemPrompts: [Review the implementation] payload: mockResult: success --- # Workflow Notes Edit frontmatter to configure orchestration behavior. `; const WORKFLOW_SCAFFOLD_AGENT_VALIDATED_JSON = { key: "agent-validated-pipeline", title: "Agent-Validated Pipeline", description: "Repeatable task pipeline where a second agent checks the implementation against explicit criteria", objectives: ["ship a change that meets the stated acceptance criteria"], defaultRetryPolicy: { maxAttempts: 2 }, steps: [ { key: "implement", kind: "task", objective: "Implement the requested change", dependsOn: [], retryPolicy: { maxAttempts: 2 }, validation: { mode: "agent", required: true, autoConfirm: false, agent: { criteria: "The change satisfies the workflow objective, builds cleanly, and includes tests for new behavior.", init: { model: "openrouter/anthropic/claude-sonnet-4", systemPrompts: ["Check the diff against the criteria; be specific about any gaps found"], }, }, }, taskSpec: { init: { context: { repo: "example/repo" }, skills: ["coding", "testing"], systemPrompts: ["Implement the change described in the workflow objective"], }, payload: { mockResult: "success" }, }, }, { key: "review-gate", kind: "approval", objective: "Human sign-off before finalizing", dependsOn: ["implement"], validation: { mode: "human", required: true, autoConfirm: false }, approvalSpec: { autoApprove: false, validation: { mode: "human", required: true, autoConfirm: false }, }, }, { key: "finalize", kind: "task", objective: "Finalize the change (for example, open a PR)", dependsOn: ["review-gate"], validation: { mode: "none", required: false, autoConfirm: true }, taskSpec: { init: { systemPrompts: ["Open a PR summarizing the change and its validation history"], }, payload: { mockResult: "success" }, }, }, ], }; const WORKFLOW_SCAFFOLD_AGENT_VALIDATED_MARKDOWN = `--- key: agent-validated-pipeline title: Agent-Validated Pipeline description: Repeatable task pipeline where a second agent checks the implementation against explicit criteria objectives: - ship a change that meets the stated acceptance criteria defaultRetryPolicy: maxAttempts: 2 steps: # "implement" omits taskSpec.adapterKey, so it runs on the default pi-agent adapter. - key: implement kind: task objective: Implement the requested change dependsOn: [] retryPolicy: maxAttempts: 2 validation: # mode: agent routes this step through a second, independent agent call after # implement finishes. That validator agent reads validation.agent.criteria, # returns a verdict (SUCCESS/QA_REJECTED/...), and the engine maps it to a QA # action: PROCEED keeps going, RETRY_CURRENT reruns this step, ROLLBACK_PREVIOUS # reruns an earlier step, RESTART_ALL restarts the run. Sharpen criteria to # control what the validator accepts. mode: agent required: true autoConfirm: false agent: criteria: >- The change satisfies the workflow objective, builds cleanly, and includes tests for new behavior. init: model: openrouter/anthropic/claude-sonnet-4 systemPrompts: - Check the diff against the criteria; be specific about any gaps found taskSpec: init: context: repo: example/repo skills: [coding, testing] systemPrompts: [Implement the change described in the workflow objective] payload: # mockResult drives the mock adapter for local dry runs; real adapters ignore it. mockResult: success - key: review-gate kind: approval objective: Human sign-off before finalizing dependsOn: [implement] # Top-level validation must match approvalSpec.validation: without it, the # parser's default (autoConfirm: true) wins and the gate auto-approves. validation: mode: human required: true autoConfirm: false approvalSpec: autoApprove: false validation: mode: human required: true autoConfirm: false - key: finalize kind: task objective: "Finalize the change (for example, open a PR)" dependsOn: [review-gate] validation: mode: none required: false autoConfirm: true taskSpec: init: systemPrompts: [Open a PR summarizing the change and its validation history] payload: mockResult: success --- # Agent-Validated Pipeline This workflow shows first-class agent validation: instead of (or in addition to) a human or external check, a step's own output can be graded by a second agent call. - \`implement\` runs, then its \`validation.agent\` config sends the result to a validator agent along with \`criteria\` — plain-language acceptance criteria the validator checks the work against. - The validator's verdict becomes a QA action: \`PROCEED\` lets the run continue, \`RETRY_CURRENT\` re-runs \`implement\` with the validator's feedback, \`ROLLBACK_PREVIOUS\` re-runs an earlier step, and \`RESTART_ALL\` restarts the run. Retries are bounded by \`retryPolicy.maxAttempts\`. - \`review-gate\` is a human approval step — agent validation cannot be used on approval steps, so high-stakes changes still get a human in the loop before \`finalize\` runs. Tighten \`validation.agent.criteria\` to describe exactly what "done" means for this step; the validator only knows what criteria tells it. `; function resolveScaffoldFormat(targetPath, explicitFormat) { if (explicitFormat === "markdown" || explicitFormat === "json") { return explicitFormat; } return path.extname(targetPath).toLowerCase() === ".json" ? "json" : "markdown"; } function parseScaffoldArgs(args) { let targetPath; let format; let template; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === "--format") { format = args[i + 1]; i += 1; continue; } if (arg === "--template") { template = args[i + 1]; i += 1; continue; } if (!arg.startsWith("-") && !targetPath) { targetPath = arg; } } return { targetPath, format, template }; } const DEFAULT_INSTALL_SKILL = "workflow-manager-cli"; const SKILL_INSTALL_TARGETS = { claude: { projectDir: path.join(".claude", "skills"), globalDir: path.join(os.homedir(), ".claude", "skills"), }, opencode: { projectDir: path.join(".opencode", "skill"), globalDir: path.join(os.homedir(), ".config", "opencode", "skill"), }, }; export function packagedSkillsDir() { return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "skills"); } export function parseSkillDescription(skillMarkdown) { try { const parsed = matter(skillMarkdown); if (typeof parsed.data.description === "string") { return parsed.data.description.replace(/\s+/g, " ").trim(); } } catch { // skills without parseable frontmatter are still installable } return ""; } export function listPackagedSkillsFromDisk(root) { if (!fs.existsSync(root)) { return []; } const skills = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = path.join(root, entry.name); const skillFile = path.join(dir, "SKILL.md"); if (!fs.existsSync(skillFile)) continue; const description = parseSkillDescription(fs.readFileSync(skillFile, "utf-8")); skills.push({ name: entry.name, description, source: { kind: "disk", dir } }); } return skills; } export function listPackagedSkillsFromBundle(bundle = BUNDLED_SKILLS) { const skills = []; for (const name of Object.keys(bundle).sort()) { const files = bundle[name]; const skillFile = files.find((file) => file.name === "SKILL.md"); if (!skillFile) continue; skills.push({ name, description: parseSkillDescription(skillFile.content), source: { kind: "bundled", files } }); } return skills; } export function listPackagedSkills(root = packagedSkillsDir()) { const onDisk = listPackagedSkillsFromDisk(root); if (onDisk.length > 0) { return onDisk; } return listPackagedSkillsFromBundle(); } export function materializeSkillFiles(skill, destDir) { if (skill.source.kind === "disk") { for (const entry of fs.readdirSync(skill.source.dir, { withFileTypes: true })) { if (!entry.isFile() || entry.name === "README.md") continue; fs.copyFileSync(path.join(skill.source.dir, entry.name), path.join(destDir, entry.name)); } return; } for (const file of skill.source.files) { if (file.name === "README.md") continue; fs.writeFileSync(path.join(destDir, file.name), file.content, "utf-8"); } } function cmdSkillList() { const skills = listPackagedSkills(); if (skills.length === 0) { console.error(`No bundled skills found at ${packagedSkillsDir()}.`); return 1; } console.log("Bundled skills:\n"); for (const skill of skills) { console.log(` ${skill.name}`); if (skill.description) { console.log(` ${skill.description}`); } } console.log(`\nInstall with: ${cliDisplayName()} skill install <name> [--agent claude|opencode] [--global]`); return 0; } function parseSkillInstallArgs(args) { const parsed = { names: [], agent: "claude", global: false, force: false, all: false }; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === "--agent") { parsed.agent = args[i + 1] ?? ""; i += 1; continue; } if (arg === "--dir") { parsed.dir = args[i + 1]; i += 1; continue; } if (arg === "--global" || arg === "-g") { parsed.global = true; continue; } if (arg === "--force" || arg === "-f") { parsed.force = true; continue; } if (arg === "--all") { parsed.all = true; continue; } if (!arg.startsWith("-")) { parsed.names.push(arg); } } return parsed; } function resolveSkillInstallRoot(args) { if (args.dir) { return path.resolve(args.dir); } const target = SKILL_INSTALL_TARGETS[args.agent]; if (!target) { return undefined; } return args.global ? target.globalDir : path.resolve(target.projectDir); } function cmdSkillInstall(args) { const parsed = parseSkillInstallArgs(args); const targetRoot = resolveSkillInstallRoot(parsed); if (!targetRoot) { console.error(`Unknown --agent value: ${parsed.agent}. Supported agents: ${Object.keys(SKILL_INSTALL_TARGETS).join(", ")}. Use --dir for any other destination.`); return 1; } const available = listPackagedSkills(); const names = parsed.all ? available.map((skill) => skill.name) : parsed.names.length > 0 ? parsed.names : [DEFAULT_INSTALL_SKILL]; for (const name of names) { const skill = available.find((candidate) => candidate.name === name); if (!skill) { console.error(`Unknown skill: ${name}. Run \`${cliDisplayName()} skill list\` to see bundled skills.`); return 1; } const destDir = path.join(targetRoot, name); const destFile = path.join(destDir, "SKILL.md"); if (fs.existsSync(destFile) && !parsed.force) { console.error(`Skill already installed at ${destFile}. Pass --force to overwrite.`); return 1; } fs.mkdirSync(destDir, { recursive: true }); materializeSkillFiles(skill, destDir); console.log(`Installed skill ${name} -> ${destDir}`); } return 0; } function cmdScaffold(targetPath, format, template) { const resolvedPath = targetPath ? path.resolve(targetPath) : path.resolve("./example-workflow.md"); const normalizedFormat = format?.toLowerCase(); const resolvedFormat = resolveScaffoldFormat(resolvedPath, normalizedFormat); if (normalizedFormat && resolvedFormat !== normalizedFormat) { console.error(`Invalid --format value: ${format}. Use markdown or json.`); return 1; } const normalizedTemplate = template?.toLowerCase() ?? "default"; if (normalizedTemplate !== "default" && normalizedTemplate !== "agent-validated") { console.error(`Invalid --template value: ${template}. Use default or agent-validated.`); return 1; } const content = normalizedTemplate === "agent-validated" ? resolvedFormat === "json" ? `${JSON.stringify(WORKFLOW_SCAFFOLD_AGENT_VALIDATED_JSON, null, 2)}\n` : WORKFLOW_SCAFFOLD_AGENT_VALIDATED_MARKDOWN : resolvedFormat === "json" ? `${JSON.stringify(WORKFLOW_SCAFFOLD_JSON, null, 2)}\n` : WORKFLOW_SCAFFOLD_MARKDOWN; fs.writeFileSync(resolvedPath, content, "utf-8"); console.log(`Scaffolded ${resolvedFormat} workflow: ${resolvedPath}`); return 0; } function cmdMan() { const modulePath = fileURLToPath(import.meta.url); const packageRoot = path.dirname(path.dirname(modulePath)); const candidatePaths = [path.join(packageRoot, "man", "wfm.1"), path.resolve("./man/wfm.1")]; const manPagePath = candidatePaths.find((candidatePath) => fs.existsSync(candidatePath)); let fallbackSource = MAN_PAGE_SOURCE; let tempDir; const resolvedManPagePath = manPagePath ?? (() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wfm-man-")); const tempManPagePath = path.join(tempDir, "wfm.1"); fs.writeFileSync(tempManPagePath, MAN_PAGE_SOURCE, "utf-8"); return tempManPagePath; })(); if (manPagePath) { fallbackSource = fs.readFileSync(manPagePath, "utf-8"); } try { const result = spawnSync("man", [resolvedManPagePath], { stdio: "inherit" }); if (result.status === 0) { return 0; } console.log("\n' man ' command unavailable, printing page contents:\n"); console.log(fallbackSource); return 0; } finally { if (tempDir) { fs.rmSync(tempDir, { recursive: true, force: true }); } } } function cmdValidate(filePath) { try { const workflow = parseWorkflowFile(path.resolve(filePath)); const errors = validateWorkflow(workflow); if (errors.length > 0) { console.log("Validation failed:"); for (const e of errors) console.log(`- ${e}`); return 1; } console.log("Validation OK"); return 0; } catch (err) { console.error(`Validation error: ${err.message}`); return 1; } } function judgeFlagValue(args, name) { const index = args.indexOf(name); return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined; } async function cmdJudge(args) { const valueFlags = new Set(["--adapter", "--model"]); let filePath; for (let i = 0; i < args.length; i++) { if (valueFlags.has(args[i])) { i++; continue; } if (!args[i].startsWith("-")) { filePath = args[i]; break; } } if (!filePath) { usage(); return 1; } const adapterFlag = judgeFlagValue(args, "--adapter"); if (adapterFlag && !SUPPORTED_ADAPTERS.includes(adapterFlag)) { console.error(`Unknown adapter "${adapterFlag}". Valid adapters: ${SUPPORTED_ADAPTERS.join(", ")}`); return 1; } const resolved = path.resolve(filePath); let workflow; try { workflow = parseWorkflowFile(resolved); } catch (err) { console.error(`Validation error: ${err.message}`); return 1; } const errors = validateWorkflow(workflow); if (errors.length > 0) { console.log("Validation failed:"); for (const e of errors) console.log(`- ${e}`); return 1; } const result = await runJudge(workflow, resolved, { adapterKey: adapterFlag, model: judgeFlagValue(args, "--model"), }); if (typeof result === "string") { console.error(result); return 1; } console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : renderJudgeReport(result)); return 0; } function renderStatus(status) { if (status === "ok") return "OK"; if (status === "missing") return "MISSING"; return "INFO"; } function cmdDoctor(args) { const workflowPath = args.find((arg) => !arg.startsWith("-")); const json = args.includes("--json"); const hostChecks = runtimeDoctorChecks(); const adapterStatuses = adapterImplementationStatuses(); let workflow = null; let workflowErrors = []; let runtimeErrors = []; let adapterWarnings = []; if (workflowPath) { try { workflow = parseWorkflowFile(path.resolve(workflowPath)); workflowErrors = validateWorkflow(workflow); if (workflowErrors.length === 0) { runtimeErrors = validateRuntimeRequirements(workflow); adapterWarnings = adapterMockFallbackWarnings(workflow); } } catch (err) { workflowErrors = [err.message]; } } const baselineErrors = workflowPath ? [] : hostChecks .filter((check) => check.required && check.status === "missing") .map((check) => `${check.label}: ${check.detail}`); const exitCode = baselineErrors.length > 0 || workflowErrors.length > 0 || runtimeErrors.length > 0 ? 1 : 0; if (json) { console.log(JSON.stringify({ ok: exitCode === 0, hostChecks, adapterStatuses, workflow: workflow ? { key: workflow.key, title: workflow.title, errors: workflowErrors, runtimeErrors, adapterWarnings, } : null, baselineErrors, }, null, 2)); return exitCode; } console.log("Workflow Manager Doctor"); console.log("\nHost runtime:"); for (const check of hostChecks) { const required = check.required ? "required" : "optional"; console.log(`- ${renderStatus(check.status)} ${check.label} (${required}): ${check.detail}`); } console.log("\nAdapter implementation:"); for (const adapter of adapterStatuses) { console.log(`- ${adapter.adapter}: ${adapter.status} - ${adapter.detail}`); } if (workflowPath) { console.log(`\nWorkflow: ${workflowPath}`); if (workflowErrors.length > 0) { console.log("- INVALID schema:"); for (const error of workflowErrors) { console.log(` - ${error}`); } } else if (runtimeErrors.length > 0) { console.log("- INVALID runtime:"); for (const error of runtimeErrors) { console.log(` - ${error}`); } } else { console.log("- OK workflow schema and runtime requirements"); } if (adapterWarnings.length > 0) { console.log("\nAdapter warnings:"); for (const warning of adapterWarnings) { console.log(`- ${warning.stepKey}: ${warning.message}`); } } } return exitCode; } function resolveRunnerConnection(args) { let baseUrl = getFlagFromArgs(args, "--url"); let token = getFlagFromArgs(args, "--token"); let runId = getFlagFromArgs(args, "--run-id"); const sessionFilePath = getFlagFromArgs(args, "--session-file"); if (sessionFilePath && (!baseUrl || !token || !runId)) { const session = readSessionFile(sessionFilePath); if (typeof session === "string") { return session; } baseUrl = baseUrl ?? session.baseUrl; token = token ?? session.attachToken; runId = runId ?? session.runId; } baseUrl = baseUrl ?? process.env.WFM_RUNNER_URL; token = token ?? process.env.WFM_RUNNER_TOKEN; if (!baseUrl) { return `Missing --url. You can also pass --session-file or set WFM_RUNNER_URL.`; } if (!token) { return `Missing --token. You can also pass --session-file or set WFM_RUNNER_TOKEN.`; } return { baseUrl, token, runId }; } async function resolveRunnerRunId(connection, headers) { if (connection.runId) { return { runId: connection.runId }; } const sessionResponse = await fetch(`${connection.baseUrl}/session`, { headers }); if (!sessionResponse.ok) { const message = await sessionResponse.text(); return { error: `Failed to discover run id: ${message}` }; } const session = (await sessionResponse.json()); const runId = session.run?.runId; if (!runId) { return { error: "Could not determine run id. Pass --run-id explicitly." }; } return { runId }; } async function runnerControlRequest(action, args) { const connection = resolveRunnerConnection(args); if (typeof connection === "string") { console.error(connection); return 1; } const stepKey = getFlagFromArgs(args, "--step"); const actor = getFlagFromArgs(args, "--actor"); const note = getFlagFromArgs(args, "--note"); const source = getFlagFromArgs(args, "--source") ?? "cli"; const headers = { Authorization: `Bearer ${connection.token}`, }; const resolved = await resolveRunnerRunId(connection, headers); if (resolved.error || !resolved.runId) { console.error(resolved.error ?? "Could not determine run id. Pass --run-id explicitly."); return 1; } const runId = resolved.runId; const response = await fetch(`${connection.baseUrl}/runs/${runId}/${action}`, { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ stepKey, actor, note, source, }), }); const payloadText = await response.text(); const payload = payloadText ? JSON.parse(payloadText) : {}; if (!response.ok) { const message = typeof payload.message === "string" ? payload.message : `Request failed with status ${response.status}`; console.error(`${action} failed: ${message}`); return 1; } const decision = typeof payload.decision === "string" ? payload.decision : action === "cancel" ? "cancelled" : "approved"; const resolvedStep = typeof payload.stepKey === "string" ? payload.stepKey : stepKey ?? "current"; console.log(`${decision} ${resolvedStep}`); return 0; } async function runnerReadRequest(command, args) { const connection = resolveRunnerConnection(args); if (typeof connection === "string") { console.error(connection); return 1; } const headers = { Authorization: `Bearer ${connection.token}`, }; try { const resolved = await resolveRunnerRunId(connection, headers); if (resolved.error || !resolved.runId) { console.error(resolved.error ?? "Could not determine run id. Pass --run-id explicitly."); return 1; } const runId = encodeURIComponent(resolved.runId); let requestPath; if (command === "status") { const stepKey = getFlagFromArgs(args, "--step"); requestPath = stepKey ? `/runs/${runId}/steps/${encodeURIComponent(stepKey)}` : `/runs/${runId}`; } else if (command === "logs") { const query = new URLSearchParams(); const stepKey = getFlagFromArgs(args, "--step"); const limit = getFlagFromArgs(args, "--limit"); const cursor = getFlagFromArgs(args, "--cursor"); if (stepKey) query.set("stepKey", stepKey); if (limit) query.set("limit", limit); if (cursor) query.set("cursor", cursor); const queryString = query.toString(); requestPath = `/runs/${runId}/logs${queryString ? `?${queryString}` : ""}`; } else { const query = new URLSearchParams(); const since = getFlagFromArgs(args, "--since"); if (since) query.set("sinceSequence", since); query.set("includeLogs", args.includes("--include-logs") ? "true" : "false"); requestPath = `/runs/${runId}/events/list?${query.toString()}`; } const response = await fetch(`${connection.baseUrl}${requestPath}`, { headers }); const payloadText = await response.text(); if (!response.ok) { let message = `Request failed with status ${response.status}`; try { const payload = JSON.parse(payloadText); if (typeof payload.message === "string") { message = payload.message; } } catch { // keep the fallback message } console.error(`${command} failed: ${message}`); return 1; } console.log(payloadText); return 0; } catch (error) { console.error(`${command} failed: ${error.message}`); return 1; } } async function cmdRun(filePath) { const resolvedPath = path.resolve(filePath); const startedAt = Date.now(); const sessionFilePath = getFlag("--session-file"); let sessionFileState; let finalRunStatus; let workflow; let runnerServer; let sessionStore; let liveRenderer; let tuiRenderer; try { workflow = parseWorkflowFile(resolvedPath); const errors = validateWorkflow(workflow); if (errors.length > 0) { console.error(`Invalid workflow: ${errors.join("; ")}`); await emitRunTelemetryBestEffort({ definition: workflow, sourceFilePath: resolvedPath, durationMs: Date.now() - startedAt, failureReason: errors.join("; "), }); return 1; } for (const warning of adapterMockFallbackWarnings(workflow)) { process.stderr.write(`⚠ ${warning.stepKey}: ${warning.message}\n`); } const objective = getFlag("--objective"); const inputRaw = getFlag("--input"); let input = {}; if (inputRaw) { const parsed = inputRaw.trimStart().startsWith("{") ? JSON.parse(inputRaw.replace(/[\n\r]/g, " ")) : JSON.parse(fs.readFileSync(path.resolve(inputRaw), "utf-8")); if (typeof parsed !== "object" || Array.isArray(parsed) || parsed === null) { console.error("--input must be a JSON object"); return 1; } input = parsed; } const confirmRaw = getFlag("--confirm") ?? ""; const confirmations = confirmRaw .split(",") .map((x) => x.trim()) .filter(Boolean); const runId = randomUUID(); const requestedPortRaw = getFlag("--port"); const requestedPort = requestedPortRaw === undefined ? 0 : Number.parseInt(requestedPortRaw, 10); if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) { console.error("--port must be an integer between 0 and 65535"); return 1; } const wantUi = hasFlag("--ui"); const useTui = wantUi && process.stdout.isTTY === true && process.stdin.isTTY === true; if (wantUi && !useTui) { process.stderr.write("⚠ --ui requires an interactive terminal; falling back to standard output\n"); } sessionStore = new RunnerSessionStore({ runId, workflow, objective: objective ?? workflow.title, objectives: workflow.objectives ?? [], }); if (!useTui) { liveRenderer = new CliRunRenderer({ workflow, verbose: hasFlag("--verbose"), }); } runnerServer = await startRunnerApiServer(sessionStore, requestedPort); const session = sessionStore.sessionInfo(); if (sessionFilePath) { sessionFileState = { baseUrl: session.baseUrl, attachToken: session.attachToken, runId, pid: process.pid, startedAt: session.startedAt, }; writeSessionFile(sessionFilePath, sessionFileState); } if (!useTui) { process.stderr.write(`Attach API: ${session.baseUrl} (token ${session.attachToken})\n`); } if (useTui) { tuiRenderer = new TuiRunRenderer({ workflow, session: sessionStore, attachUrl: session.baseUrl, attachToken: session.attachToken, }); tuiRenderer.start(); } const result = await runWorkflow(workflow, { runId, objective, input, confirmations, autoConfirmAll: hasFlag("--auto-confirm-all"), interactive: useTui ? false : process.stdin.isTTY, workflowFilePath: resolvedPath, approvalPrompt: useTui ? undefined : async (request) => { liveRenderer?.pauseHeartbeat(); try { const decision = await promptForApprovalDecision(request.stepKey, request.reason, request.validation ?? "external", request.preview ?? null, "cli", request.signal); if (!decision) { return null; } const metadata = { actor: decision.actor, note: decision.note, source: decision.source, }; const outcome = decision.decision === "cancelled" ? sessionStore?.cancel(request.stepKey, metadata) : request.validation === "external" ? sessionStore?.resume(request.stepKey, metadata) : sessionStore?.approve(request.stepKey, metadata); if (outcome && !outcome.ok) { process.stderr.write(`Could not apply terminal decision for ${request.stepKey}: ${outcome.reason ?? "unknown error"}\n`); } return null; } finally { liveRenderer?.resumeHeartbeat(); } }, observer: combineRunObservers(sessionStore, useTui ? tuiRenderer : liveRenderer), controller: sessionStore, }); tuiRenderer?.stop(); liveRenderer?.close(); finalRunStatus = result.status; if (hasFlag("--json")) { console.log(JSON.stringify({ session: sessionStore.sessionInfo(), ...result }, null, 2)); } else { const icon = result.status === "succeeded" ? "✓" : result.status === "waiting_for_approval" ? "◌" : "✗"; process.stderr.write(`\n${icon} ${result.status}${workflow.title}\n\n`); for (const sr of result.stepRuns) { const stepIcon = sr.status === "succeeded" ? "✓" : sr.status === "waiting_for_approval" ? "◌" : "✗"; const step = workflow.steps.find((s) => s.key === sr.stepKey); const adapterKey = step?.kind === "task" ? (step.taskSpec?.adapterKey ?? "pi-agent") : "approval"; process.stderr.write(` ${stepIcon} ${sr.stepKey.padEnd(20)} ${adapterKey}\n`); } if (result.status !== "succeeded") { process.stderr.write(`\nRun --json to see full output.\n`); } process.stderr.write("\n"); } await emitRunTelemetryBestEffort({ definition: workflow, sourceFilePath: resolvedPath, durationMs: Date.now() - startedAt, result, failureReason: result.status === "failed" ? "run failed" : result.status === "waiting_for_approval" ? "confirmation required" : undefined, }); return result.status === "succeeded" ? 0 : 2; } catch (err) { tuiRenderer?.stop(); liveRenderer?.close(); console.error(`Run error: ${err.message}`); if (workflow) { await emitRunTelemetryBestEffort({ definition: workflow, sourceFilePath: resolvedPath, durationMs: Date.now() - startedAt, failureReason: err.message, }); } return 1; } finally { tuiRenderer?.stop(); liveRenderer?.close(); if (sessionFilePath && sessionFileState) { try { writeSessionFile(sessionFilePath, { ...sessionFileState, endedAt: new Date().toISOString(), status: finalRunStatus ?? "failed", }); } catch { // session-file finalization is best effort; the run result is authoritative } } await runnerServer?.close().catch(() => undefined); } } async function main() { const cmd = process.argv[2]; if (cmd === "--version" || cmd === "-v" || cmd === "version") { console.log(resolveVersion()); process.exit(0); } if (!cmd || cmd === "-h" || cmd === "--help") { usage(); process.exit(0); } if (cmd === "doctor") { process.exit(cmdDoctor(process.argv.slice(3))); } if (cmd === "skill") { const sub = process.argv[3]; if (sub === "list") { process.exit(cmdSkillList()); } if (sub === "install") { process.exit(cmdSkillInstall(process.argv.slice(4))); } usage(); process.exit(1); } if (cmd === "scaffold") { const { targetPath, format, template } = parseScaffoldArgs(process.argv.slice(3)); process.exit(cmdScaffold(targetPath, format, template)); } if (cmd === "man") { process.exit(cmdMan()); } if (cmd === "validate") { const file = process.argv[3]; if (!file) { usage(); process.exit(1); } process.exit(cmdValidate(file)); } if (cmd === "judge") { process.exit(await cmdJudge(process.argv.slice(3))); } if (cmd === "run") { const file = process.argv[3]; if (!file) { usage(); process.exit(1); } process.exit(await cmdRun(file)); } if (cmd === "approve" || cmd === "resume" || cmd === "cancel") { process.exit(await runnerControlRequest(cmd, process.argv.slice(3))); } if (cmd === "status" || cmd === "logs" || cmd === "events") { process.exit(await runnerReadRequest(cmd, process.argv.slice(3))); } if (cmd === "auth") { process.exit(await cmdAuth(process.argv.slice(3))); } if (cmd === "search") { process.exit(await cmdSearch(process.argv.slice(3))); } if (cmd === "publish") { const file = process.argv[3]; if (!file) { usage(); process.exit(1); } process.exit(await cmdPublish(file, process.argv.slice(4))); } if (cmd === "pull") { const reference = process.argv[3]; if (!reference) { usage(); process.exit(1); } process.exit(await cmdPull(reference, process.argv.slice(4))); } if (cmd === "remote" && process.argv[3] === "info") { const reference = process.argv[4]; if (!reference) { usage(); process.exit(1); } process.exit(await cmdRemoteInfo(reference)); } usage(); process.exit(1); } // Run main() only when this module is the process entrypoint (direct `bun // run`/`node` invocation, an npm-installed symlinked bin, or a `bun build // --compile` standalone binary) — never when imported as a module, e.g. by // tests exercising the exported skill-catalog helpers below. function isEntryModule() { if (!process.argv[1]) { return false; } const self = fileURLToPath(import.meta.url); let invoked = process.argv[1]; try { invoked = fs.realpathSync(invoked); } catch { // compiled/virtual filesystem paths (e.g. bun's $bunfs) can't be // realpath'd; fall back to the raw invoked path. } try { return path.resolve(invoked) === self; } catch { return false; } } if (isEntryModule()) { void main(); }