#!/usr/bin/env node // SPDX-License-Identifier: MIT // Part of pi-steering. /** * `@cad0p/pi-steering` CLI. Two subcommands: * * pi-steering import-json [-o ] * Convert a v1 JSON config to the v2 TS config shape. * * pi-steering list [--format=text|json] * Resolve the two-layer config (project `/.pi/steering/` + * global `/steering/`) from the CWD and print the * effective plugins / rules / observers / disables. * * Exit codes: * 0 - success * 1 - invalid arguments / file read / parse error / `list` * surfaced one or more error-class diagnostics (CI-lint signal) * 2 - import-json conversion error ({@link FromJSONError}) */ import { readFile, writeFile } from "node:fs/promises"; import { FromJSONError, fromJSON } from "../compat.ts"; import { EVALUATOR_BUILTIN_TRACKERS } from "../evaluator.ts"; import { finalizePluginState } from "../internal/finalize-plugin-state.ts"; import { formatSingleLineDiagnostic, runMergerPipeline, } from "../internal/session-runtime.ts"; import { loadConfigs } from "../loader.ts"; import type { Observer, Rule, SteeringConfig, SteeringDiagnostic, TopLevelWhenClause, } from "../schema.ts"; /** * CLI entrypoint. Exported (not just `void main(...)` at module top) * so the test suite can exercise the argument parser without spawning * a subprocess. Real invocation goes through the bottom-of-file * bootstrap when this module is run as `node pi-steering.js`. */ export async function main(argv: string[]): Promise { const args = argv.slice(2); if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { printHelp(); return 0; } const [subcommand, ...rest] = args; if (subcommand === "import-json") { return runImportJson(rest); } if (subcommand === "list") { return runList(rest); } process.stderr.write(`pi-steering: unknown subcommand "${subcommand}"\n\n`); printHelp(); return 1; } function printHelp(): void { process.stdout.write(`pi-steering — tools for pi-steering USAGE pi-steering [options] SUBCOMMANDS import-json [-o ] Convert a v1 JSON steering config to v2 TS form. Writes to if specified, else stdout. See the README for the JSON-to-TS conversion surface and rejected features. list [--format=text|json] Load the effective config for the current directory (project layer at /.pi/steering/ + global layer at /steering/) and print the resolved plugins, rules, and observers, grouped by source. Useful for answering "which rules are active here?" without reading the config files by hand. OPTIONS -h, --help Show this help. `); } async function runImportJson(args: string[]): Promise { let inputPath: string | null = null; let outputPath: string | null = null; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "-o" || a === "--output") { const next = args[++i]; if (next === undefined) { process.stderr.write("pi-steering: -o requires an argument\n"); return 1; } outputPath = next; } else if (a !== undefined && a.startsWith("-")) { process.stderr.write(`pi-steering: unknown flag "${a}"\n`); return 1; } else if (inputPath === null && a !== undefined) { inputPath = a; } else { process.stderr.write("pi-steering: too many positional arguments\n"); return 1; } } if (inputPath === null) { process.stderr.write("pi-steering: import-json requires an input file\n"); return 1; } let raw: string; try { raw = await readFile(inputPath, "utf8"); } catch (err) { process.stderr.write( `pi-steering: cannot read ${inputPath}: ${ err instanceof Error ? err.message : String(err) }\n`, ); return 1; } let json: unknown; try { json = JSON.parse(raw); } catch (err) { process.stderr.write( `pi-steering: ${inputPath} is not valid JSON: ${ err instanceof Error ? err.message : String(err) }\n`, ); return 1; } let config: SteeringConfig; try { config = fromJSON(json); } catch (err) { if (err instanceof FromJSONError) { process.stderr.write( `pi-steering: conversion failed at ${err.path}: ${err.message}\n`, ); return 2; } throw err; } const output = renderConfig(config); if (outputPath !== null) { await writeFile(outputPath, output, "utf8"); process.stdout.write(`Wrote ${outputPath}\n`); } else { process.stdout.write(output); } return 0; } /** * Render a {@link SteeringConfig} as a v2 TS file using `defineConfig`. * Uses `JSON.stringify` for the config value since v1 rules only carry * string / number / boolean / array — JSON literal rendering is * semantically identical to the TS object literal. */ function renderConfig(config: SteeringConfig): string { const body = JSON.stringify(config, null, 2); return `// Generated by \`pi-steering import-json\`. // Edit freely — this is your steering config now. import { defineConfig } from "@cad0p/pi-steering"; export default defineConfig(${body}); `; } // --------------------------------------------------------------------------- // `list` subcommand // --------------------------------------------------------------------------- /** * Parse the `list` flag set and print the resolved config. * * Flags: * --format=text (default) * --format=json machine-readable JSON * -h / --help per-subcommand help */ async function runList(args: string[]): Promise { let format: "text" | "json" = "text"; for (const a of args) { if (a === "--help" || a === "-h") { printListHelp(); return 0; } if (a === "--format=text") { format = "text"; } else if (a === "--format=json") { format = "json"; } else if (a.startsWith("--format=")) { process.stderr.write( `pi-steering: unknown --format value "${a.slice("--format=".length)}"; use text|json\n`, ); return 1; } else { process.stderr.write(`pi-steering: unknown flag "${a}"\n`); return 1; } } // Load project layer (cwd) + global layer (agent dir) and merge. // CLI deliberately omits DEFAULT_PLUGINS / DEFAULT_RULES; runtime // injects them. let layers: readonly SteeringConfig[]; let loaderDiagnostics: readonly SteeringDiagnostic[] = []; try { ({ layers, diagnostics: loaderDiagnostics } = await loadConfigs( process.cwd(), )); } catch (err) { process.stderr.write( `pi-steering: failed to load config: ${ err instanceof Error ? err.message : String(err) }\n`, ); return 1; } // Surface loader-side diagnostics on stderr in the legacy shape so // users running `pi-steering list` against a tree with broken layers, // dual-form coexistence, stray files, or cross-layer collisions see // them — restores the pre-refactor visibility the loader's direct // `console.warn` calls used to provide. Track whether any error- // class diagnostic was emitted so the CLI can exit non-zero, giving // CI lint pipelines a binary signal that the config would refuse to // start in production. // // CLI deliberately renders errors inline rather than via the // aggregated thrown-Error form: the audience is a CI grep target, // not a human reading a single Error.message. let sawError = false; const recordDiagnostic = (d: SteeringDiagnostic) => { if (d.type === "error") sawError = true; process.stderr.write(`${formatSingleLineDiagnostic(d)}\n`); }; for (const d of loaderDiagnostics) { recordDiagnostic(d); } if (layers.length === 0) { if (format === "json") { process.stdout.write(`${JSON.stringify(emptyListJSON(), null, 2)}\n`); } else { process.stdout.write("No steering config found.\n"); } return sawError ? 1 : 0; } const { config, diagnostics: mergeAndResolveDiagnostics } = runCliMergeWithInfoCapture(layers); for (const d of mergeAndResolveDiagnostics) { recordDiagnostic(d); } if (format === "json") { process.stdout.write( `${JSON.stringify(renderListJSON(config), null, 2)}\n`, ); } else { process.stdout.write(renderListText(config)); } return sawError ? 1 : 0; } /** * CLI variant of the merge pipeline. Redirects `console.info` * breadcrumbs (disabled-plugin / disabled-rule / dropped-observer) * onto stderr so stdout stays clean for `--format=json`. Loads the * project + global layers and merges them inner-first (project wins), * then mirrors {@link buildSessionRuntime}'s `disabledRules` filter + * `finalizePluginState` so `pi-steering list` reports the same * observer-drop set production sees. */ function runCliMergeWithInfoCapture(layers: readonly SteeringConfig[]): { config: SteeringConfig; diagnostics: SteeringDiagnostic[]; } { const originalInfo = console.info; console.info = (...args: unknown[]) => { process.stderr.write(`${args.map((a) => String(a)).join(" ")}\n`); }; try { const { merged, resolved, diagnostics } = runMergerPipeline( layers, undefined, EVALUATOR_BUILTIN_TRACKERS, ); // Skipped on merge short-circuit; without resolved we can't // enumerate plugin-side observers. if (resolved !== null) { const userObservers = merged.observers ?? []; // Mirror the runtime's `disabledRules` filter (see // `buildSessionRuntime` in `internal/session-runtime.ts`) // before invoking `finalizePluginState`. Without this // filter, observers whose only consumer is a disabled rule // would appear consumed in the CLI but get dropped by the // runtime — a divergence between `pi-steering list` and // what production sees. const disabledRules = new Set(merged.disabledRules ?? []); const userRules = (merged.rules ?? []).filter( (r) => !disabledRules.has(r.name), ); finalizePluginState( userRules, resolved.rules, userObservers, resolved.observers, ); } return { config: merged, diagnostics }; } finally { console.info = originalInfo; } } function printListHelp(): void { process.stdout.write(`pi-steering list — show the resolved config USAGE pi-steering list [--format=text|json] Loads the project layer (/.pi/steering/) and the global layer (~/.pi/agent/steering/, or $PI_CODING_AGENT_DIR/steering) and prints the effective plugins, rules, and observers. Project layer wins on rule-name collision. FLAGS --format=text (default) human-readable grouped output --format=json machine-readable JSON -h, --help show this help EXIT CODES 0 resolved successfully (warnings, if any, are fail-soft) 1 one or more error-class diagnostics surfaced — production runtime would refuse to start on this config; CI lint pipelines can gate on this code EXAMPLES pi-steering list pi-steering list --format=json `); } /** * Curated mapping from a plugin's `name` to a short human-readable * source label (used in the `name [source]` header in text output). * Covers plugins shipped by this package. Unknown plugin names get * no bracket — the plugin name alone is enough for the user to * locate it. */ const KNOWN_PLUGIN_SOURCES: Record = { git: "pi-steering/plugins/git", }; /** * Render the config as JSON for machine consumption. * * Shape: * { * plugins: [{ name, source?, rules: [{ name, tool, when }], observers: [...] }], * userRules: [{ name, tool, when }], * userObservers: [{ name, writes }], * disabled: { rules: [...], plugins: [...] }, * defaultNoOverride: bool|null, * disableDefaults: bool|null * } */ function renderListJSON(config: SteeringConfig): unknown { const disabledSet = new Set(config.disabledRules ?? []); const disabledPluginsSet = new Set(config.disabledPlugins ?? []); const plugins = (config.plugins ?? []).map((p) => ({ name: p.name, ...(KNOWN_PLUGIN_SOURCES[p.name] !== undefined ? { source: KNOWN_PLUGIN_SOURCES[p.name] } : {}), ...(disabledPluginsSet.has(p.name) ? { disabled: true } : {}), rules: (p.rules ?? []).map((r) => ruleJSON(r, disabledSet)), observers: (p.observers ?? []).map((o) => observerJSON(o)), })); return { plugins, userRules: (config.rules ?? []).map((r) => ruleJSON(r, disabledSet)), userObservers: (config.observers ?? []).map((o) => observerJSON(o)), disabled: { rules: config.disabledRules ?? [], plugins: config.disabledPlugins ?? [], }, defaultNoOverride: config.defaultNoOverride ?? null, disableDefaults: config.disableDefaults ?? null, }; } function emptyListJSON(): unknown { return { plugins: [], userRules: [], userObservers: [], disabled: { rules: [], plugins: [] }, defaultNoOverride: null, disableDefaults: null, }; } function ruleJSON(r: Rule, disabledSet?: ReadonlySet): unknown { return { name: r.name, tool: r.tool, ...(r.when !== undefined ? { when: whenSummaryKeys(r.when) } : {}), ...(disabledSet?.has(r.name) ? { disabled: true } : {}), }; } function observerJSON(o: Observer): unknown { return { name: o.name, writes: o.writes ?? [], }; } /** * Render the config as a grouped text block. * * Shape mirrors ADR §16: * * Resolved config: 2 plugins, 4 rules, 1 observer. * * git [pi-steering/plugins/git] * no-main-commit bash when: branch * ... * * User (project + global): * (none) * * Disabled: (none) */ function renderListText(config: SteeringConfig): string { const plugins = config.plugins ?? []; const userRules = config.rules ?? []; const userObservers = config.observers ?? []; const disabled = config.disabledRules ?? []; const disabledPlugins = config.disabledPlugins ?? []; const disabledSet = new Set(disabled); const disabledPluginsSet = new Set(disabledPlugins); const totalRules = plugins.reduce((n, p) => n + (p.rules?.length ?? 0), 0) + userRules.length; const totalObservers = plugins.reduce((n, p) => n + (p.observers?.length ?? 0), 0) + userObservers.length; const lines: string[] = []; lines.push( `Resolved config: ${plugins.length} ${plural("plugin", plugins.length)}, ${totalRules} ${plural("rule", totalRules)}, ${totalObservers} ${plural("observer", totalObservers)}.`, ); lines.push(""); // Per-plugin block. for (const plugin of plugins) { const source = KNOWN_PLUGIN_SOURCES[plugin.name]; const pluginDisabled = disabledPluginsSet.has(plugin.name); const suffix = pluginDisabled ? " (disabled)" : ""; const header = source ? `${plugin.name} [${source}]${suffix}` : `${plugin.name}${suffix}`; lines.push(header); renderRuleLines(plugin.rules ?? [], lines, disabledSet); renderObserverLines(plugin.observers ?? [], lines); lines.push(""); } // User block. lines.push("User (project + global):"); if (userRules.length === 0 && userObservers.length === 0) { lines.push(" (none)"); } else { renderRuleLines(userRules, lines, disabledSet); renderObserverLines(userObservers, lines); } lines.push(""); // Disabled block. if (disabled.length === 0 && disabledPlugins.length === 0) { lines.push("Disabled: (none)"); } else { if (disabled.length > 0) { lines.push(`Disabled rules: ${disabled.join(", ")}`); } if (disabledPlugins.length > 0) { lines.push(`Disabled plugins: ${disabledPlugins.join(", ")}`); } } lines.push(""); return lines.join("\n"); } function renderRuleLines( rules: readonly Rule[], lines: string[], disabledSet?: ReadonlySet, ): void { if (rules.length === 0) return; const nameWidth = Math.max(...rules.map((r) => r.name.length), 20); for (const r of rules) { const tools = r.tool.padEnd(5); const whenSummary = r.when ? ` when: ${whenSummaryKeys(r.when)}` : ""; const disabledSuffix = disabledSet?.has(r.name) ? " (disabled)" : ""; lines.push( ` ${r.name.padEnd(nameWidth)} ${tools}${whenSummary}${disabledSuffix}`, ); } } function renderObserverLines( observers: readonly Observer[], lines: string[], ): void { if (observers.length === 0) return; for (const o of observers) { const writes = o.writes && o.writes.length > 0 ? ` writes: ${o.writes.join(", ")}` : ""; lines.push(` observer: ${o.name}${writes}`); } } /** * Compact summary of a `TopLevelWhenClause`. Returns a comma-separated list * of keys (e.g. `branch, cwd`). Built-in keys get special labels so * the output is informative without dumping full predicate values: * - `happened` becomes `happened:` * - `not` becomes `not:...` * - `condition` stays `condition` * - plugin predicates just show the key name (`branch`, `upstream`, …). */ function whenSummaryKeys(when: TopLevelWhenClause): string { const parts: string[] = []; for (const key of Object.keys(when)) { if (key === "happened") { const happened = when.happened; if (happened !== undefined) { parts.push(`happened:${happened.event}`); } else { parts.push("happened"); } } else if (key === "not") { parts.push("not:..."); } else { // Everything else (cwd, condition, plugin predicates) renders // as its key name — the source file is the canonical place // to look up the full value. parts.push(key); } } return parts.join(", "); } function plural(word: string, n: number): string { return n === 1 ? word : `${word}s`; } // Bootstrap: only invoke `main` when this module is the entry point, // not when imported for testing. `import.meta.url` matches `argv[1]` // when run as `node pi-steering.js`. const entry = process.argv[1]; if (entry !== undefined && import.meta.url === `file://${entry}`) { void main(process.argv).then((code) => { process.exit(code); }); }