@blundergoat/goat-flow
Version:
AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.
603 lines • 24.1 kB
JavaScript
/**
* Command-dispatch layer for the CLI: one handler per subcommand plus the COMMAND_HANDLERS table
* and dispatchCommand entry that routes a parsed ParsedCLI to the right one. Handlers lazy-import
* their heavy dependencies (audit, facts, quality, dashboard, stats) so a single command never pays
* for modules it does not use. The shared error convention is to throw CLIError for user-facing
* failures (the entry point maps that to an exit code) and to set process.exitCode (not exit) for
* non-zero-but-successful outcomes like a failing audit, so buffered stdout still flushes.
*/
import { resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { classifyProjectState } from "./classify-state.js";
import { CLIError } from "./cli-error.js";
import { writeOutput } from "./cli-output.js";
import { MULTI_AGENT_SYNC_BANNER, validAgentFlags, validAgents, } from "./cli-agent-options.js";
import { createFS } from "./facts/fs.js";
import { handleHooksCommand } from "./hooks-command.js";
import { buildInstallerInvocation, buildInstallerSpawnSpec, } from "./install-invocation.js";
import { getPackageVersion, getTemplatePath } from "./paths.js";
import { emitIndexGenerationInstallResult, handleIndexCommand, } from "./learning-loop-index/command.js";
import { ensureGitCommitInstructions, } from "./prompt/commit-guidance.js";
import { handleQualityCommand as runQualityCommand } from "./quality/quality-command.js";
const PACKAGE_VERSION = getPackageVersion();
function formatCandidacyArtifact(recommendation) {
switch (recommendation.type) {
case "skill":
return `skill (${recommendation.subtype})`;
case "reference":
return `reference (${recommendation.subtype})`;
case "instruction-file":
return `instruction-file rule (${recommendation.reason})`;
case "learning-loop":
return `learning-loop (${recommendation.subtype})`;
case "cli-command":
return "cli-command";
case "do-not-create":
return `do-not-create (${recommendation.reason})`;
}
}
/** Return a shallow copy of one check with its heavy `details` payload removed for compact JSON. */
function stripCheckDetails(check) {
const stripped = { ...check };
delete stripped.details;
return stripped;
}
/** Remove detail payloads from every check inside one audit scope. */
function stripScopeDetails(scope) {
return {
...scope,
checks: scope.checks.map(stripCheckDetails),
};
}
/** Return the compact audit report shape used by non-verbose JSON output. */
function stripAuditDetails(report) {
return {
...report,
scopes: {
setup: stripScopeDetails(report.scopes.setup),
agent: stripScopeDetails(report.scopes.agent),
harness: report.scopes.harness
? stripScopeDetails(report.scopes.harness)
: null,
},
};
}
const MENU_ACTIONS = [
{
key: "1",
label: "Start dashboard",
command: "dashboard",
needsAgent: false,
},
{
key: "2",
label: "Install/update goat-flow files",
command: "install",
needsAgent: true,
},
{
key: "3",
label: "Generate setup prompt",
command: "setup",
needsAgent: true,
},
{
key: "4",
label: "Audit current project",
command: "audit",
needsAgent: false,
},
{
key: "5",
label: "Show project status",
command: "status",
needsAgent: false,
},
];
/** Render the no-args command picker. */
function renderMenuText() {
const lines = [
"goat-flow",
"",
"What do you want to do?",
...MENU_ACTIONS.map((action) => ` ${action.key}. ${action.label}`),
"",
"Run a command directly any time, for example:",
" goat-flow dashboard .",
" goat-flow install . --agent codex",
" goat-flow audit . --harness",
];
return lines.join("\n");
}
/** Return true when the process can safely ask questions. */
function canPrompt() {
return process.stdin.isTTY === true && process.stdout.isTTY === true;
}
/** Find a menu action by number or case-insensitive label prefix. */
function findMenuAction(input) {
const normalized = input.trim().toLowerCase();
if (!normalized)
return null;
return (MENU_ACTIONS.find((action) => action.key === normalized ||
action.label.toLowerCase().startsWith(normalized)) ?? null);
}
/** Ask for a project path, defaulting to the current working directory. */
async function promptProjectPath(rl) {
const answer = await rl.question("Project path [.] ");
return resolve(answer.trim() || ".");
}
/** Ask for one supported agent id. */
async function promptAgent(rl) {
const agents = validAgents();
for (;;) {
const answer = await rl.question(`Agent (${agents.join("/")}) `);
const selected = answer.trim();
if (agents.includes(selected))
return selected;
console.log(`Use one of: ${agents.join(", ")}`);
}
}
/** Ask whether install should overwrite settings/config. */
async function promptForce(rl) {
const answer = await rl.question("Overwrite existing settings/config? [y/N] ");
return /^y(?:es)?$/iu.test(answer.trim());
}
/** Read all menu answers and build the command options to run. */
async function promptMenuCommand(options, rl) {
console.log(renderMenuText());
const choice = await rl.question("\nChoice [1] ");
const action = findMenuAction(choice || "1");
if (!action) {
throw new CLIError("Unknown menu choice.", 2);
}
const projectPath = await promptProjectPath(rl);
const agent = action.needsAgent ? await promptAgent(rl) : options.agent;
const shouldForce = action.command === "install" ? await promptForce(rl) : false;
return {
...options,
command: action.command,
projectPath,
agent,
shouldForce,
shouldApply: false,
};
}
/** Handle the interactive no-args command picker. */
async function handleMenuCommand(options) {
if (!canPrompt() || options.output !== null) {
writeOutput(options, renderMenuText());
return;
}
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
let nextOptions;
try {
nextOptions = await promptMenuCommand(options, rl);
}
finally {
rl.close();
}
await dispatchCommand(nextOptions);
}
/** Handle the status command: classify and display project adoption state */
async function handleStatusCommand(options) {
const { createFS } = await import("./facts/fs.js");
const { classifyProjectState } = await import("./classify-state.js");
const fs = createFS(options.projectPath);
const result = classifyProjectState(fs, options.agent ?? undefined);
if (options.format === "json") {
writeOutput(options, JSON.stringify({ path: options.projectPath, ...result, version: PACKAGE_VERSION }, null, 2));
return;
}
if (options.format === "markdown") {
const lines = [
`**Path:** ${options.projectPath}`,
`**State:** ${result.state}`,
`**Action:** ${result.action}`,
`**Details:** ${result.details}`,
];
writeOutput(options, lines.join("\n"));
return;
}
const stateColors = {
bare: "\x1b[90m",
partial: "\x1b[33m",
"v0.9": "\x1b[31m",
outdated: "\x1b[36m",
current: "\x1b[32m",
error: "\x1b[31m",
};
const reset = "\x1b[0m";
const color = stateColors[result.state] || "";
const rendered = [
` Path: ${options.projectPath}`,
` State: ${color}${result.state}${reset}`,
` Action: ${result.action}`,
` Details: ${result.details}`,
].join("\n");
writeOutput(options, rendered);
}
/** Pick the agent list for setup output from the CLI override or extracted facts. */
function getSetupAgentIds(options, facts) {
return options.agent
? [options.agent]
: facts.agents.map((af) => af.agent.id);
}
/** Print the banner that warns multi-agent setup output must stay in sync. */
function writeMultiAgentSyncBanner(withDivider) {
const lines = withDivider
? [...MULTI_AGENT_SYNC_BANNER, "", "---", ""]
: [...MULTI_AGENT_SYNC_BANNER, "", ""];
process.stdout.write(lines.join("\n"));
}
/** Handle the setup command: compose and render setup prompts per agent */
async function handleSetupCommand(options, auditReport, facts) {
const { composeSetup } = await import("./prompt/compose-setup.js");
const agentIds = getSetupAgentIds(options, facts);
if (agentIds.length === 0) {
throw new CLIError(`No agents detected. Use one of: ${validAgentFlags()}`, 1);
}
if (agentIds.length > 1) {
writeMultiAgentSyncBanner(true);
}
const parts = [];
for (const agentId of agentIds) {
const output = composeSetup(auditReport, facts, agentId);
if (output)
parts.push(output);
}
if (parts.length > 0) {
writeOutput(options, parts.join("\n\n---\n\n"));
}
}
/** Derive installer flags from the project's adoption state. */
function deriveInstallFlags(projectPath, agentId, options) {
if (options.shouldForce)
return [];
try {
const projectFS = createFS(projectPath);
const state = classifyProjectState(projectFS, agentId);
const flags = [];
if (!options.updateConfigVersion &&
(state.state === "outdated" || state.state === "v0.9")) {
flags.push("--update-config-version");
}
if (!options.cleanDeprecated && state.state === "v0.9") {
flags.push("--clean-deprecated");
}
return flags;
}
catch {
return [];
}
}
/** Build the user-supplied installer flag list for the bundled bash script. */
function collectInstallerFlags(options, agent) {
const flags = [];
if (options.shouldForce)
flags.push("--force");
if (options.updateConfigVersion)
flags.push("--update-config-version");
if (options.cleanDeprecated)
flags.push("--clean-deprecated");
flags.push(...deriveInstallFlags(options.projectPath, agent, options));
return flags;
}
function commitGuidanceInstallSummary(detection) {
if (detection.status === "insufficient-history") {
return detection.gitAvailable
? `stub generated from ${detection.total} commits`
: "stub generated because git history was unavailable";
}
return `${detection.status} guidance generated from ${detection.total} commits`;
}
/** Print commit-guide generation status only when install wrote new guidance. */
function emitCommitGuidanceInstallResult(projectPath) {
const result = ensureGitCommitInstructions(projectPath);
if (result.status !== "written" || result.detection === null)
return;
console.log("");
console.log("Git commit instructions:");
console.log(` ✓ ${result.path} (${commitGuidanceInstallSummary(result.detection)})`);
}
/** Handle deterministic install/update; spawns the bundled installer through the safe-exec gate and reports CLIError failures. */
async function handleInstallCommand(options) {
if (!options.agent) {
throw new CLIError(`install requires --agent. Use one of: ${validAgentFlags()}\n (--apply installs per-agent surfaces; each agent needs a separate run)`, 2);
}
if (options.output !== null) {
throw new CLIError("--output is not supported for install.", 2);
}
const invocation = buildInstallerInvocation({
scriptPath: getTemplatePath("workflow/install-goat-flow.sh"),
projectPath: options.projectPath,
agent: options.agent,
installerFlags: collectInstallerFlags(options, options.agent),
platform: process.platform,
});
if (!invocation.ok) {
throw new CLIError(invocation.error, 1);
}
const { spawnInheritedSync } = await import("./server/safe-exec.js");
const spawnSpec = buildInstallerSpawnSpec(invocation);
const result = spawnInheritedSync({
command: spawnSpec.command,
args: spawnSpec.args,
allowedBasenames: ["bash", "bash.exe"],
env: spawnSpec.env,
});
if (result.error) {
throw new CLIError(`Could not run installer with ${spawnSpec.command}: ${result.error.message}`, 1);
}
if (result.signal) {
throw new CLIError(`Installer terminated by signal ${result.signal}`, 1);
}
if (result.status !== 0) {
process.exitCode = result.status ?? 1;
return;
}
emitCommitGuidanceInstallResult(options.projectPath);
emitIndexGenerationInstallResult(options.projectPath);
}
/** Handle the removed info command; throws CLIError with the current audit replacement. */
function handleInfoCommand(options) {
// The subcommand is the first positional arg after 'info'.
// parseCLIArgs resolves projectPath to an absolute path, so extract the basename.
const sub = options.projectPath.split(/[/\\]/).pop() ?? "";
if (sub === "rubrics" || sub === "anti-patterns") {
throw new CLIError(`"info ${sub}" was removed. Use "audit" for setup validation or "audit --harness" for advisory scoring.`, 2);
}
throw new CLIError('Usage: goat-flow info <rubrics|anti-patterns>\n Both subcommands were removed in v1.1.0. Use "audit" instead.', 2);
}
/** Run the audit command: validate setup correctness and optionally check harness completeness. */
async function handleAuditCommand(options) {
const { createFS } = await import("./facts/fs.js");
const { runAudit } = await import("./audit/audit.js");
const { renderAuditText, renderAuditJson, renderAuditMarkdown, renderAuditSarif, } = await import("./audit/render.js");
const fs = createFS(options.projectPath);
const report = runAudit(fs, options.projectPath, {
agentFilter: options.agent ?? null,
harness: options.includeHarness,
checkDrift: options.checkDrift,
checkContent: options.checkContent,
// The deny-mechanism runtime smoke executes the target checkout's own hook
// code (configured launcher string and managed script) and runs by default.
// `--untrusted-target` keeps the deny check static for a checkout you do not
// trust; otherwise the property is omitted, leaving the default unchanged.
// (The dashboard separately audits selected targets at "static".)
...(options.untrustedTarget
? { denyMechanismEvidenceLevel: "static" }
: {}),
});
const reportForRender = options.auditDetails
? report
: stripAuditDetails(report);
let rendered;
if (options.format === "json") {
rendered = renderAuditJson(reportForRender);
}
else if (options.format === "markdown") {
rendered = renderAuditMarkdown(reportForRender);
}
else if (options.format === "sarif") {
rendered = renderAuditSarif(reportForRender);
}
else {
rendered = renderAuditText(reportForRender);
}
writeOutput(options, rendered);
if (report.status === "fail") {
process.exitCode = 1;
}
}
/**
* Run the quality command by delegating to the quality module with CLI-side dependencies injected.
* The error/output behaviour lives in the injected collaborators - CLIError for failures and
* writeOutput for results - so this wrapper only supplies them and forwards the parsed options.
*/
async function handleQualityCommand(options) {
await runQualityCommand(options, {
CLIError,
formatCandidacyArtifact,
validAgents,
writeOutput,
});
}
/** Handle the stats command: report learning-loop health (live counts, stale refs, freshness). */
async function handleStatsCommand(options) {
const { createFS } = await import("./facts/fs.js");
const { loadConfig } = await import("./config/reader.js");
const { extractFootgunFacts, extractLessonsFacts } = await import("./facts/shared/learning-loop.js");
const { buildStatsReport, checkStats, buildDecisionsSection } = await import("./stats/stats.js");
const { renderStatsText, renderStatsJson, renderStatsMarkdown, renderStatsCheckText, } = await import("./stats/render.js");
const { collectIndexFreshness } = await import("./stats/index-freshness.js");
const { resolveIndexBucketPaths } = await import("./learning-loop-index/parse-bucket.js");
const fs = createFS(options.projectPath);
const configState = loadConfig(options.projectPath, fs);
const report = buildStatsReport({
footguns: extractFootgunFacts(fs, configState),
lessons: extractLessonsFacts(fs, configState),
decisions: buildDecisionsSection(fs, configState.config.decisions.path),
indexes: collectIndexFreshness(fs, resolveIndexBucketPaths(configState.config)),
});
if (options.shouldCheck) {
const verdict = checkStats(report);
if (options.format === "json") {
writeOutput(options, JSON.stringify(verdict, null, 2));
}
else {
writeOutput(options, renderStatsCheckText(verdict).trimEnd());
}
if (verdict.status === "fail")
process.exitCode = 1;
return;
}
let rendered;
if (options.format === "json") {
rendered = renderStatsJson(report);
}
else if (options.format === "markdown") {
rendered = renderStatsMarkdown(report);
}
else {
rendered = renderStatsText(report);
}
writeOutput(options, rendered.trimEnd());
}
/**
* Handle `events tail`, reading the most recent local evidence-envelope events for the project.
* Throws a usage CLIError (exit 2) for any subcommand other than `tail`. Emits the events as a
* JSON array under `--format json`, otherwise one compact JSON object per line (JSONL) for piping.
*/
async function handleEventsCommand(options) {
if (options.eventsSubcommand !== "tail") {
throw new CLIError("Usage: goat-flow events tail [path] [--limit 20]", 2);
}
const { tailEvidenceEvents } = await import("./evidence/envelope.js");
const events = tailEvidenceEvents(options.projectPath, options.eventsLimit);
if (options.format === "json") {
writeOutput(options, JSON.stringify(events, null, 2));
return;
}
writeOutput(options, events.map((event) => JSON.stringify(event)).join("\n"));
}
/**
* Handle the manifest command: resolve + print the single-source-of-truth manifest.
* The function forks up front on `--check` because the two modes are genuinely different outputs,
* not formatting variants of one: `--check` is the CI gate that runs checkManifest and sets
* process.exitCode to 1 on drift (so the pipeline fails), while the default branch just loads and
* prints the resolved manifest with no exit-code side effect. They are kept in one handler so both
* honour the same `--format` flag, but the early return after the check branch is intentional - it
* avoids the printer ever running in CI mode.
*/
async function handleManifestCommand(options) {
const { loadManifest, checkManifest, renderManifestMarkdown } = await import("./manifest/manifest.js");
if (options.shouldCheck) {
const report = checkManifest();
let rendered;
if (options.format === "json") {
rendered = JSON.stringify(report, null, 2);
}
else {
const lines = [];
if (report.status === "pass") {
lines.push("Manifest check: PASS");
}
else {
lines.push("Manifest check: FAIL");
for (const f of report.findings) {
lines.push(` - [${f.rule}] ${f.message}`);
}
}
rendered = lines.join("\n");
}
writeOutput(options, rendered);
if (report.status === "fail")
process.exitCode = 1;
return;
}
const manifest = loadManifest();
if (options.format === "json") {
writeOutput(options, JSON.stringify(manifest, null, 2));
return;
}
writeOutput(options, renderManifestMarkdown(manifest));
}
/** Run the default `setup` command pipeline: facts + audit + compose. */
async function runSetupPipeline(options) {
const { createFS } = await import("./facts/fs.js");
const { runAudit } = await import("./audit/audit.js");
const { extractProjectFacts } = await import("./facts/orchestrator.js");
const { loadConfig } = await import("./config/reader.js");
const fs = createFS(options.projectPath);
const configState = loadConfig(options.projectPath, fs);
const facts = extractProjectFacts(fs, {
agentFilter: options.agent ?? null,
projectPath: options.projectPath,
configState,
});
const auditReport = runAudit(fs, options.projectPath, {
agentFilter: options.agent ?? null,
harness: false,
});
await handleSetupCommand(options, auditReport, facts);
}
/** Launch the web dashboard. */
async function runDashboardCommand(options) {
const { serveDashboard } = await import("./server/dashboard.js");
await serveDashboard({
projectPath: options.projectPath,
isDevMode: options.isDevMode,
});
}
const COMMAND_HANDLERS = {
menu: handleMenuCommand,
install: handleInstallCommand,
audit: handleAuditCommand,
quality: handleQualityCommand,
events: handleEventsCommand,
hooks: handleHooksCommand,
skill: handleSkillCommand,
manifest: handleManifestCommand,
stats: handleStatsCommand,
index: handleIndexCommand,
status: handleStatusCommand,
dashboard: runDashboardCommand,
info: handleInfoCommand,
};
/**
* Run `skill new`, scaffolding a skill/playbook from a description, draft, or interactive prompt.
* Throws a usage CLIError (exit 2) when the subcommand is not `new` or when the skill author
* reports a SkillNewInputError (bad/missing input); any other author error is rethrown unchanged.
* Emits a JSON candidacy/path/score summary under `--format json`, otherwise the author's text.
*/
async function handleSkillCommand(options) {
if (options.skillSubcommand !== "new") {
throw new CLIError('Usage: goat-flow skill new ["<description>" | --draft <path> | --interactive]', 2);
}
const { runSkillNew, SkillNewInputError } = await import("./skill-author.js");
let result;
try {
result = await runSkillNew({
description: options.skillDescription ?? undefined,
draftPath: options.skillDraftPath ?? undefined,
shouldUseInteractivePrompt: options.skillInteractive,
name: options.skillName ?? undefined,
shouldSkipConfirm: options.skillSkipConfirm,
projectRoot: options.projectPath,
});
}
catch (err) {
if (err instanceof SkillNewInputError) {
throw new CLIError(err.message, 2);
}
throw err;
}
if (options.format === "json") {
writeOutput(options, JSON.stringify({
candidacy: result.candidacy,
proposedPath: result.proposedPath,
written: result.written,
postScaffoldScore: result.postScaffoldScore ?? null,
}, null, 2));
return;
}
writeOutput(options, result.output.join("\n"));
}
/** Dispatch one parsed CLI command to its handler. */
export async function dispatchCommand(options) {
const handler = COMMAND_HANDLERS[options.command];
if (handler) {
await handler(options);
return;
}
if (options.shouldApply) {
await handleInstallCommand(options);
return;
}
// Remaining command: setup (uses audit + facts to compose setup guidance).
await runSetupPipeline(options);
}
//# sourceMappingURL=cli-handlers.js.map