/** * `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 { tool } from "ai"; import { z } from "zod"; import type { ToolContext, ToolDefinition } 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 ?? "", }; } } /// `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(ctx: ToolContext): ToolDefinition { return tool({ 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.", inputSchema: z.object({ path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(), }), execute: async (args): Promise => { const target = args.path ?? "."; const res = await runCph(["check", target], ctx.workspaceDir); return JSON.stringify({ exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, }); }, }); } /// `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(ctx: ToolContext): ToolDefinition { return tool({ description: "Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.", inputSchema: z.object({ target: z.string().describe("Build target, e.g. 'student', 'teacher'."), path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(), output: z.string().describe("Output file path (relative to workspace or absolute).").optional(), }), execute: async (args): Promise => { const target = args.path ?? "."; const out = args.output ?? `build/${args.target}.pdf`; const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir); return JSON.stringify({ exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, output: out, }); }, }); }