/** * `cph` subprocess tools — the Hub↔Courseware coupling point. * * The Hub agent does not parse engineering-file models in-process; it calls the * stable `cph` CLI (ADR-0016 version contract). `cph check` validates a * project; `cph build` renders a target artifact. Version compatibility is the * CLI's responsibility — it refuses incompatible `.cph-version` files with a * `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the * subprocess and returns its stdout/stderr/exit to the model. * * The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on * PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in * diagnostics are relative to the project root. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { RegisteredTool } from "./tools.js"; const execFileAsync = promisify(execFile); const CPH_BIN = process.env["CPH_BIN"] ?? "cph"; const DEFAULT_TIMEOUT_MS = 120_000; interface ExecResult { readonly exitCode: number; readonly stdout: string; readonly stderr: string; } async function runCph(args: readonly string[], workspaceDir: string): Promise { try { const { stdout, stderr } = await execFileAsync(CPH_BIN, args, { cwd: workspaceDir, timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, }); return { exitCode: 0, stdout, stderr }; } catch (e) { // execFile rejects on non-zero exit; the error carries stdout/stderr/code. const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string }; if (err.code === "ENOENT") { return { exitCode: 127, stdout: "", stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`, }; } return { exitCode: typeof err.code === "number" ? err.code : 1, stdout: err.stdout ?? "", stderr: err.stderr ?? "", }; } } interface CphCheckArgs { readonly path?: string; } /// `cph check ` — validate a curriculum engineering file. Returns the /// checker's diagnostics (stdout) for the model to act on. Non-zero exit means /// the file has error diagnostics (ADR-0010); the output is still returned, /// not thrown — the model needs to see the diagnostics to fix them. export function cphCheckTool(): RegisteredTool { return { spec: { name: "cph_check", description: "Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version…). Non-zero exit means error diagnostics present — read the output to fix them.", parameters: { type: "object", properties: { path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." }, }, }, }, async execute(args, ctx): Promise { const a = (args as CphCheckArgs) ?? {}; const target = a.path ?? "."; const res = await runCph(["check", target], ctx.workspaceDir); return JSON.stringify({ exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, }); }, }; } interface CphBuildArgs { readonly target: string; readonly path?: string; readonly output?: string; } /// `cph build --target -o ` — render a target artifact. The /// target (student/teacher/…) determines the build (ADR-0009). Output path /// is relative to the workspace unless absolute. export function cphBuildTool(): RegisteredTool { return { spec: { name: "cph_build", description: "Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.", parameters: { type: "object", properties: { target: { type: "string", description: "Build target, e.g. 'student', 'teacher'." }, path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." }, output: { type: "string", description: "Output file path (relative to workspace or absolute)." }, }, required: ["target"], }, }, async execute(args, ctx): Promise { const a = args as CphBuildArgs; const target = a.path ?? "."; const out = a.output ?? `build/${a.target}.pdf`; const res = await runCph(["build", target, "--target", a.target, "-o", out], ctx.workspaceDir); return JSON.stringify({ exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, output: out, }); }, }; }