diff --git a/docs/harness-configuration.md b/docs/harness-configuration.md new file mode 100644 index 0000000..d455ba5 --- /dev/null +++ b/docs/harness-configuration.md @@ -0,0 +1,129 @@ +# Harness configuration projection + +Canonfig can compile one project-local `.canonfig/` source into native +configuration for multiple AI development harnesses. This feature is separate +from Source/Follower Machine Profile synchronization: it does not change the +published profile contract, transport, synchronization planner, recovery, or +AgentResolution runtime. + +## Canonical layout + +```text +.canonfig/ + harness.yaml + instructions/ + AGENTS.md + rules/ + skills/ + hooks/ + agents/ + commands/ +``` + +`harness.yaml`, `harness.yml`, and `harness.json` are accepted. Generated-file +ownership is stored in `.canonfig/.harness-state.json`; it contains hashes and +cleanup metadata, never credential values. + +## Commands + +```bash +canonfig harness init +canonfig harness validate +canonfig harness targets +canonfig harness plan +canonfig harness diff +canonfig harness apply +canonfig harness status +canonfig harness clean +canonfig harness doctor +``` + +Use repeatable `--target ` or comma-separated `--targets ` to select +specific harnesses. `--strict` rejects mappings classified as `shim`, `lossy`, +or `unsupported`. `--force` is required to take ownership of an existing native +entry or externally edited generated file. + +## Target identifiers + +| Identifier | Harness | +| --- | --- | +| `codex` | OpenAI Codex | +| `claude-code` | Claude Code | +| `amp` | Amp | +| `oh-my-pi` | Oh My Pi | +| `pi` | Pi Coding Agent | +| `factory-droid` | Factory Droid CLI | +| `cursor` | Cursor Agent CLI | +| `devin` | Devin CLI / Devin Local | +| `opencode` | OpenCode | +| `grok-build` | Grok Build CLI | +| `antigravity` | Google Antigravity CLI | +| `copilot-cli` | GitHub Copilot CLI | + +Each adapter declares a verification date, documentation references, executable +probes, feature support levels, and target-specific notes. Adapter code is pure: +it converts the canonical model into desired artifacts and diagnostics. A +shared planner owns collision detection, external-edit detection, path safety, +atomic writes, cleanup, and idempotence. + +## Safety and ownership + +- Native files and keys not owned by Canonfig are preserved. +- Existing conflicting keys become plan conflicts unless `--force` is explicit. +- A generated replacement edited outside Canonfig is never overwritten silently. +- Generated paths are constrained to the repository root, including symlink + resolution. +- MCP secrets remain symbolic environment references. +- Executable hook and plugin shims are shown in the plan before they are written. +- `clean` removes only artifacts represented in the ownership state. + +## Canonical example + +```yaml +version: 1 +project: + name: example + +targets: + codex: + enabled: true + options: {} + claude-code: + enabled: true + options: {} + +instructions: + root: instructions/AGENTS.md + rules: + - id: frontend + file: rules/frontend.md + paths: + - apps/web/** + activation: path + description: Frontend rules + +skills: + roots: + - skills + +mcp: + servers: + docs: + transport: streamable-http + url: https://example.invalid/mcp + headers: + Authorization: + fromEnv: DOCS_MCP_TOKEN + +hooks: [] +agents: [] +commands: [] +permissions: + rules: [] +extensions: {} +``` + +The canonical model intentionally carries semantic features rather than raw +native file shapes. When a harness changes, its adapter and fixtures can be +updated without changing the loader, planner, ownership model, or other +adapters. diff --git a/src/harness-configuration/adapters/amp.ts b/src/harness-configuration/adapters/amp.ts new file mode 100644 index 0000000..0f53b01 --- /dev/null +++ b/src/harness-configuration/adapters/amp.ts @@ -0,0 +1,115 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + commandSkillArtifacts, + enabledHooks, + hasEnabledMcpServers, + ruleDocuments, + ruleMarkdown, + skillArtifacts, + standardMcpMap, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; +import { AMP_PLUGIN_EVENT_MAP, ampPluginSource } from "../templates/runtime.ts"; + +const AMP_PLUGIN_EVENTS = new Set(Object.keys(AMP_PLUGIN_EVENT_MAP)); + +export const ampAdapter: HarnessAdapter = { + descriptor: descriptor( + "amp", + "Amp", + ["amp"], + [ + "https://ampcode.com/manual", + "https://ampcode.com/manual#mcp", + "https://ampcode.com/manual#agent-skills", + "https://ampcode.com/manual#plugins", + ], + { + instructions: "portable", + rules: "translated", + skills: "portable", + mcp: "native", + hooks: "shim", + agents: "translated", + commands: "translated", + }, + [ + "Hooks and custom subagents compile into an Amp TypeScript plugin.", + "Commands compile to Agent Skills so they remain invokable without relying on an unstable command manifest.", + ], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + if (hasEnabledMcpServers(context)) { + diagnostics.push(...standardMcpProjectionDiagnostics(context, "amp", false)); + artifacts.push({ + kind: "json", + path: ".amp/settings.json", + owner: "amp", + operations: [{ + kind: "managed-map", + path: ["amp.mcpServers"], + entries: standardMcpMap(context, false), + collision: "error", + }], + }); + } + + const agents = await agentDocuments(context); + const hooks = enabledHooks(context); + const supportedHooks = hooks.filter((hook) => AMP_PLUGIN_EVENTS.has(hook.event)); + for (const hook of hooks) { + if (!AMP_PLUGIN_EVENTS.has(hook.event)) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "amp", + message: `Amp's generated plugin cannot map hook event ${hook.event}; it was skipped.`, + }); + } + } + if (supportedHooks.length > 0 || agents.length > 0) { + artifacts.push({ + kind: "replace", + path: ".amp/plugins/canonfig.ts", + owner: "amp", + content: ampPluginSource( + supportedHooks, + agents.map(({ agent, content }) => ({ agent, content, tools: nativeTools("amp", agent) })), + ), + }); + } + + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ + kind: "replace", + path: `.amp/rules/${rule.id}.md`, + owner: "amp", + content: ruleMarkdown(rule, content), + }); + } + + const commonSkillPaths = new Set( + (await skillArtifacts(context, ".agents/skills", "common")).map((artifact) => artifact.path), + ); + for (const artifact of await commandSkillArtifacts(context, ".agents/skills", "amp")) { + if (commonSkillPaths.has(artifact.path)) { + diagnostics.push({ + level: "error", + code: "TRANSLATED_SKILL_COLLISION", + target: "amp", + path: artifact.path, + message: `Amp command output collides with a canonical skill at ${artifact.path}; rename the skill or command.`, + }); + } else { + artifacts.push(artifact); + } + } + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/antigravity.ts b/src/harness-configuration/adapters/antigravity.ts new file mode 100644 index 0000000..eb0422c --- /dev/null +++ b/src/harness-configuration/adapters/antigravity.ts @@ -0,0 +1,70 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentSkillArtifacts, + antigravityHooks, + antigravityMcpMap, + commandSkillArtifacts, + enabledHooks, + hasEnabledMcpServers, + ruleDocuments, + ruleMarkdown, + skillArtifacts, +} from "./shared.ts"; + +export const antigravityAdapter: HarnessAdapter = { + descriptor: descriptor( + "antigravity", + "Google Antigravity CLI", + ["agy"], + ["https://antigravity.google/docs/mcp", "https://antigravity.google/docs/hooks", "https://antigravity.google/docs/gcli-migration"], + { instructions: "portable", rules: "native", skills: "portable", mcp: "native", hooks: "native", agents: "translated", commands: "translated" }, + ["Canonical agents and commands compile to Agent Skills; Antigravity exposes subagents separately from portable agent manifests."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + if (hasEnabledMcpServers(context)) { + artifacts.push({ + kind: "json", path: ".agents/mcp_config.json", owner: "antigravity", + operations: [{ kind: "managed-map", path: ["mcpServers"], entries: antigravityMcpMap(context), collision: "error" }], + }); + } + if (enabledHooks(context).length > 0) { + const compiled = antigravityHooks(context); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.entries).length > 0) { + artifacts.push({ + kind: "json", path: ".agents/hooks.json", owner: "antigravity", + operations: [{ kind: "managed-map", path: [], entries: compiled.entries, collision: "error" }], + }); + } + } + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ kind: "replace", path: `.agents/rules/${rule.id}.md`, owner: "antigravity", content: ruleMarkdown(rule, content, { trigger: rule.paths.length ? "glob" : "always" }) }); + } + + const commonSkills = await skillArtifacts(context, ".agents/skills", "common"); + artifacts.push(...commonSkills); + const occupiedSkillPaths = new Set(commonSkills.map((artifact) => artifact.path)); + const translatedSkills = [ + ...await agentSkillArtifacts(context, ".agents/skills", "antigravity"), + ...await commandSkillArtifacts(context, ".agents/skills", "antigravity"), + ]; + for (const artifact of translatedSkills) { + if (occupiedSkillPaths.has(artifact.path)) { + diagnostics.push({ + level: "error", + code: "TRANSLATED_SKILL_COLLISION", + target: "antigravity", + path: artifact.path, + message: `Antigravity translated skill output collides at ${artifact.path}; rename the canonical skill, agent, or command.`, + }); + continue; + } + occupiedSkillPaths.add(artifact.path); + artifacts.push(artifact); + } + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/claude.ts b/src/harness-configuration/adapters/claude.ts new file mode 100644 index 0000000..5a76728 --- /dev/null +++ b/src/harness-configuration/adapters/claude.ts @@ -0,0 +1,65 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + agentMarkdown, + claudeStyleHooks, + commandDocuments, + commandMarkdown, + enabledHooks, + enabledMcpServerEntries, + ruleDocuments, + ruleMarkdown, + skillArtifacts, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; + +export const claudeAdapter: HarnessAdapter = { + descriptor: descriptor( + "claude-code", + "Claude Code", + ["claude"], + ["https://docs.anthropic.com/en/docs/claude-code/settings", "https://docs.anthropic.com/en/docs/claude-code/hooks"], + { instructions: "translated", rules: "native", skills: "native", mcp: "native", hooks: "native", agents: "native", commands: "native" }, + ["CLAUDE.md is generated as a small bridge to the canonical AGENTS.md."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = [{ + kind: "managed-text", path: "CLAUDE.md", owner: "claude-code", marker: "instructions-import", + comments: "html", placement: "start", content: "@AGENTS.md", + }]; + const diagnostics: Diagnostic[] = standardMcpProjectionDiagnostics(context, "claude-code"); + + artifacts.push(...await skillArtifacts(context, ".claude/skills", "claude-code")); + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ kind: "replace", path: `.claude/rules/${rule.id}.md`, owner: "claude-code", content: ruleMarkdown(rule, content, rule.paths.length ? { paths: rule.paths } : {}) }); + } + if (enabledHooks(context).length > 0) { + const compiled = claudeStyleHooks(context); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", path: ".claude/settings.json", owner: "claude-code", + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + }); + } + } + for (const { agent, content } of await agentDocuments(context)) { + const tools = nativeTools("claude-code", agent); + if (agent.tools.includes("mcp")) { + tools.push(...enabledMcpServerEntries(context).map(([name]) => `mcp__${name}`)); + } + artifacts.push({ + kind: "replace", + path: `.claude/agents/${agent.id}.md`, + owner: "claude-code", + content: agentMarkdown(agent, content, [...new Set(tools)]), + }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.claude/commands/${command.id}.md`, owner: "claude-code", content: commandMarkdown(command, content) }); + } + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/codex.ts b/src/harness-configuration/adapters/codex.ts new file mode 100644 index 0000000..8b6ff4d --- /dev/null +++ b/src/harness-configuration/adapters/codex.ts @@ -0,0 +1,82 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + CODEX_EVENT_MAP, + codexMcpDiagnostics, + commandSkillArtifacts, + enabledHooks, + mcpToml, + claudeStyleHooks, +} from "./shared.ts"; + +function tomlString(value: string): string { return JSON.stringify(value); } +function tomlMultiline(value: string): string { + const escaped = value.replaceAll("\\", "\\\\").replaceAll('"""', '\\"\\"\\"'); + return `"""\n${escaped.trim()}\n"""`; +} + +export const codexAdapter: HarnessAdapter = { + descriptor: descriptor( + "codex", + "OpenAI Codex", + ["codex"], + [ + "https://developers.openai.com/codex/config-reference", + "https://developers.openai.com/codex/subagents", + "https://developers.openai.com/codex/hooks", + ], + { + instructions: "portable", + rules: "translated", + skills: "portable", + mcp: "native", + hooks: "native", + agents: "native", + commands: "translated", + }, + ["Commands compile to Agent Skills because Codex uses skills as the portable command surface."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + const mcp = mcpToml(context); + if (mcp) { + diagnostics.push(...codexMcpDiagnostics(context)); + artifacts.push({ + kind: "toml", path: ".codex/config.toml", owner: "codex", + blocks: [{ marker: "mcp-servers", content: mcp }], + description: "Codex project MCP servers", + }); + } + + if (enabledHooks(context).length > 0) { + const compiled = claudeStyleHooks(context, CODEX_EVENT_MAP); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", path: ".codex/hooks.json", owner: "codex", + rootDefaults: { version: 1 }, + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + description: "Codex lifecycle hooks", + }); + } + } + + for (const { agent, content } of await agentDocuments(context)) { + const lines = [ + `name = ${tomlString(agent.id)}`, + `description = ${tomlString(agent.description)}`, + ...(agent.model === "inherit" ? [] : [`model = ${tomlString(agent.model)}`]), + `sandbox_mode = ${tomlString(agent.writable ? "workspace-write" : "read-only")}`, + `developer_instructions = ${tomlMultiline(content)}`, + "", + ]; + artifacts.push({ kind: "replace", path: `.codex/agents/${agent.id}.toml`, owner: "codex", content: lines.join("\n") }); + } + + artifacts.push(...await commandSkillArtifacts(context, ".codex/skills", "codex")); + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/copilot.ts b/src/harness-configuration/adapters/copilot.ts new file mode 100644 index 0000000..c135a9a --- /dev/null +++ b/src/harness-configuration/adapters/copilot.ts @@ -0,0 +1,99 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + commandSkillArtifacts, + copilotHooks, + enabledHooks, + ruleDocuments, + skillArtifacts, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; +import { markdownWithFrontmatter } from "../core/frontmatter.ts"; + +export const copilotAdapter: HarnessAdapter = { + descriptor: descriptor( + "copilot-cli", + "GitHub Copilot CLI", + ["copilot"], + [ + "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions", + "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills", + "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-hooks", + "https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot", + ], + { + instructions: "portable", + rules: "native", + skills: "native", + mcp: "portable", + hooks: "native", + agents: "native", + commands: "translated", + }, + ["The shared repository .mcp.json is used for MCP; commands compile to Agent Skills."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = standardMcpProjectionDiagnostics(context, "copilot-cli"); + + const commonSkills = await skillArtifacts(context, ".github/skills", "copilot-cli"); + artifacts.push(...commonSkills); + const occupiedSkillPaths = new Set(commonSkills.map((artifact) => artifact.path)); + if (enabledHooks(context).length > 0) { + const compiled = copilotHooks(context); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", + path: ".github/hooks/canonfig.json", + owner: "copilot-cli", + rootDefaults: { version: 1 }, + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + }); + } + } + + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ + kind: "replace", + path: `.github/instructions/${rule.id}.instructions.md`, + owner: "copilot-cli", + content: markdownWithFrontmatter({ + description: rule.description ?? `Canonfig rule: ${rule.id}`, + applyTo: rule.paths.length > 0 ? rule.paths.join(",") : "**", + }, content), + }); + } + for (const { agent, content } of await agentDocuments(context)) { + artifacts.push({ + kind: "replace", + path: `.github/agents/${agent.id}.agent.md`, + owner: "copilot-cli", + content: markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + ...(agent.model === "inherit" ? {} : { model: agent.model }), + tools: nativeTools("copilot-cli", agent), + }, content), + }); + } + for (const artifact of await commandSkillArtifacts(context, ".github/skills", "copilot-cli")) { + if (occupiedSkillPaths.has(artifact.path)) { + diagnostics.push({ + level: "error", + code: "TRANSLATED_SKILL_COLLISION", + target: "copilot-cli", + path: artifact.path, + message: `Copilot command output collides with a canonical skill at ${artifact.path}; rename the skill or command.`, + }); + continue; + } + occupiedSkillPaths.add(artifact.path); + artifacts.push(artifact); + } + + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/cursor.ts b/src/harness-configuration/adapters/cursor.ts new file mode 100644 index 0000000..707d33b --- /dev/null +++ b/src/harness-configuration/adapters/cursor.ts @@ -0,0 +1,92 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + agentMarkdown, + commandDocuments, + commandMarkdown, + cursorHooks, + enabledHooks, + hasEnabledMcpServers, + jsonMcpArtifact, + ruleDocuments, + skillArtifacts, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; +import { markdownWithFrontmatter } from "../core/frontmatter.ts"; + +export const cursorAdapter: HarnessAdapter = { + descriptor: descriptor( + "cursor", + "Cursor", + ["cursor-agent", "agent"], + [ + "https://cursor.com/docs/cli/reference/configuration", + "https://cursor.com/docs/context/rules", + "https://cursor.com/docs/skills", + "https://cursor.com/docs/agent/hooks", + ], + { + instructions: "portable", + rules: "native", + skills: "native", + mcp: "native", + hooks: "native", + agents: "native", + commands: "native", + }, + ["Hook event availability is Cursor-version dependent; unsupported canonical events produce diagnostics."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + artifacts.push(...await skillArtifacts(context, ".cursor/skills", "cursor")); + if (hasEnabledMcpServers(context)) { + diagnostics.push(...standardMcpProjectionDiagnostics(context, "cursor")); + artifacts.push(jsonMcpArtifact(".cursor/mcp.json", "cursor", context)); + } + + if (enabledHooks(context).length > 0) { + const compiled = cursorHooks(context); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", + path: ".cursor/hooks.json", + owner: "cursor", + rootDefaults: { version: 1 }, + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + }); + } + } + + for (const { rule, content } of await ruleDocuments(context)) { + const activation = rule.activation ?? (rule.paths.length > 0 ? "path" : "always"); + artifacts.push({ + kind: "replace", + path: `.cursor/rules/${rule.id}.mdc`, + owner: "cursor", + content: markdownWithFrontmatter({ + description: rule.description ?? `Canonfig rule: ${rule.id}`, + globs: rule.paths.join(","), + alwaysApply: activation === "always", + }, content), + }); + } + for (const { agent, content } of await agentDocuments(context)) { + artifacts.push({ + kind: "replace", + path: `.cursor/agents/${agent.id}.md`, + owner: "cursor", + content: agentMarkdown(agent, content, nativeTools("cursor", agent)), + }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.cursor/commands/${command.id}.md`, owner: "cursor", content: commandMarkdown(command, content) }); + } + + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/descriptor.ts b/src/harness-configuration/adapters/descriptor.ts new file mode 100644 index 0000000..0554572 --- /dev/null +++ b/src/harness-configuration/adapters/descriptor.ts @@ -0,0 +1,20 @@ +import type { Feature, HarnessDescriptor, SupportLevel, TargetId } from "../core/types.ts"; + +export function descriptor( + id: TargetId, + name: string, + executables: readonly string[], + docs: readonly string[], + capabilities: Partial>, + notes: readonly string[] = [], + verifiedAt = "2026-08-18", +): HarnessDescriptor { + return { + id, name, executables, docs, verifiedAt, + capabilities: { + instructions: "portable", rules: "translated", skills: "portable", mcp: "native", + hooks: "native", agents: "native", commands: "native", permissions: "unsupported", ...capabilities, + }, + ...(notes.length ? { notes } : {}), + }; +} diff --git a/src/harness-configuration/adapters/devin.ts b/src/harness-configuration/adapters/devin.ts new file mode 100644 index 0000000..773b58d --- /dev/null +++ b/src/harness-configuration/adapters/devin.ts @@ -0,0 +1,91 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentSkillArtifacts, + claudeStyleHooks, + commandSkillArtifacts, + DEVIN_EVENT_MAP, + enabledHooks, + hasEnabledMcpServers, + jsonMcpArtifact, + ruleDocuments, + ruleMarkdown, + skillArtifacts, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; + +export const devinAdapter: HarnessAdapter = { + descriptor: descriptor( + "devin", + "Devin CLI / Devin Local", + ["devin"], + [ + "https://docs.devin.ai/cli/extensibility/configuration", + "https://docs.devin.ai/cli/extensibility/rules", + "https://docs.devin.ai/cli/extensibility/mcp", + "https://docs.devin.ai/cli/extensibility/hooks", + "https://docs.devin.ai/cli/extensibility/skills", + ], + { + instructions: "portable", + rules: "native", + skills: "native", + mcp: "native", + hooks: "native", + agents: "translated", + commands: "translated", + }, + ["Canonical agents and commands compile to Devin-discoverable Agent Skills."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + const skills = await skillArtifacts(context, ".devin/skills", "devin"); + artifacts.push(...skills); + if (hasEnabledMcpServers(context)) { + diagnostics.push(...standardMcpProjectionDiagnostics(context, "devin")); + artifacts.push(jsonMcpArtifact(".devin/mcp.json", "devin", context)); + } + + if (enabledHooks(context).length > 0) { + const compiled = claudeStyleHooks(context, DEVIN_EVENT_MAP); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", + path: ".devin/hooks.v1.json", + owner: "devin", + rootDefaults: { version: 1 }, + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + }); + } + } + + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ kind: "replace", path: `.devin/rules/${rule.id}.md`, owner: "devin", content: ruleMarkdown(rule, content) }); + } + + const occupiedSkillPaths = new Set(skills.map((artifact) => artifact.path)); + const translated = [ + ...await agentSkillArtifacts(context, ".devin/skills", "devin"), + ...await commandSkillArtifacts(context, ".devin/skills", "devin"), + ]; + for (const artifact of translated) { + if (occupiedSkillPaths.has(artifact.path)) { + diagnostics.push({ + level: "error", + code: "TRANSLATED_SKILL_COLLISION", + target: "devin", + path: artifact.path, + message: `Devin translated skill output collides with another skill at ${artifact.path}; rename the canonical skill, agent, or command.`, + }); + } else { + occupiedSkillPaths.add(artifact.path); + artifacts.push(artifact); + } + } + + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/droid.ts b/src/harness-configuration/adapters/droid.ts new file mode 100644 index 0000000..be4068d --- /dev/null +++ b/src/harness-configuration/adapters/droid.ts @@ -0,0 +1,51 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + agentMarkdown, + claudeStyleHooks, + commandDocuments, + commandMarkdown, + enabledHooks, + hasEnabledMcpServers, + jsonMcpArtifact, + skillArtifacts, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; + +export const droidAdapter: HarnessAdapter = { + descriptor: descriptor( + "factory-droid", + "Factory Droid CLI", + ["droid"], + ["https://docs.factory.ai/harness/hooks", "https://docs.factory.ai/harness/mcp", "https://docs.factory.ai/droid-cli/settings"], + { rules: "portable", skills: "native" }, + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + artifacts.push(...await skillArtifacts(context, ".factory/skills", "factory-droid")); + if (hasEnabledMcpServers(context)) { + diagnostics.push(...standardMcpProjectionDiagnostics(context, "factory-droid")); + artifacts.push(jsonMcpArtifact(".factory/mcp.json", "factory-droid", context)); + } + if (enabledHooks(context).length > 0) { + const compiled = claudeStyleHooks(context); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", path: ".factory/hooks.json", owner: "factory-droid", + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + }); + } + } + for (const { agent, content } of await agentDocuments(context)) { + artifacts.push({ kind: "replace", path: `.factory/droids/${agent.id}.md`, owner: "factory-droid", content: agentMarkdown(agent, content, nativeTools("factory-droid", agent)) }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.factory/commands/${command.id}.md`, owner: "factory-droid", content: commandMarkdown(command, content) }); + } + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/grok.ts b/src/harness-configuration/adapters/grok.ts new file mode 100644 index 0000000..abd1cf1 --- /dev/null +++ b/src/harness-configuration/adapters/grok.ts @@ -0,0 +1,66 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + agentMarkdown, + claudeStyleHooks, + commandDocuments, + commandMarkdown, + enabledHooks, + GROK_EVENT_MAP, + grokMcpToml, + ruleDocuments, + skillArtifacts, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; + +export const grokAdapter: HarnessAdapter = { + descriptor: descriptor( + "grok-build", + "Grok Build CLI", + ["grok"], + ["https://docs.x.ai/build/cli/reference", "https://docs.x.ai/build/features/skills-plugins-marketplaces", "https://docs.x.ai/build/features/mcp-servers", "https://docs.x.ai/build/features/hooks"], + { instructions: "portable", rules: "portable", skills: "native", mcp: "native", hooks: "native", agents: "native", commands: "native" }, + ["Project hooks require Grok workspace hook trust."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + artifacts.push(...await skillArtifacts(context, ".grok/skills", "grok-build")); + + const mcp = grokMcpToml(context); + if (mcp) artifacts.push({ kind: "toml", path: ".grok/config.toml", owner: "grok-build", blocks: [{ marker: "mcp-servers", content: mcp }] }); + + if (enabledHooks(context).length > 0) { + const compiled = claudeStyleHooks(context, GROK_EVENT_MAP); + diagnostics.push(...compiled.diagnostics); + if (Object.keys(compiled.hooks).length > 0) { + artifacts.push({ + kind: "json", path: ".grok/hooks/canonfig.json", owner: "grok-build", rootDefaults: { version: 1 }, + operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }], + }); + } + } + + for (const { agent, content } of await agentDocuments(context)) { + artifacts.push({ kind: "replace", path: `.grok/agents/${agent.id}.md`, owner: "grok-build", content: agentMarkdown(agent, content, nativeTools("grok-build", agent)) }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.grok/commands/${command.id}.md`, owner: "grok-build", content: commandMarkdown(command, content) }); + } + for (const { rule, content } of await ruleDocuments(context)) { + if (rule.paths.length > 0) { + diagnostics.push({ + level: "warning", + code: "RULE_SCOPE_LOST", + target: "grok-build", + path: `.canonfig/${rule.file}`, + message: `Grok project rules cannot preserve Canonfig's path scope for ${rule.id}; the native rule file was skipped and the scoped AGENTS.md bridge remains authoritative.`, + }); + continue; + } + artifacts.push({ kind: "replace", path: `.grok/rules/${rule.id}.md`, owner: "grok-build", content }); + } + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/index.ts b/src/harness-configuration/adapters/index.ts new file mode 100644 index 0000000..9dbae3e --- /dev/null +++ b/src/harness-configuration/adapters/index.ts @@ -0,0 +1,41 @@ +export { codexAdapter } from "./codex.ts"; +export { claudeAdapter } from "./claude.ts"; +export { ampAdapter } from "./amp.ts"; +export { ompAdapter } from "./omp.ts"; +export { piAdapter } from "./pi.ts"; +export { droidAdapter } from "./droid.ts"; +export { cursorAdapter } from "./cursor.ts"; +export { devinAdapter } from "./devin.ts"; +export { opencodeAdapter } from "./opencode.ts"; +export { grokAdapter } from "./grok.ts"; +export { antigravityAdapter } from "./antigravity.ts"; +export { copilotAdapter } from "./copilot.ts"; + +import type { HarnessAdapter } from "../core/types.ts"; +import { codexAdapter } from "./codex.ts"; +import { claudeAdapter } from "./claude.ts"; +import { ampAdapter } from "./amp.ts"; +import { ompAdapter } from "./omp.ts"; +import { piAdapter } from "./pi.ts"; +import { droidAdapter } from "./droid.ts"; +import { cursorAdapter } from "./cursor.ts"; +import { devinAdapter } from "./devin.ts"; +import { opencodeAdapter } from "./opencode.ts"; +import { grokAdapter } from "./grok.ts"; +import { antigravityAdapter } from "./antigravity.ts"; +import { copilotAdapter } from "./copilot.ts"; + +export const BUILTIN_ADAPTERS: readonly HarnessAdapter[] = [ + codexAdapter, + claudeAdapter, + ampAdapter, + ompAdapter, + piAdapter, + droidAdapter, + cursorAdapter, + devinAdapter, + opencodeAdapter, + grokAdapter, + antigravityAdapter, + copilotAdapter, +]; diff --git a/src/harness-configuration/adapters/omp.ts b/src/harness-configuration/adapters/omp.ts new file mode 100644 index 0000000..ab9cd3a --- /dev/null +++ b/src/harness-configuration/adapters/omp.ts @@ -0,0 +1,90 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + agentMarkdown, + commandDocuments, + commandMarkdown, + enabledHooks, + hasEnabledMcpServers, + jsonMcpArtifact, + ruleDocuments, + ruleMarkdown, + skillArtifacts, + standardMcpProjectionDiagnostics, +} from "./shared.ts"; +import { nativeTools } from "./tools.ts"; +import { PI_PLUGIN_EVENT_MAP, piPluginSource } from "../templates/runtime.ts"; + +const OMP_PLUGIN_EVENTS = new Set(Object.keys(PI_PLUGIN_EVENT_MAP)); + +export const ompAdapter: HarnessAdapter = { + descriptor: descriptor( + "oh-my-pi", + "Oh My Pi", + ["omp"], + [ + "https://github.com/can1357/oh-my-pi/blob/main/docs/config-usage.md", + "https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md", + "https://github.com/can1357/oh-my-pi/blob/main/docs/hooks.md", + ], + { + instructions: "portable", + rules: "native", + skills: "native", + mcp: "native", + hooks: "shim", + agents: "native", + commands: "native", + }, + ["Lifecycle hooks compile to an executable Oh My Pi extension."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + artifacts.push(...await skillArtifacts(context, ".omp/skills", "oh-my-pi")); + if (hasEnabledMcpServers(context)) { + diagnostics.push(...standardMcpProjectionDiagnostics(context, "oh-my-pi")); + artifacts.push(jsonMcpArtifact(".omp/mcp.json", "oh-my-pi", context)); + } + + const hooks = enabledHooks(context); + const supportedHooks = hooks.filter((hook) => OMP_PLUGIN_EVENTS.has(hook.event)); + for (const hook of hooks) { + if (!OMP_PLUGIN_EVENTS.has(hook.event)) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "oh-my-pi", + message: `Oh My Pi's generated extension cannot map hook event ${hook.event}; it was skipped.`, + }); + } + } + if (supportedHooks.length > 0) { + artifacts.push({ + kind: "replace", + path: ".omp/extensions/canonfig.ts", + owner: "oh-my-pi", + content: piPluginSource("oh-my-pi", supportedHooks), + }); + } + + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ kind: "replace", path: `.omp/rules/${rule.id}.md`, owner: "oh-my-pi", content: ruleMarkdown(rule, content) }); + } + for (const { agent, content } of await agentDocuments(context)) { + artifacts.push({ + kind: "replace", + path: `.omp/agents/${agent.id}.md`, + owner: "oh-my-pi", + content: agentMarkdown(agent, content, nativeTools("oh-my-pi", agent)), + }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.omp/commands/${command.id}.md`, owner: "oh-my-pi", content: commandMarkdown(command, content) }); + } + + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/opencode.ts b/src/harness-configuration/adapters/opencode.ts new file mode 100644 index 0000000..e321d32 --- /dev/null +++ b/src/harness-configuration/adapters/opencode.ts @@ -0,0 +1,103 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentDocuments, + commandDocuments, + commandMarkdown, + enabledHooks, + enabledMcpServerEntries, + openCodeMcpMap, + skillArtifacts, +} from "./shared.ts"; +import { nativeTools, nativeToolsForCapabilities } from "./tools.ts"; +import { markdownWithFrontmatter } from "../core/frontmatter.ts"; +import { openCodePluginSource } from "../templates/runtime.ts"; + +export const opencodeAdapter: HarnessAdapter = { + descriptor: descriptor( + "opencode", + "OpenCode", + ["opencode"], + [ + "https://opencode.ai/docs/config/", + "https://opencode.ai/docs/agents/", + "https://opencode.ai/docs/skills/", + "https://opencode.ai/docs/plugins/", + ], + { + instructions: "portable", + rules: "portable", + skills: "native", + mcp: "native", + hooks: "shim", + agents: "native", + commands: "native", + }, + ["Tool hooks compile into an OpenCode TypeScript plugin; non-tool lifecycle events are not emitted."], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + artifacts.push(...await skillArtifacts(context, ".opencode/skills", "opencode")); + + if (Object.keys(context.config.mcp.servers).length > 0) { + artifacts.push({ + kind: "json", + path: "opencode.json", + owner: "opencode", + rootDefaults: { $schema: "https://opencode.ai/config.json" }, + operations: [{ kind: "managed-map", path: ["mcp"], entries: openCodeMcpMap(context), collision: "error" }], + }); + } + + const hooks = enabledHooks(context); + const supportedHooks = hooks.filter((hook) => hook.event === "before_tool" || hook.event === "after_tool"); + for (const hook of hooks) { + if (hook.event !== "before_tool" && hook.event !== "after_tool") { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "opencode", + message: `OpenCode's generated plugin cannot map hook event ${hook.event}; it was skipped.`, + }); + } + } + if (supportedHooks.length > 0) { + artifacts.push({ + kind: "replace", + path: ".opencode/plugins/canonfig.ts", + owner: "opencode", + content: openCodePluginSource(supportedHooks), + }); + } + + const readOnlyTools = new Set(nativeToolsForCapabilities("opencode", ["read", "search"])); + const mcpServerNames = enabledMcpServerEntries(context).map(([name]) => name); + for (const { agent, content } of await agentDocuments(context)) { + const permissions: Record = Object.fromEntries(nativeTools("opencode", agent).map((tool) => [ + tool, + agent.writable || readOnlyTools.has(tool) ? "allow" : "deny", + ])); + for (const serverName of mcpServerNames) { + permissions[`${serverName}_*`] = agent.tools.includes("mcp") ? "allow" : "deny"; + } + artifacts.push({ + kind: "replace", + path: `.opencode/agents/${agent.id}.md`, + owner: "opencode", + content: markdownWithFrontmatter({ + description: agent.description, + mode: "subagent", + ...(agent.model === "inherit" ? {} : { model: agent.model }), + permission: permissions, + }, content), + }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.opencode/commands/${command.id}.md`, owner: "opencode", content: commandMarkdown(command, content) }); + } + + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/pi.ts b/src/harness-configuration/adapters/pi.ts new file mode 100644 index 0000000..277b3dd --- /dev/null +++ b/src/harness-configuration/adapters/pi.ts @@ -0,0 +1,116 @@ +import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts"; +import { descriptor } from "./descriptor.ts"; +import { + agentSkillArtifacts, + commandDocuments, + commandMarkdown, + enabledHooks, + hasEnabledMcpServers, + piMcpMap, + ruleDocuments, + ruleMarkdown, + skillArtifacts, +} from "./shared.ts"; +import { PI_PLUGIN_EVENT_MAP, piPluginSource } from "../templates/runtime.ts"; + +const PI_PLUGIN_EVENTS = new Set(Object.keys(PI_PLUGIN_EVENT_MAP)); + +function mcpPackageOption(options: Record): string | false { + if (options.mcpPackage === false) return false; + if (typeof options.mcpPackage === "string" && options.mcpPackage.trim()) return options.mcpPackage.trim(); + return "npm:pi-mcp-extension"; +} + +export const piAdapter: HarnessAdapter = { + descriptor: descriptor( + "pi", + "Pi Coding Agent", + ["pi"], + [ + "https://pi.dev/docs/latest/settings", + "https://pi.dev/docs/latest/extensions", + "https://pi.dev/docs/latest/skills", + ], + { + instructions: "portable", + rules: "translated", + skills: "native", + mcp: "shim", + hooks: "shim", + agents: "lossy", + commands: "native", + }, + [ + "Pi does not ship a core MCP client; Canonfig writes a compatible MCP file and registers a configurable third-party package.", + "Canonical agents compile to Agent Skills because Pi has no equivalent static subagent manifest.", + ], + ), + async build(context) { + const artifacts: DesiredArtifact[] = []; + const diagnostics: Diagnostic[] = []; + + artifacts.push(...await skillArtifacts(context, ".pi/skills", "pi")); + + if (hasEnabledMcpServers(context)) { + artifacts.push({ + kind: "json", + path: ".pi/mcp.json", + owner: "pi", + operations: [{ kind: "managed-map", path: ["mcpServers"], entries: piMcpMap(context), collision: "error" }], + }); + const mcpPackage = mcpPackageOption(context.targetOptions); + if (mcpPackage === false) { + diagnostics.push({ + level: "warning", + code: "PI_MCP_PACKAGE_DISABLED", + target: "pi", + message: "Pi MCP output was generated, but no MCP extension package will be registered because targets.pi.options.mcpPackage is false.", + }); + } else { + artifacts.push({ + kind: "json", + path: ".pi/settings.json", + owner: "pi", + operations: [{ kind: "managed-array", path: ["packages"], values: [mcpPackage] }], + }); + diagnostics.push({ + level: "warning", + code: "PI_THIRD_PARTY_MCP", + target: "pi", + message: `Pi MCP support depends on the executable third-party package ${mcpPackage}; review and trust it before loading project resources.`, + }); + } + } + + const hooks = enabledHooks(context); + const supportedHooks = hooks.filter((hook) => PI_PLUGIN_EVENTS.has(hook.event)); + for (const hook of hooks) { + if (!PI_PLUGIN_EVENTS.has(hook.event)) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "pi", + message: `Pi's generated extension cannot map hook event ${hook.event}; it was skipped.`, + }); + } + } + if (supportedHooks.length > 0) { + artifacts.push({ + kind: "replace", + path: ".pi/extensions/canonfig.ts", + owner: "pi", + content: piPluginSource("pi", supportedHooks), + }); + } + + for (const { rule, content } of await ruleDocuments(context)) { + artifacts.push({ kind: "replace", path: `.pi/rules/${rule.id}.md`, owner: "pi", content: ruleMarkdown(rule, content) }); + } + for (const { command, content } of await commandDocuments(context)) { + artifacts.push({ kind: "replace", path: `.pi/prompts/${command.id}.md`, owner: "pi", content: commandMarkdown(command, content) }); + } + artifacts.push(...await agentSkillArtifacts(context, ".pi/skills", "pi")); + + return { artifacts, diagnostics }; + }, +}; diff --git a/src/harness-configuration/adapters/shared-common.ts b/src/harness-configuration/adapters/shared-common.ts new file mode 100644 index 0000000..90b2d87 --- /dev/null +++ b/src/harness-configuration/adapters/shared-common.ts @@ -0,0 +1,73 @@ +import type { BuildContext, DesiredArtifact } from "../core/types.ts"; +import { hookRegistryJson, hookRunnerSource } from "../templates/runtime.ts"; +import { readCanonfigText, skillArtifacts } from "./shared-documents.ts"; +import { enabledHooks } from "./shared-hooks.ts"; +import { hasEnabledMcpServers, standardMcpMap } from "./shared-mcp.ts"; + +export async function commonArtifacts(context: BuildContext): Promise { + const rootInstructions = await readCanonfigText(context, context.config.instructions.root); + const scoped = context.config.instructions.rules.length === 0 ? "" : [ + "", + "## Scoped instruction sources", + "", + ...context.config.instructions.rules.map((rule) => { + const scope = rule.paths.length ? rule.paths.map((item) => `\`${item}\``).join(", ") : "all files"; + return `- Read \`.canonfig/${rule.file}\` when working on ${scope}.`; + }), + ].join("\n"); + + const artifacts: DesiredArtifact[] = [ + { + kind: "managed-text", + path: "AGENTS.md", + owner: "common", + marker: "instructions", + comments: "html", + placement: "end", + content: `${rootInstructions.trim()}${scoped}`, + }, + { + kind: "managed-text", + path: ".gitignore", + owner: "common", + marker: "state-ignore", + comments: "hash", + placement: "end", + content: ".canonfig/.harness-state.json", + }, + ]; + + artifacts.push(...await skillArtifacts(context, ".agents/skills", "common")); + if (hasEnabledMcpServers(context)) { + artifacts.push({ + kind: "json", + path: ".mcp.json", + owner: "common", + operations: [{ + kind: "managed-map", + path: ["mcpServers"], + entries: standardMcpMap(context), + collision: "error", + }], + }); + } + const hooks = enabledHooks(context); + if (hooks.length > 0) { + artifacts.push( + { + kind: "replace", + path: ".canonfig/.runtime/hook-runner.mjs", + owner: "common", + content: hookRunnerSource(), + mode: 0o755, + }, + { + kind: "replace", + path: ".canonfig/.runtime/hooks.json", + owner: "common", + content: hookRegistryJson(hooks), + }, + ); + } + return artifacts; +} diff --git a/src/harness-configuration/adapters/shared-documents.ts b/src/harness-configuration/adapters/shared-documents.ts new file mode 100644 index 0000000..695b110 --- /dev/null +++ b/src/harness-configuration/adapters/shared-documents.ts @@ -0,0 +1,156 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { Agent, Command, Rule } from "../core/schema.ts"; +import type { ArtifactOwner, BuildContext, DesiredArtifact, TargetId } from "../core/types.ts"; +import { assertRealPathInside, assertSafeRelativePath, resolveInside, toPosix } from "../core/path.ts"; +import { markdownWithFrontmatter } from "../core/frontmatter.ts"; +import { walkFiles } from "../core/filesystem.ts"; + +export const RUNTIME_MARKER = ".canonfig/.runtime/hook-runner.mjs"; + +export async function readCanonfigText(context: BuildContext, relativePath: string): Promise { + const safe = assertSafeRelativePath(relativePath); + const absolute = resolveInside(context.canonfigDir, safe); + await assertRealPathInside(context.canonfigDir, absolute); + return fs.readFile(absolute, "utf8"); +} + +export async function copyDirectoryArtifacts( + context: BuildContext, + sourceRelative: string, + destination: string, + owner: ArtifactOwner, +): Promise { + const source = resolveInside(context.canonfigDir, assertSafeRelativePath(sourceRelative)); + try { + await assertRealPathInside(context.canonfigDir, source); + await fs.access(source); + } catch { + return []; + } + + const files = await walkFiles(source); + const artifacts: DesiredArtifact[] = []; + for (const file of files.sort()) { + const absolute = path.join(source, file); + const [content, stat] = await Promise.all([fs.readFile(absolute), fs.stat(absolute)]); + const executable = (stat.mode & 0o111) !== 0; + artifacts.push({ + kind: "replace", + path: toPosix(path.posix.join(destination, toPosix(file))), + owner, + content, + ...(executable ? { mode: 0o755 } : {}), + }); + } + return artifacts; +} + +export async function skillArtifacts( + context: BuildContext, + destination: string, + owner: ArtifactOwner, +): Promise { + const artifacts: DesiredArtifact[] = []; + for (const root of context.config.skills.roots) { + artifacts.push(...await copyDirectoryArtifacts(context, root, destination, owner)); + } + return artifacts; +} + +export async function ruleDocuments(context: BuildContext): Promise> { + return Promise.all(context.config.instructions.rules.map(async (rule) => ({ + rule, + content: await readCanonfigText(context, rule.file), + }))); +} + +export async function agentDocuments(context: BuildContext): Promise> { + return Promise.all(context.config.agents.map(async (agent) => ({ + agent, + content: await readCanonfigText(context, agent.file), + }))); +} + +export async function commandDocuments(context: BuildContext): Promise> { + return Promise.all(context.config.commands.map(async (command) => ({ + command, + content: await readCanonfigText(context, command.file), + }))); +} + +export function agentMarkdown(agent: Agent, content: string, tools: string[]): string { + return markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + ...(agent.model === "inherit" ? {} : { model: agent.model }), + tools, + }, content); +} + +export function commandMarkdown(command: Command, content: string): string { + return markdownWithFrontmatter({ + description: command.description, + ...(command.argumentHint ? { "argument-hint": command.argumentHint } : {}), + }, content); +} + +export function ruleMarkdown(rule: Rule, content: string, extra: Record = {}): string { + return markdownWithFrontmatter({ + description: rule.description ?? `Canonfig rule: ${rule.id}`, + ...(rule.paths.length ? { globs: rule.paths } : {}), + ...extra, + }, content); +} + +export function skillMarkdown( + name: string, + description: string, + content: string, + metadata: Record = {}, +): string { + return markdownWithFrontmatter({ name, description, ...metadata }, content); +} + +function translatedSkillName(owner: TargetId, kind: "command" | "agent", id: string): string { + return `canonfig-${owner}-${kind}-${id}`; +} + +export async function commandSkillArtifacts( + context: BuildContext, + destination: string, + owner: TargetId, +): Promise { + const documents = await commandDocuments(context); + return documents.map(({ command, content }) => { + const name = translatedSkillName(owner, "command", command.id); + return { + kind: "replace", + path: `${destination}/${name}/SKILL.md`, + owner, + content: skillMarkdown(name, command.description, content, { + metadata: { canonfig: { kind: "command", sourceId: command.id, argumentHint: command.argumentHint ?? null } }, + }), + }; + }); +} + +export async function agentSkillArtifacts( + context: BuildContext, + destination: string, + owner: TargetId, +): Promise { + const documents = await agentDocuments(context); + return documents.map(({ agent, content }) => { + const name = translatedSkillName(owner, "agent", agent.id); + return { + kind: "replace", + path: `${destination}/${name}/SKILL.md`, + owner, + content: skillMarkdown(name, agent.description, content, { + metadata: { canonfig: { kind: "agent", sourceId: agent.id, model: agent.model, tools: agent.tools } }, + }), + }; + }); +} diff --git a/src/harness-configuration/adapters/shared-hooks.ts b/src/harness-configuration/adapters/shared-hooks.ts new file mode 100644 index 0000000..2989d35 --- /dev/null +++ b/src/harness-configuration/adapters/shared-hooks.ts @@ -0,0 +1,202 @@ +import type { Hook } from "../core/schema.ts"; +import type { BuildContext, Diagnostic, TargetId } from "../core/types.ts"; + +export function hookCommand(target: TargetId, hook: Hook): string { + return `node \".canonfig/.runtime/hook-runner.mjs\" --hook ${hook.id} --target ${target} --event ${hook.event}`; +} + +export function enabledHooks(context: BuildContext): Hook[] { + return context.config.hooks.filter((hook) => hook.enabled); +} + +function timeoutSeconds(timeoutMs: number): number { + return Math.max(1, Math.ceil(timeoutMs / 1000)); +} + +export const CLAUDE_EVENT_MAP: Partial> = { + session_start: "SessionStart", + session_end: "SessionEnd", + prompt_submit: "UserPromptSubmit", + before_agent: "PreInvocation", + after_agent: "PostInvocation", + before_tool: "PreToolUse", + after_tool: "PostToolUse", + before_compact: "PreCompact", + after_compact: "PostCompact", + stop: "Stop", + subagent_start: "SubagentStart", + subagent_stop: "SubagentStop", +}; + +export const CODEX_EVENT_MAP: Partial> = { + session_start: "SessionStart", + session_end: "SessionEnd", + prompt_submit: "UserPromptSubmit", + before_tool: "PreToolUse", + after_tool: "PostToolUse", + before_compact: "PreCompact", + after_compact: "PostCompact", + stop: "Stop", + subagent_start: "SubagentStart", + subagent_stop: "SubagentStop", +}; + +export const DEVIN_EVENT_MAP: Partial> = { + session_start: "SessionStart", + session_end: "SessionEnd", + prompt_submit: "UserPromptSubmit", + before_tool: "PreToolUse", + after_tool: "PostToolUse", + after_compact: "PostCompaction", + stop: "Stop", +}; + +export const GROK_EVENT_MAP: Partial> = { + session_start: "SessionStart", + session_end: "SessionEnd", + prompt_submit: "UserPromptSubmit", + before_tool: "PreToolUse", + after_tool: "PostToolUse", + before_compact: "PreCompact", + after_compact: "PostCompact", + stop: "Stop", + subagent_start: "SubagentStart", + subagent_stop: "SubagentStop", +}; + +export function claudeStyleHooks( + context: BuildContext, + eventMap: Partial> = CLAUDE_EVENT_MAP, +): { hooks: Record; diagnostics: Diagnostic[] } { + const hooks: Record = {}; + const diagnostics: Diagnostic[] = []; + for (const hook of enabledHooks(context)) { + const nativeEvent = eventMap[hook.event]; + if (!nativeEvent) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: context.target, + message: `${context.target} cannot directly map hook event ${hook.event}; it was skipped.`, + }); + continue; + } + const entry = { + matcher: ".*", + hooks: [{ + type: "command", + command: hookCommand(context.target, hook), + timeout: timeoutSeconds(hook.timeoutMs), + }], + }; + (hooks[nativeEvent] ??= []).push(entry); + } + return { hooks, diagnostics }; +} + +export function cursorHooks(context: BuildContext): { hooks: Record; diagnostics: Diagnostic[] } { + const eventMap: Partial> = { + session_start: "sessionStart", + session_end: "sessionEnd", + prompt_submit: "beforeSubmitPrompt", + before_tool: "preToolUse", + after_tool: "postToolUse", + before_compact: "preCompact", + stop: "stop", + subagent_start: "subagentStart", + subagent_stop: "subagentStop", + }; + const hooks: Record = {}; + const diagnostics: Diagnostic[] = []; + for (const hook of enabledHooks(context)) { + const event = eventMap[hook.event]; + if (!event) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "cursor", + message: `Cursor cannot directly map hook event ${hook.event}; it was skipped.`, + }); + continue; + } + (hooks[event] ??= []).push({ + command: hookCommand("cursor", hook), + ...(event === "preToolUse" || event === "postToolUse" ? { matcher: ".*" } : {}), + }); + } + return { hooks, diagnostics }; +} + +export function copilotHooks(context: BuildContext): { hooks: Record; diagnostics: Diagnostic[] } { + const eventMap: Partial> = { + session_start: "sessionStart", + session_end: "sessionEnd", + prompt_submit: "userPromptSubmitted", + before_tool: "preToolUse", + after_tool: "postToolUse", + stop: "agentStop", + subagent_start: "subagentStart", + subagent_stop: "subagentStop", + before_compact: "preCompact", + }; + const hooks: Record = {}; + const diagnostics: Diagnostic[] = []; + for (const hook of enabledHooks(context)) { + const event = eventMap[hook.event]; + if (!event) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "copilot-cli", + message: `Copilot CLI cannot directly map hook event ${hook.event}; it was skipped.`, + }); + continue; + } + const command = hookCommand("copilot-cli", hook); + (hooks[event] ??= []).push({ + type: "command", + bash: command, + powershell: command, + cwd: ".", + timeoutSec: timeoutSeconds(hook.timeoutMs), + ...(event === "preToolUse" || event === "postToolUse" ? { matcher: ".*" } : {}), + }); + } + return { hooks, diagnostics }; +} + +export function antigravityHooks(context: BuildContext): { entries: Record; diagnostics: Diagnostic[] } { + const eventMap: Partial> = { + before_tool: "PreToolUse", + after_tool: "PostToolUse", + before_agent: "PreInvocation", + after_agent: "PostInvocation", + stop: "Stop", + }; + const entries: Record = {}; + const diagnostics: Diagnostic[] = []; + for (const hook of enabledHooks(context)) { + const event = eventMap[hook.event]; + if (!event) { + diagnostics.push({ + level: "warning", + code: "HOOK_EVENT_UNSUPPORTED", + target: "antigravity", + message: `Antigravity cannot map hook event ${hook.event}; it was skipped.`, + }); + continue; + } + const handler = { + type: "command", + command: hookCommand("antigravity", hook), + timeout: timeoutSeconds(hook.timeoutMs), + }; + entries[`canonfig-${hook.id}`] = { + enabled: true, + [event]: event === "PreToolUse" || event === "PostToolUse" + ? [{ matcher: ".*", hooks: [handler] }] + : [handler], + }; + } + return { entries, diagnostics }; +} diff --git a/src/harness-configuration/adapters/shared-mcp.ts b/src/harness-configuration/adapters/shared-mcp.ts new file mode 100644 index 0000000..2421328 --- /dev/null +++ b/src/harness-configuration/adapters/shared-mcp.ts @@ -0,0 +1,247 @@ +import type { McpServer, SecretValue } from "../core/schema.ts"; +import type { BuildContext, Diagnostic, JsonArtifact, TargetId } from "../core/types.ts"; + +export function secretValue(value: SecretValue): string { + return typeof value === "string" ? value : `\${${value.fromEnv}}`; +} + +export function enabledMcpServerEntries(context: BuildContext): Array<[string, McpServer]> { + return Object.entries(context.config.mcp.servers).filter(([, server]) => server.enabled); +} + +export function hasEnabledMcpServers(context: BuildContext): boolean { + return enabledMcpServerEntries(context).length > 0; +} + +export function standardMcpProjectionDiagnostics( + context: BuildContext, + target: TargetId, + includeType = true, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const [name, server] of enabledMcpServerEntries(context)) { + const omitted: string[] = []; + if (server.timeoutMs !== undefined) omitted.push("timeoutMs"); + if (server.enabledTools?.length) omitted.push("enabledTools"); + if (server.disabledTools?.length) omitted.push("disabledTools"); + if (!includeType && server.transport === "sse") omitted.push("sse transport discriminator"); + if (omitted.length > 0) { + diagnostics.push({ + level: "warning", + code: "MCP_OPTION_UNSUPPORTED", + target, + message: `${target} cannot represent ${omitted.join(", ")} for MCP server ${name} in its standard JSON projection; those options were omitted.`, + }); + } + } + return diagnostics; +} + +export function codexMcpDiagnostics(context: BuildContext): Diagnostic[] { + return enabledMcpServerEntries(context).flatMap(([name, server]): Diagnostic[] => + server.transport === "sse" + ? [{ + level: "warning", + code: "MCP_TRANSPORT_UNSUPPORTED", + target: "codex", + message: `Codex project MCP config supports streamable HTTP URLs but cannot preserve legacy SSE transport for server ${name}; the URL is emitted as streamable HTTP.`, + }] + : [] + ); +} + +export function standardMcpServer(server: McpServer, includeType = true): Record { + if (server.transport === "stdio") { + return { + ...(includeType ? { type: "stdio" } : {}), + command: server.command, + args: server.args, + ...(Object.keys(server.env).length > 0 + ? { env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) } + : {}), + ...(server.cwd ? { cwd: server.cwd } : {}), + }; + } + return { + ...(includeType ? { type: server.transport === "sse" ? "sse" : "http" } : {}), + url: server.url, + ...(Object.keys(server.headers).length > 0 + ? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) } + : {}), + }; +} + +export function standardMcpMap(context: BuildContext, includeType = true): Record { + return Object.fromEntries( + enabledMcpServerEntries(context) + .map(([name, server]) => [name, standardMcpServer(server, includeType)]), + ); +} + +export function piMcpMap(context: BuildContext): Record { + return Object.fromEntries( + enabledMcpServerEntries(context) + .map(([name, server]) => { + if (server.transport === "stdio") { + return [name, { + command: server.command, + args: server.args, + ...(server.cwd ? { cwd: server.cwd } : {}), + ...(Object.keys(server.env).length + ? { env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) } + : {}), + }]; + } + return [name, { + transport: server.transport, + url: server.url, + ...(Object.keys(server.headers).length + ? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) } + : {}), + }]; + }), + ); +} + +export function antigravityMcpMap(context: BuildContext): Record { + return Object.fromEntries( + enabledMcpServerEntries(context) + .map(([name, server]) => { + if (server.transport === "stdio") { + return [name, { + command: server.command, + args: server.args, + ...(Object.keys(server.env).length + ? { env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) } + : {}), + }]; + } + return [name, { + serverUrl: server.url, + ...(Object.keys(server.headers).length + ? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) } + : {}), + }]; + }), + ); +} + +export function openCodeMcpMap(context: BuildContext): Record { + return Object.fromEntries(Object.entries(context.config.mcp.servers).map(([name, server]) => { + if (server.transport === "stdio") { + return [name, { + type: "local", + command: [server.command, ...server.args], + enabled: server.enabled, + ...(Object.keys(server.env).length + ? { environment: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) } + : {}), + ...(server.timeoutMs ? { timeout: server.timeoutMs } : {}), + }]; + } + return [name, { + type: "remote", + url: server.url, + enabled: server.enabled, + ...(Object.keys(server.headers).length + ? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) } + : {}), + ...(server.timeoutMs ? { timeout: server.timeoutMs } : {}), + }]; + })); +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} +function tomlKey(value: string): string { + return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value); +} +function tomlArray(values: string[]): string { + return `[${values.map(tomlString).join(", ")}]`; +} +function tomlInlineTable(entries: Record): string { + return `{ ${Object.entries(entries).map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`).join(", ")} }`; +} + +export function mcpToml( + context: BuildContext, + remoteHeadersKey: "http_headers" | "headers" = "http_headers", +): string { + const sections: string[] = []; + for (const [name, server] of enabledMcpServerEntries(context)) { + const lines = [`[mcp_servers.${tomlKey(name)}]`]; + if (server.transport === "stdio") { + lines.push(`command = ${tomlString(server.command)}`); + if (server.args.length) lines.push(`args = ${tomlArray(server.args)}`); + if (server.cwd) lines.push(`cwd = ${tomlString(server.cwd)}`); + if (Object.keys(server.env).length) { + lines.push(`env = ${tomlInlineTable(Object.fromEntries( + Object.entries(server.env).map(([key, value]) => [key, secretValue(value)]), + ))}`); + } + } else { + lines.push(`url = ${tomlString(server.url)}`); + if (Object.keys(server.headers).length) { + lines.push(`${remoteHeadersKey} = ${tomlInlineTable(Object.fromEntries( + Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)]), + ))}`); + } + } + if (server.enabledTools?.length) lines.push(`enabled_tools = ${tomlArray(server.enabledTools)}`); + if (server.disabledTools?.length) lines.push(`disabled_tools = ${tomlArray(server.disabledTools)}`); + sections.push(lines.join("\n")); + } + return sections.join("\n\n"); +} + +export function jsonMcpArtifact( + pathname: string, + owner: TargetId, + context: BuildContext, + pathSegments: string[] = ["mcpServers"], + includeType = true, +): JsonArtifact { + return { + kind: "json", + path: pathname, + owner, + operations: [{ + kind: "managed-map", + path: pathSegments, + entries: standardMcpMap(context, includeType), + collision: "error", + }], + }; +} + +export function grokMcpToml(context: BuildContext): string { + const sections: string[] = []; + for (const [name, server] of enabledMcpServerEntries(context)) { + const lines = [`[mcp_servers.${tomlKey(name)}]`]; + if (server.transport === "stdio") { + lines.push(`command = ${tomlString(server.command)}`); + if (server.args.length) lines.push(`args = ${tomlArray(server.args)}`); + if (server.cwd) lines.push(`cwd = ${tomlString(server.cwd)}`); + if (Object.keys(server.env).length) { + lines.push(`env = ${tomlInlineTable(Object.fromEntries( + Object.entries(server.env).map(([key, value]) => [key, secretValue(value)]), + ))}`); + } + } else { + lines.push(`url = ${tomlString(server.url)}`); + if (Object.keys(server.headers).length) { + lines.push(`headers = ${tomlInlineTable(Object.fromEntries( + Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)]), + ))}`); + } + } + if (server.timeoutMs) { + const seconds = Math.max(1, Math.ceil(server.timeoutMs / 1000)); + lines.push(`startup_timeout_sec = ${seconds}`); + lines.push(`tool_timeout_sec = ${seconds}`); + } + sections.push(lines.join("\n")); + } + return sections.join("\n\n"); +} diff --git a/src/harness-configuration/adapters/shared.ts b/src/harness-configuration/adapters/shared.ts new file mode 100644 index 0000000..64499fe --- /dev/null +++ b/src/harness-configuration/adapters/shared.ts @@ -0,0 +1,4 @@ +export * from "./shared-common.ts"; +export * from "./shared-documents.ts"; +export * from "./shared-hooks.ts"; +export * from "./shared-mcp.ts"; diff --git a/src/harness-configuration/adapters/tools.ts b/src/harness-configuration/adapters/tools.ts new file mode 100644 index 0000000..872c92b --- /dev/null +++ b/src/harness-configuration/adapters/tools.ts @@ -0,0 +1,25 @@ +import type { Agent, Capability } from "../core/schema.ts"; +import type { TargetId } from "../core/types.ts"; + +const maps: Record> = { + codex: { read: ["read_file"], write: ["apply_patch"], search: ["grep", "glob"], shell: ["shell"], web: ["web_search"], mcp: ["mcp"], subagent: ["spawn_agent"], test: ["shell"], git: ["shell"] }, + "claude-code": { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebFetch", "WebSearch"], mcp: [], subagent: ["Task"], test: ["Bash"], git: ["Bash"] }, + amp: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["Bash"], web: ["web"], mcp: ["mcp"], subagent: ["agent"], test: ["Bash"], git: ["Bash"] }, + "oh-my-pi": { read: ["read"], write: ["edit", "write"], search: ["search", "find"], shell: ["bash"], web: ["web_search"], mcp: ["mcp"], subagent: ["task"], test: ["bash"], git: ["bash"] }, + pi: { read: ["read"], write: ["edit", "write"], search: ["grep", "find"], shell: ["bash"], web: ["web"], mcp: ["mcp"], subagent: ["task"], test: ["bash"], git: ["bash"] }, + "factory-droid": { read: ["Read"], write: ["Edit", "Create", "ApplyPatch"], search: ["Grep", "Glob", "LS"], shell: ["Execute"], web: ["FetchUrl", "WebSearch"], mcp: ["mcp__.*"], subagent: ["Task"], test: ["Execute"], git: ["Execute"] }, + cursor: { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Shell"], web: ["WebFetch", "WebSearch"], mcp: ["MCP"], subagent: ["Agent"], test: ["Shell"], git: ["Shell"] }, + devin: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["exec"], web: ["web"], mcp: ["mcp"], subagent: ["subagent"], test: ["exec"], git: ["exec"] }, + opencode: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["bash"], web: ["webfetch"], mcp: [], subagent: ["task"], test: ["bash"], git: ["bash"] }, + "grok-build": { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebFetch", "WebSearch"], mcp: ["mcp__.*"], subagent: ["Agent"], test: ["Bash"], git: ["Bash"] }, + antigravity: { read: ["view_file"], write: ["write_to_file", "replace_file_content", "multi_replace_file_content"], search: ["grep_search", "find_by_name", "list_dir"], shell: ["run_command"], web: ["browser_*", "search_web"], mcp: ["mcp_*"], subagent: ["task"], test: ["run_command"], git: ["run_command"] }, + "copilot-cli": { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebFetch", "WebSearch"], mcp: ["mcp__*"], subagent: ["Task"], test: ["Bash"], git: ["Bash"] }, +}; + +export function nativeToolsForCapabilities(target: TargetId, capabilities: readonly Capability[]): string[] { + return [...new Set(capabilities.flatMap((capability) => maps[target][capability]))]; +} + +export function nativeTools(target: TargetId, agent: Agent): string[] { + return nativeToolsForCapabilities(target, agent.tools); +} diff --git a/src/harness-configuration/cli-arguments.ts b/src/harness-configuration/cli-arguments.ts new file mode 100644 index 0000000..0bae499 --- /dev/null +++ b/src/harness-configuration/cli-arguments.ts @@ -0,0 +1,129 @@ +import path from "node:path"; + +import { parseTargetList } from "./core/config.ts"; +import { CanonfigError } from "./core/errors.ts"; +import type { TargetId } from "./core/types.ts"; + +export interface ParsedHarnessArguments { + readonly command: string; + readonly root: string; + readonly json: boolean; + readonly strict: boolean; + readonly force: boolean; + readonly all: boolean; + readonly dryRun: boolean; + readonly targets?: ReadonlyArray | undefined; +} + +export const harnessHelpText = `Canonfig harness configuration + +Usage: canonfig harness [options] + +Commands: + init Create .canonfig/harness.yaml and canonical source directories + validate Validate canonical sources and selected adapter translations + targets List built-in harness adapters and support levels + plan Show native files that would change + apply Apply the current plan atomically + sync Alias for apply + status Report pending changes, conflicts, and diagnostics + diff Print a unified-style diff for pending changes + clean Remove only configuration currently owned by Canonfig + doctor Probe selected harness executables + +Options: + --root Repository root or descendant working directory + --target Select one target; repeatable + --targets Select comma-separated targets + --strict Reject shim, lossy, and unsupported mappings + --force Take ownership of explicit collisions or managed edits + --all Include unchanged files in plan output + --dry-run Do not write during apply or clean + --no-input Never prompt; accepted for scheduled invocations + --json Emit the stable canonfig.cli/v1 envelope +`; + +export const parseHarnessArguments = ( + arguments_: ReadonlyArray, +): ParsedHarnessArguments => { + const [command = "help", ...rest] = arguments_; + let root = process.cwd(); + let json = false; + let strict = false; + let force = false; + let all = false; + let dryRun = false; + const requestedTargets: string[] = []; + + for (let index = 0; index < rest.length; index += 1) { + const argument = rest[index]!; + if (argument === "--json") { + json = true; + continue; + } + if (argument === "--strict") { + strict = true; + continue; + } + if (argument === "--force") { + force = true; + continue; + } + if (argument === "--all") { + all = true; + continue; + } + if (argument === "--dry-run") { + dryRun = true; + continue; + } + if (argument === "--no-input") continue; + if (argument === "--help" || argument === "-h") { + return { + command: "help", + root: path.resolve(root), + json, + strict, + force, + all, + dryRun, + }; + } + if ( + argument === "--root" + || argument === "--cwd" + || argument === "--target" + || argument === "--targets" + ) { + const value = rest[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new CanonfigError( + "HARNESS_OPTION_VALUE_REQUIRED", + `${argument} requires a value.`, + ); + } + index += 1; + if (argument === "--root" || argument === "--cwd") root = path.resolve(value); + else requestedTargets.push(value); + continue; + } + throw new CanonfigError( + "HARNESS_OPTION_UNKNOWN", + `Unknown harness option: ${argument}`, + ); + } + + const targets = requestedTargets.length === 0 + ? undefined + : parseTargetList(requestedTargets.join(",")); + return { + command, + root: path.resolve(root), + json, + strict, + force, + all, + dryRun, + ...(targets === undefined ? {} : { targets }), + }; +}; diff --git a/src/harness-configuration/cli-output.ts b/src/harness-configuration/cli-output.ts new file mode 100644 index 0000000..2efb4e4 --- /dev/null +++ b/src/harness-configuration/cli-output.ts @@ -0,0 +1,117 @@ +import { Schema } from "effect"; + +import { CliExitCode, type CliExitCode as CliExitCodeValue } from "../cli/exit-codes.ts"; +import { renderCliResult } from "../cli/render.ts"; +import type { CliPayload } from "../cli/source-commands.ts"; +import { CanonfigError } from "./core/errors.ts"; +import type { Diagnostic, Plan, PlanEntry } from "./core/types.ts"; + +export interface HarnessConfigurationCliIo { + readonly writeStdout: (text: string) => void; + readonly writeStderr: (text: string) => void; + readonly setExitCode: (exitCode: CliExitCodeValue) => void; +} + +const actionSymbol = (entry: PlanEntry): string => { + switch (entry.action) { + case "create": return "+"; + case "update": return "~"; + case "delete": return "-"; + case "conflict": return "!"; + case "unchanged": return "="; + } +}; + +export const renderHumanPlan = ( + plan: Plan, + includeUnchanged: boolean, +): string => { + const lines: string[] = []; + for (const entry of plan.entries) { + if (!includeUnchanged && entry.action === "unchanged") continue; + lines.push(`${actionSymbol(entry)} ${entry.action.padEnd(9)} ${entry.path}`); + if (entry.reason !== undefined) lines.push(` ${entry.reason}`); + } + for (const diagnostic of plan.diagnostics) { + const target = diagnostic.target === undefined ? "" : `[${diagnostic.target}] `; + const location = diagnostic.path === undefined ? "" : ` (${diagnostic.path})`; + lines.push( + `${diagnostic.level.toUpperCase()} ${target}${diagnostic.code}: ${diagnostic.message}${location}`, + ); + } + const counts = ["create", "update", "delete", "unchanged", "conflict"] + .map((action) => `${plan.entries.filter((entry) => entry.action === action).length} ${action}`) + .join(", "); + lines.push(counts); + return `${lines.join("\n")}\n`; +}; + +export const toCliPayload = (value: unknown): CliPayload => + Schema.decodeUnknownSync(Schema.MutableJson)( + JSON.parse(JSON.stringify(value)), + ); + +export const planPayload = (plan: Plan): CliPayload => + toCliPayload({ + root: plan.root, + targets: plan.targets, + entries: plan.entries.map(({ + content: _content, + before: _before, + after: _after, + nextState: _nextState, + ...entry + }) => entry), + diagnostics: plan.diagnostics, + }); + +export const diagnosticsPayload = ( + diagnostics: ReadonlyArray, +): CliPayload => toCliPayload(diagnostics); + +export const isHarnessPlanBlocked = (plan: Plan): boolean => + plan.entries.some((entry) => entry.action === "conflict") + || plan.diagnostics.some((diagnostic) => diagnostic.level === "error"); + +export const renderHarnessResult = ( + io: HarnessConfigurationCliIo, + input: { + readonly command: string; + readonly message: string; + readonly data?: CliPayload | undefined; + readonly exitCode: CliExitCodeValue; + readonly json: boolean; + readonly human?: string | undefined; + }, +): void => { + const rendered = input.json + ? renderCliResult({ + command: input.command, + message: input.message, + data: input.data, + exitCode: input.exitCode, + }, "json") + : (input.human ?? renderCliResult({ + command: input.command, + message: input.message, + data: input.data, + exitCode: input.exitCode, + }, "human")); + if (input.exitCode === CliExitCode.success) io.writeStdout(rendered); + else io.writeStderr(rendered); + io.setExitCode(input.exitCode); +}; + +export const harnessFailureExitCode = (error: unknown): CliExitCodeValue => { + if (!(error instanceof CanonfigError)) return CliExitCode.internal; + if (/CONFLICT|COLLISION|EDITED|ESCAPE|STALE/u.test(error.code)) { + return CliExitCode.conflictOrDrift; + } + if (/APPLY|WRITE|ROLLBACK/u.test(error.code)) { + return CliExitCode.verificationOrApplyFailure; + } + if (/INVALID|NOT_FOUND|UNKNOWN|REQUIRED|EMPTY|PARSE/u.test(error.code)) { + return CliExitCode.usageOrConfiguration; + } + return CliExitCode.internal; +}; diff --git a/src/harness-configuration/cli.ts b/src/harness-configuration/cli.ts new file mode 100644 index 0000000..c8aa88f --- /dev/null +++ b/src/harness-configuration/cli.ts @@ -0,0 +1,244 @@ +import { CliExitCode } from "../cli/exit-codes.ts"; +import { + harnessHelpText, + parseHarnessArguments, + type ParsedHarnessArguments, +} from "./cli-arguments.ts"; +import { + diagnosticsPayload, + harnessFailureExitCode, + isHarnessPlanBlocked, + planPayload, + renderHarnessResult, + renderHumanPlan, + toCliPayload, + type HarnessConfigurationCliIo, +} from "./cli-output.ts"; +import { + HarnessConfigurationCompiler, + createDefaultRegistry, +} from "./core/compiler.ts"; +import { findRepositoryRoot } from "./core/config.ts"; +import { formatPlanDiff } from "./core/diff.ts"; +import { doctorTargets } from "./core/doctor.ts"; +import { CanonfigError, errorMessage } from "./core/errors.ts"; +import { applyPlan, createPlan } from "./core/planner.ts"; +import { scaffoldProject } from "./core/scaffold.ts"; +import { TARGET_IDS } from "./core/types.ts"; + +export type { HarnessConfigurationCliIo } from "./cli-output.ts"; + +export const isHarnessConfigurationCommand = ( + arguments_: ReadonlyArray, +): boolean => arguments_[0] === "harness"; + +const renderHelp = ( + parsed: ParsedHarnessArguments, + io: HarnessConfigurationCliIo, +): void => { + renderHarnessResult(io, { + command: "harness.help", + message: "Harness configuration help", + data: { + commands: [ + "init", + "validate", + "targets", + "plan", + "apply", + "status", + "diff", + "clean", + "doctor", + ], + }, + exitCode: CliExitCode.success, + json: parsed.json, + human: harnessHelpText, + }); +}; + +export const runHarnessConfigurationCli = async ( + arguments_: ReadonlyArray, + io: HarnessConfigurationCliIo, +): Promise => { + let parsed: ParsedHarnessArguments | undefined; + try { + parsed = parseHarnessArguments(arguments_); + const registry = createDefaultRegistry(); + const compiler = new HarnessConfigurationCompiler(registry); + const commandName = `harness.${parsed.command}`; + + if (parsed.command === "help") { + renderHelp(parsed, io); + return; + } + + if (parsed.command === "init") { + const written = await scaffoldProject(parsed.root, { + targets: parsed.targets, + force: parsed.force, + }); + renderHarnessResult(io, { + command: commandName, + message: written.length === 0 + ? "No harness source files changed" + : "Harness source initialized", + data: { root: parsed.root, written }, + exitCode: CliExitCode.success, + json: parsed.json, + human: written.length === 0 + ? "No files changed.\n" + : `${written.map((file) => `+ ${file}`).join("\n")}\n`, + }); + return; + } + + if (parsed.command === "targets") { + const descriptors = registry.list().map((adapter) => adapter.descriptor); + renderHarnessResult(io, { + command: commandName, + message: "Harness targets listed", + data: toCliPayload(descriptors), + exitCode: CliExitCode.success, + json: parsed.json, + human: `${descriptors.map((descriptor) => [ + `${descriptor.id.padEnd(16)} ${descriptor.name}`, + ` ${Object.entries(descriptor.capabilities) + .map(([feature, level]) => `${feature}:${level}`) + .join(" ")}`, + ].join("\n")).join("\n")}\n`, + }); + return; + } + + if (parsed.command === "doctor") { + const results = doctorTargets(registry, parsed.targets); + renderHarnessResult(io, { + command: commandName, + message: "Harness probes completed", + data: toCliPayload(results), + exitCode: CliExitCode.success, + json: parsed.json, + human: `${results.map((result) => + `${result.id.padEnd(16)} ${(result.found ? "found" : "missing").padEnd(8)} ${result.executable ?? result.error ?? ""}` + ).join("\n")}\n`, + }); + return; + } + + const root = await findRepositoryRoot(parsed.root); + if (parsed.command === "clean") { + const plan = await createPlan( + root, + [...TARGET_IDS], + [], + [], + { force: parsed.force }, + ); + const exitCode = isHarnessPlanBlocked(plan) + ? CliExitCode.conflictOrDrift + : CliExitCode.success; + if (exitCode === CliExitCode.success && !parsed.dryRun) await applyPlan(plan); + renderHarnessResult(io, { + command: commandName, + message: exitCode !== CliExitCode.success + ? "Harness cleanup blocked" + : parsed.dryRun + ? "Harness cleanup planned" + : "Harness-owned configuration cleaned", + data: planPayload(plan), + exitCode, + json: parsed.json, + human: renderHumanPlan(plan, parsed.all), + }); + return; + } + + const plan = await compiler.plan({ + root, + targets: parsed.targets, + strict: parsed.strict, + force: parsed.force, + }); + const exitCode = isHarnessPlanBlocked(plan) + ? CliExitCode.conflictOrDrift + : CliExitCode.success; + + if (parsed.command === "validate") { + renderHarnessResult(io, { + command: commandName, + message: exitCode === CliExitCode.success + ? "Harness configuration is valid" + : "Harness configuration validation failed", + data: diagnosticsPayload(plan.diagnostics), + exitCode, + json: parsed.json, + human: exitCode === CliExitCode.success + ? "Harness configuration is valid.\n" + : renderHumanPlan(plan, false), + }); + return; + } + + if (parsed.command === "plan" || parsed.command === "status") { + renderHarnessResult(io, { + command: commandName, + message: parsed.command === "status" + ? (exitCode === CliExitCode.success + ? "Harness configuration status computed" + : "Harness configuration has conflicts") + : "Harness configuration plan computed", + data: planPayload(plan), + exitCode, + json: parsed.json, + human: renderHumanPlan(plan, parsed.all), + }); + return; + } + + if (parsed.command === "diff") { + const diff = formatPlanDiff(plan); + renderHarnessResult(io, { + command: commandName, + message: "Harness configuration diff computed", + data: planPayload(plan), + exitCode, + json: parsed.json, + human: diff === "" ? "No pending changes.\n" : `${diff}\n`, + }); + return; + } + + if (parsed.command === "apply" || parsed.command === "sync") { + if (exitCode === CliExitCode.success && !parsed.dryRun) await applyPlan(plan); + renderHarnessResult(io, { + command: commandName, + message: exitCode !== CliExitCode.success + ? "Harness configuration apply blocked" + : parsed.dryRun + ? "Harness configuration apply planned" + : "Harness configuration applied", + data: planPayload(plan), + exitCode, + json: parsed.json, + human: renderHumanPlan(plan, parsed.all), + }); + return; + } + + throw new CanonfigError( + "HARNESS_COMMAND_UNKNOWN", + `Unknown harness command: ${parsed.command}`, + ); + } catch (error) { + const exitCode = harnessFailureExitCode(error); + renderHarnessResult(io, { + command: `harness.${parsed?.command ?? "unknown"}`, + message: errorMessage(error), + data: error instanceof CanonfigError ? { code: error.code } : undefined, + exitCode, + json: parsed?.json ?? arguments_.includes("--json"), + }); + } +}; diff --git a/src/harness-configuration/core/compiler.ts b/src/harness-configuration/core/compiler.ts new file mode 100644 index 0000000..96835c7 --- /dev/null +++ b/src/harness-configuration/core/compiler.ts @@ -0,0 +1,185 @@ +import path from "node:path"; +import { BUILTIN_ADAPTERS } from "../adapters/index.ts"; +import { commonArtifacts, enabledMcpServerEntries } from "../adapters/shared.ts"; +import { configuredTargets, findRepositoryRoot, loadConfig, targetOptions } from "./config.ts"; +import { CanonfigError } from "./errors.ts"; +import { createPlan } from "./planner.ts"; +import type { + BuildContext, + DesiredArtifact, + Diagnostic, + Feature, + HarnessAdapter, + HarnessDescriptor, + Plan, + SupportLevel, + TargetId, +} from "./types.ts"; +import { validateProject } from "./validation.ts"; + +export interface CompileOptions { + cwd?: string | undefined; + root?: string | undefined; + targets?: readonly TargetId[] | undefined; + strict?: boolean | undefined; + force?: boolean | undefined; + includeCommon?: boolean | undefined; +} + +export interface BuildProjectResult { + root: string; + configPath: string; + targets: TargetId[]; + artifacts: DesiredArtifact[]; + diagnostics: Diagnostic[]; +} + +const FEATURE_LEVEL_WEIGHT: Record = { + native: 0, + portable: 0, + translated: 1, + shim: 2, + lossy: 3, + unsupported: 4, +}; + +function usedFeatures(config: BuildContext["config"]): Feature[] { + const used: Feature[] = ["instructions"]; + if (config.instructions.rules.length > 0) used.push("rules"); + if (config.skills.roots.length > 0) used.push("skills"); + if (Object.keys(config.mcp.servers).length > 0) used.push("mcp"); + if (config.hooks.length > 0) used.push("hooks"); + if (config.agents.length > 0) used.push("agents"); + if (config.commands.length > 0) used.push("commands"); + if (config.permissions.rules.length > 0) used.push("permissions"); + return used; +} + +function compatibilityDiagnostics( + descriptor: HarnessDescriptor, + features: Feature[], + strict: boolean, +): Diagnostic[] { + return features.flatMap((feature): Diagnostic[] => { + const support = descriptor.capabilities[feature]; + if (support === "native" || support === "portable") return []; + const strictFailure = strict && FEATURE_LEVEL_WEIGHT[support] >= FEATURE_LEVEL_WEIGHT.shim; + const unsupported = support === "unsupported"; + return [{ + level: unsupported || strictFailure ? "error" : support === "translated" ? "info" : "warning", + code: `FEATURE_${support.toUpperCase()}`, + target: descriptor.id, + message: `${descriptor.name}: ${feature} support is ${support}.`, + }]; + }); +} + +function commonMcpProjectionDiagnostics(context: BuildContext): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const [name, server] of enabledMcpServerEntries(context)) { + const omitted: string[] = []; + if (server.timeoutMs !== undefined) omitted.push("timeoutMs"); + if (server.enabledTools?.length) omitted.push("enabledTools"); + if (server.disabledTools?.length) omitted.push("disabledTools"); + if (omitted.length > 0) { + diagnostics.push({ + level: "warning", + code: "MCP_OPTION_UNSUPPORTED", + path: ".mcp.json", + message: `.mcp.json cannot represent ${omitted.join(", ")} for MCP server ${name}; those options were omitted.`, + }); + } + } + return diagnostics; +} + +export class AdapterRegistry { + readonly #adapters = new Map(); + + constructor(adapters: readonly HarnessAdapter[] = []) { + for (const adapter of adapters) this.register(adapter); + } + + register(adapter: HarnessAdapter): this { + if (this.#adapters.has(adapter.descriptor.id)) { + throw new CanonfigError("ADAPTER_DUPLICATE", `An adapter is already registered for ${adapter.descriptor.id}.`); + } + this.#adapters.set(adapter.descriptor.id, adapter); + return this; + } + + replace(adapter: HarnessAdapter): this { + this.#adapters.set(adapter.descriptor.id, adapter); + return this; + } + + get(id: TargetId): HarnessAdapter { + const adapter = this.#adapters.get(id); + if (!adapter) throw new CanonfigError("ADAPTER_MISSING", `No adapter is registered for ${id}.`); + return adapter; + } + + list(): HarnessAdapter[] { + return [...this.#adapters.values()].sort((left, right) => left.descriptor.id.localeCompare(right.descriptor.id)); + } +} + +export function createDefaultRegistry(): AdapterRegistry { + return new AdapterRegistry(BUILTIN_ADAPTERS); +} + +export class HarnessConfigurationCompiler { + constructor(readonly registry: AdapterRegistry = createDefaultRegistry()) {} + + async build(options: CompileOptions = {}): Promise { + const root = options.root + ? path.resolve(options.root) + : await findRepositoryRoot(options.cwd ?? process.cwd()); + const loaded = await loadConfig(root); + const targets = [...new Set(options.targets ?? configuredTargets(loaded.config))]; + if (targets.length === 0) throw new CanonfigError("TARGET_EMPTY", "No enabled targets were selected."); + + const diagnostics = await validateProject(root, loaded.config); + const artifacts: DesiredArtifact[] = []; + const commonContext: BuildContext = { + root, + canonfigDir: path.join(root, ".canonfig"), + config: loaded.config, + target: targets[0]!, + targetOptions: {}, + }; + if (options.includeCommon !== false) { + artifacts.push(...await commonArtifacts(commonContext)); + diagnostics.push(...commonMcpProjectionDiagnostics(commonContext)); + } + + const features = usedFeatures(loaded.config); + for (const target of targets) { + const adapter = this.registry.get(target); + diagnostics.push(...compatibilityDiagnostics(adapter.descriptor, features, options.strict ?? false)); + const context: BuildContext = { + root, + canonfigDir: path.join(root, ".canonfig"), + config: loaded.config, + target, + targetOptions: targetOptions(loaded.config, target), + }; + const result = await adapter.build(context); + artifacts.push(...result.artifacts); + diagnostics.push(...result.diagnostics); + } + + return { root, configPath: loaded.path, targets, artifacts, diagnostics }; + } + + async plan(options: CompileOptions = {}): Promise { + const built = await this.build(options); + return createPlan( + built.root, + built.targets, + built.artifacts, + built.diagnostics, + { force: options.force ?? false }, + ); + } +} diff --git a/src/harness-configuration/core/config.ts b/src/harness-configuration/core/config.ts new file mode 100644 index 0000000..c51b616 --- /dev/null +++ b/src/harness-configuration/core/config.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import YAML from "yaml"; +import { CanonfigConfigSchema, type CanonfigConfig } from "./schema.ts"; +import { CanonfigError } from "./errors.ts"; +import { TARGET_IDS, type TargetId } from "./types.ts"; + +export const CANONFIG_DIR = ".canonfig"; +export const CONFIG_FILENAMES = ["harness.yaml", "harness.yml", "harness.json"] as const; +export const STATE_FILENAME = ".harness-state.json"; + +export async function findRepositoryRoot(start = process.cwd()): Promise { + let current = path.resolve(start); + while (true) { + for (const filename of CONFIG_FILENAMES) { + try { await fs.access(path.join(current, CANONFIG_DIR, filename)); return current; } catch { /* continue */ } + } + const parent = path.dirname(current); + if (parent === current) { + throw new CanonfigError("CONFIG_NOT_FOUND", `No ${CANONFIG_DIR}/harness.yaml found from ${path.resolve(start)} upward. Run \"canonfig harness init\" first.`); + } + current = parent; + } +} + +export async function findConfigFile(root: string): Promise { + for (const filename of CONFIG_FILENAMES) { + const candidate = path.join(root, CANONFIG_DIR, filename); + try { await fs.access(candidate); return candidate; } catch { /* continue */ } + } + throw new CanonfigError("CONFIG_NOT_FOUND", `Missing ${CANONFIG_DIR}/harness.yaml in ${root}`); +} + +export async function loadConfig(root: string): Promise<{ config: CanonfigConfig; path: string }> { + const configPath = await findConfigFile(root); + const raw = await fs.readFile(configPath, "utf8"); + let parsed: unknown; + try { parsed = configPath.endsWith(".json") ? JSON.parse(raw) : YAML.parse(raw); } + catch (error) { throw new CanonfigError("CONFIG_PARSE", `Could not parse ${configPath}: ${String(error)}`, error); } + + const result = CanonfigConfigSchema.safeParse(parsed); + if (!result.success) { + const details = result.error.issues.map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`).join("\n"); + throw new CanonfigError("CONFIG_INVALID", `Invalid ${path.relative(root, configPath)}:\n${details}`, result.error); + } + return { config: result.data, path: configPath }; +} + +export function configuredTargets(config: CanonfigConfig): TargetId[] { + if (Array.isArray(config.targets)) return [...config.targets]; + const targetMap = config.targets as Partial>; + return TARGET_IDS.filter((id) => targetMap[id]?.enabled === true); +} + +export function targetOptions(config: CanonfigConfig, target: TargetId): Record { + const targetMap = config.targets as Partial }>>; + const direct = Array.isArray(config.targets) ? {} : (targetMap[target]?.options ?? {}); + return { ...direct, ...(config.extensions[target] ?? {}) }; +} + +export function parseTargetList(input: string | undefined): TargetId[] | undefined { + if (!input) return undefined; + const values = input.split(",").map((value) => value.trim()).filter(Boolean); + const invalid = values.filter((value) => !TARGET_IDS.includes(value as TargetId)); + if (invalid.length > 0) throw new CanonfigError("TARGET_INVALID", `Unknown target(s): ${invalid.join(", ")}`); + return [...new Set(values)] as TargetId[]; +} diff --git a/src/harness-configuration/core/diff.ts b/src/harness-configuration/core/diff.ts new file mode 100644 index 0000000..7e36df4 --- /dev/null +++ b/src/harness-configuration/core/diff.ts @@ -0,0 +1,63 @@ +import type { Plan, PlanEntry } from "./types.ts"; + +interface DiffLine { prefix: " " | "+" | "-"; text: string; } + +function lineDiff(before: string, after: string): DiffLine[] { + const left = before.split("\n"); + const right = after.split("\n"); + const cells = (left.length + 1) * (right.length + 1); + if (cells > 1_500_000) { + return [ + ...left.map((text): DiffLine => ({ prefix: "-", text })), + ...right.map((text): DiffLine => ({ prefix: "+", text })), + ]; + } + + const table = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1)); + for (let i = left.length - 1; i >= 0; i -= 1) { + for (let j = right.length - 1; j >= 0; j -= 1) { + table[i]![j] = left[i] === right[j] + ? table[i + 1]![j + 1]! + 1 + : Math.max(table[i + 1]![j]!, table[i]![j + 1]!); + } + } + + const lines: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < left.length && j < right.length) { + if (left[i] === right[j]) { + lines.push({ prefix: " ", text: left[i]! }); + i += 1; + j += 1; + } else if (table[i + 1]![j]! >= table[i]![j + 1]!) { + lines.push({ prefix: "-", text: left[i]! }); + i += 1; + } else { + lines.push({ prefix: "+", text: right[j]! }); + j += 1; + } + } + while (i < left.length) lines.push({ prefix: "-", text: left[i++]! }); + while (j < right.length) lines.push({ prefix: "+", text: right[j++]! }); + return lines; +} + +function diffEntry(entry: PlanEntry): string { + const header = [`--- a/${entry.path}`, `+++ b/${entry.path}`]; + if (entry.action === "create") { + return [...header, ...(entry.after ?? "").split("\n").map((line) => `+${line}`)].join("\n"); + } + if (entry.action === "delete") { + return [...header, ...(entry.before ?? "").split("\n").map((line) => `-${line}`)].join("\n"); + } + if (entry.binary) return [...header, `Binary file ${entry.action}`].join("\n"); + return [...header, ...lineDiff(entry.before ?? "", entry.after ?? "").map((line) => `${line.prefix}${line.text}`)].join("\n"); +} + +export function formatPlanDiff(plan: Plan): string { + return plan.entries + .filter((entry) => entry.action !== "unchanged") + .map(diffEntry) + .join("\n\n"); +} diff --git a/src/harness-configuration/core/doctor.ts b/src/harness-configuration/core/doctor.ts new file mode 100644 index 0000000..c8d26a7 --- /dev/null +++ b/src/harness-configuration/core/doctor.ts @@ -0,0 +1,54 @@ +import { spawnSync } from "node:child_process"; +import type { HarnessDescriptor, TargetId } from "./types.ts"; +import type { AdapterRegistry } from "./compiler.ts"; + +export interface DoctorTargetResult { + id: TargetId; + name: string; + found: boolean; + executable?: string; + version?: string; + error?: string; + descriptor: HarnessDescriptor; +} + +function firstLine(value: string): string | undefined { + const line = value.split(/\r?\n/, 1)[0]?.trim(); + return line ? line : undefined; +} + +export function doctorTargets(registry: AdapterRegistry, targets?: readonly TargetId[]): DoctorTargetResult[] { + const selected = targets ? new Set(targets) : undefined; + return registry.list() + .filter((adapter) => !selected || selected.has(adapter.descriptor.id)) + .map((adapter): DoctorTargetResult => { + const descriptor = adapter.descriptor; + let lastError: string | undefined; + for (const executable of descriptor.executables) { + const result = spawnSync(executable, ["--version"], { + encoding: "utf8", + timeout: 4_000, + windowsHide: true, + }); + if (!result.error && result.status === 0) { + const version = firstLine(result.stdout) ?? firstLine(result.stderr); + return { + id: descriptor.id, + name: descriptor.name, + found: true, + executable, + ...(version ? { version } : {}), + descriptor, + }; + } + lastError = result.error?.message ?? firstLine(result.stderr) ?? `exit ${result.status ?? "unknown"}`; + } + return { + id: descriptor.id, + name: descriptor.name, + found: false, + ...(lastError ? { error: lastError } : {}), + descriptor, + }; + }); +} diff --git a/src/harness-configuration/core/errors.ts b/src/harness-configuration/core/errors.ts new file mode 100644 index 0000000..c1c9fec --- /dev/null +++ b/src/harness-configuration/core/errors.ts @@ -0,0 +1,10 @@ +export class CanonfigError extends Error { + readonly code: string; + readonly details?: unknown; + constructor(code: string, message: string, details?: unknown) { + super(message); this.name = "CanonfigError"; this.code = code; this.details = details; + } +} +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/harness-configuration/core/filesystem.ts b/src/harness-configuration/core/filesystem.ts new file mode 100644 index 0000000..185d8fc --- /dev/null +++ b/src/harness-configuration/core/filesystem.ts @@ -0,0 +1,157 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { CanonfigError } from "./errors.ts"; + +let temporarySequence = 0; + +function relativeInside(root: string, candidate: string): { root: string; relative: string } { + const resolvedRoot = path.resolve(root); + const resolvedCandidate = path.resolve(candidate); + const relative = path.relative(resolvedRoot, resolvedCandidate); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new CanonfigError("PATH_ESCAPE", `Path escapes repository root: ${candidate}`); + } + return { root: resolvedRoot, relative }; +} + +async function lstatOptional(filePath: string): Promise> | undefined> { + try { return await fs.lstat(filePath); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function openExclusiveTemporary( + filePath: string, + mode?: number, +): Promise<{ temporary: string; handle: Awaited> }> { + for (;;) { + const temporary = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${temporarySequence++}.tmp`, + ); + try { + return { temporary, handle: await fs.open(temporary, "wx", mode) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + } +} + +export async function assertNoSymlinkPathComponents(root: string, candidate: string): Promise { + const confined = relativeInside(root, candidate); + if (!confined.relative) return; + let current = confined.root; + for (const component of confined.relative.split(path.sep).filter(Boolean)) { + current = path.join(current, component); + const stats = await lstatOptional(current); + if (stats === undefined) return; + if (stats.isSymbolicLink()) { + throw new CanonfigError("SYMLINK_ESCAPE", `Path component is a symbolic link: ${current}`); + } + } +} + +export async function ensureDirectoryNoFollow(root: string, directory: string): Promise { + const confined = relativeInside(root, directory); + if (!confined.relative) return; + let current = confined.root; + for (const component of confined.relative.split(path.sep).filter(Boolean)) { + await assertNoSymlinkPathComponents(confined.root, current); + current = path.join(current, component); + let stats = await lstatOptional(current); + if (stats === undefined) { + try { await fs.mkdir(current); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + stats = await lstatOptional(current); + } + if (stats === undefined) { + throw new CanonfigError("PATH_CREATE_FAILED", `Failed to create directory: ${current}`); + } + if (stats.isSymbolicLink()) { + throw new CanonfigError("SYMLINK_ESCAPE", `Path component is a symbolic link: ${current}`); + } + if (!stats.isDirectory()) { + throw new CanonfigError("PATH_COMPONENT_NOT_DIRECTORY", `Path component is not a directory: ${current}`); + } + } +} + +export async function readOptionalFile(filePath: string): Promise { + try { return await fs.readFile(filePath); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export async function walkFiles(root: string): Promise { + const result: string[] = []; + async function visit(directory: string): Promise { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) result.push(path.relative(root, absolute)); + } + } + try { + await visit(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return result; +} + +export async function atomicWrite( + filePath: string, + content: string | Uint8Array, + mode?: number, + root?: string, +): Promise { + if (root === undefined) await fs.mkdir(path.dirname(filePath), { recursive: true }); + else await ensureDirectoryNoFollow(root, path.dirname(filePath)); + let temporary: string | undefined; + try { + if (root !== undefined) await assertNoSymlinkPathComponents(root, path.dirname(filePath)); + const opened = await openExclusiveTemporary(filePath, mode); + temporary = opened.temporary; + try { + await opened.handle.writeFile(content); + if (mode !== undefined) await opened.handle.chmod(mode); + } finally { + await opened.handle.close(); + } + if (root !== undefined) await assertNoSymlinkPathComponents(root, path.dirname(filePath)); + await fs.rename(temporary, filePath); + temporary = undefined; + } catch (error) { + if (temporary !== undefined) { + try { + if (root !== undefined) await assertNoSymlinkPathComponents(root, path.dirname(filePath)); + await fs.rm(temporary, { force: true }); + } catch { /* Preserve the original write error. */ } + } + throw error; + } +} + +export async function removeFileAndEmptyParents(filePath: string, stopAt: string): Promise { + await assertNoSymlinkPathComponents(stopAt, path.dirname(filePath)); + try { await fs.unlink(filePath); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + let current = path.dirname(filePath); + const stop = path.resolve(stopAt); + while (current !== stop) { + const relative = path.relative(stop, current); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) break; + try { + await assertNoSymlinkPathComponents(stop, current); + await fs.rmdir(current); + } catch { break; } + current = path.dirname(current); + } +} diff --git a/src/harness-configuration/core/frontmatter.ts b/src/harness-configuration/core/frontmatter.ts new file mode 100644 index 0000000..aba182e --- /dev/null +++ b/src/harness-configuration/core/frontmatter.ts @@ -0,0 +1,56 @@ +import YAML from "yaml"; + +export interface SkillFrontmatter { + name: string; + description: string; + license?: string; + compatibility?: string; + metadata?: Record; + "allowed-tools"?: string | string[]; +} + +export interface MarkdownDocument { data: Record; content: string; } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function parseMarkdownDocument(source: string): MarkdownDocument { + const normalized = source.replaceAll("\r\n", "\n"); + if (!normalized.startsWith("---\n")) return { data: {}, content: normalized.trim() }; + const body = normalized.slice(4); + const match = /^---[ \t]*(?:\n|$)/mu.exec(body); + if (!match) throw new Error("Unterminated YAML frontmatter block."); + const rawData = body.slice(0, match.index); + const parsed = rawData.trim() === "" ? {} : (YAML.parse(rawData) as unknown); + if (!isRecord(parsed)) throw new Error("YAML frontmatter must be an object."); + return { data: parsed, content: body.slice(match.index + match[0].length).trim() }; +} + +export function parseSkill(source: string): { data: SkillFrontmatter; content: string } { + const parsed = parseMarkdownDocument(source); + const { data } = parsed; + if (typeof data.name !== "string" || data.name.length === 0) throw new Error("Skill frontmatter requires a non-empty name."); + if (typeof data.description !== "string" || data.description.length === 0) throw new Error("Skill frontmatter requires a non-empty description."); + if (data.license !== undefined && typeof data.license !== "string") throw new Error("Skill license must be a string."); + if (data.compatibility !== undefined && typeof data.compatibility !== "string") throw new Error("Skill compatibility must be a string."); + if (data.metadata !== undefined && !isRecord(data.metadata)) throw new Error("Skill metadata must be an object."); + const allowedTools = data["allowed-tools"]; + if (allowedTools !== undefined && typeof allowedTools !== "string" && !(Array.isArray(allowedTools) && allowedTools.every((item) => typeof item === "string"))) { + throw new Error("Skill allowed-tools must be a string or an array of strings."); + } + const result: SkillFrontmatter = { + name: data.name, + description: data.description, + ...(typeof data.license === "string" ? { license: data.license } : {}), + ...(typeof data.compatibility === "string" ? { compatibility: data.compatibility } : {}), + ...(isRecord(data.metadata) ? { metadata: data.metadata } : {}), + ...(typeof allowedTools === "string" || Array.isArray(allowedTools) ? { "allowed-tools": allowedTools as string | string[] } : {}), + }; + return { data: result, content: parsed.content }; +} + +export function markdownWithFrontmatter(data: Record, content: string): string { + const frontmatter = YAML.stringify(data, { lineWidth: 0 }).trimEnd(); + return `---\n${frontmatter}\n---\n${content.trim()}\n`; +} diff --git a/src/harness-configuration/core/hash.ts b/src/harness-configuration/core/hash.ts new file mode 100644 index 0000000..db1b0ad --- /dev/null +++ b/src/harness-configuration/core/hash.ts @@ -0,0 +1,4 @@ +import { createHash } from "node:crypto"; +export function sha256(value: string | Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/src/harness-configuration/core/path.ts b/src/harness-configuration/core/path.ts new file mode 100644 index 0000000..d4f73b6 --- /dev/null +++ b/src/harness-configuration/core/path.ts @@ -0,0 +1,51 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { CanonfigError } from "./errors.ts"; +export function toPosix(value: string): string { return value.replaceAll("\\", "/").split(path.sep).join("/"); } +export function assertSafeRelativePath(value: string): string { + const normalized = path.posix.normalize(toPosix(value)); + if ( + normalized === "." + || normalized === ".." + || normalized.startsWith("../") + || normalized.includes("/../") + || path.posix.isAbsolute(normalized) + || /^[A-Za-z]:\//u.test(normalized) + ) { + throw new CanonfigError("UNSAFE_PATH", `Unsafe repository-relative path: ${value}`); + } + return normalized; +} +export function resolveInside(root: string, relativePath: string): string { + const safe = assertSafeRelativePath(relativePath); + const resolved = path.resolve(root, safe); + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new CanonfigError("PATH_ESCAPE", `Path escapes repository root: ${relativePath}`); + return resolved; +} + + +function assertAbsoluteInside(realRoot: string, realCandidate: string, original: string): void { + const relative = path.relative(realRoot, realCandidate); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new CanonfigError("SYMLINK_ESCAPE", `Path resolves outside repository root: ${original}`); + } +} + +/** Verify an existing path, or its nearest existing ancestor, remains inside root after symlink resolution. */ +export async function assertRealPathInside(root: string, candidate: string): Promise { + const rootReal = await fs.realpath(root); + let current = candidate; + while (true) { + try { + const resolved = await fs.realpath(current); + assertAbsoluteInside(rootReal, resolved, candidate); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} diff --git a/src/harness-configuration/core/planner.ts b/src/harness-configuration/core/planner.ts new file mode 100644 index 0000000..2b97c2d --- /dev/null +++ b/src/harness-configuration/core/planner.ts @@ -0,0 +1,314 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { Buffer } from "node:buffer"; +import type { + ArtifactOwner, + ArtifactState, + CanonfigState, + DesiredArtifact, + Diagnostic, + Plan, + PlanEntry, + TargetId, +} from "./types.ts"; +import { assertRealPathInside, assertSafeRelativePath, resolveInside } from "./path.ts"; +import { + assertNoSymlinkPathComponents, + atomicWrite, + ensureDirectoryNoFollow, + readOptionalFile, + removeFileAndEmptyParents, +} from "./filesystem.ts"; +import { loadState, writeState, HARNESS_CONFIGURATION_VERSION } from "./state.ts"; +import { renderArtifacts } from "./render.ts"; +import { sha256 } from "./hash.ts"; +import { CanonfigError } from "./errors.ts"; + +export interface PlanOptions { force?: boolean; } + +function bytesEqual(left: Uint8Array | undefined, right: string | Uint8Array | undefined): boolean { + if (left === undefined || right === undefined) return left === undefined && right === undefined; + const rightBytes = typeof right === "string" ? Buffer.from(right) : Buffer.from(right); + return Buffer.from(left).equals(rightBytes); +} + +function isTextArtifacts(artifacts: readonly DesiredArtifact[]): boolean { + return artifacts.every((artifact) => artifact.kind !== "replace" || typeof artifact.content === "string"); +} + +function selectedOwner(owner: ArtifactOwner, targets: readonly TargetId[]): boolean { + return owner === "common" || targets.includes(owner); +} + +function ownerFor(artifacts: readonly DesiredArtifact[], diagnostics: Diagnostic[], filePath: string): ArtifactOwner { + const owners = [...new Set(artifacts.map((artifact) => artifact.owner))]; + if (owners.length > 1) { + diagnostics.push({ + level: "error", + code: "ARTIFACT_OWNER_COLLISION", + message: `Multiple owners target ${filePath}: ${owners.join(", ")}`, + path: filePath, + }); + } + return owners[0] ?? "common"; +} + +function modeFor(artifacts: readonly DesiredArtifact[]): number | undefined { + const modes = artifacts.flatMap((artifact) => artifact.kind === "replace" && artifact.mode !== undefined ? [artifact.mode] : []); + return modes[0]; +} + +export async function createPlan( + root: string, + targets: TargetId[], + artifacts: DesiredArtifact[], + diagnostics: Diagnostic[] = [], + options: PlanOptions = {}, +): Promise { + const previousState = await loadState(root); + const nextArtifacts: Record = {}; + for (const [filePath, state] of Object.entries(previousState.artifacts)) { + if (!selectedOwner(state.owner, targets)) nextArtifacts[filePath] = state; + } + + const groups = new Map(); + for (const artifact of artifacts) { + const safePath = assertSafeRelativePath(artifact.path); + const list = groups.get(safePath) ?? []; + list.push({ ...artifact, path: safePath } as DesiredArtifact); + groups.set(safePath, list); + } + + const entries: PlanEntry[] = []; + for (const [filePath, group] of [...groups.entries()].sort(([left], [right]) => left.localeCompare(right))) { + const absolute = resolveInside(root, filePath); + await assertRealPathInside(root, absolute); + const currentBytes = await readOptionalFile(absolute); + const current = isTextArtifacts(group) && currentBytes !== undefined ? Buffer.from(currentBytes).toString("utf8") : currentBytes; + const previous = previousState.artifacts[filePath]; + const rendered = renderArtifacts(group, current, previous, options.force ?? false); + const owner = ownerFor(group, diagnostics, filePath); + const mode = modeFor(group); + + if (rendered.conflicts.length > 0) { + entries.push({ + path: filePath, + owner, + action: "conflict", + reason: rendered.conflicts.join(" "), + before: typeof current === "string" ? current : undefined, + after: typeof rendered.content === "string" ? rendered.content : undefined, + content: rendered.content, + binary: rendered.content instanceof Uint8Array, + ...(mode === undefined ? {} : { mode }), + }); + if (previous) nextArtifacts[filePath] = previous; + continue; + } + + if (rendered.content === undefined) { + entries.push({ path: filePath, owner, action: currentBytes === undefined ? "unchanged" : "delete" }); + continue; + } + + const contentHash = sha256(rendered.content); + const unmanagedIdenticalReplace = + previous === undefined && + currentBytes !== undefined && + group.length === 1 && + group[0]?.kind === "replace" && + contentHash === sha256(currentBytes); + + let nextState: ArtifactState | undefined; + if (!unmanagedIdenticalReplace) { + nextState = { + owner, + hash: contentHash, + existedBefore: previous?.existedBefore ?? currentBytes !== undefined, + cleanup: rendered.cleanup, + ...(mode === undefined ? {} : { mode }), + }; + nextArtifacts[filePath] = nextState; + } else { + diagnostics.push({ + level: "info", + code: "UNMANAGED_IDENTICAL", + message: `${filePath} already matches generated output; Canonfig left ownership unchanged.`, + path: filePath, + }); + } + + const action = currentBytes === undefined ? "create" : bytesEqual(currentBytes, rendered.content) ? "unchanged" : "update"; + entries.push({ + path: filePath, + owner, + action, + before: typeof current === "string" ? current : undefined, + after: typeof rendered.content === "string" ? rendered.content : undefined, + content: rendered.content, + binary: rendered.content instanceof Uint8Array, + ...(mode === undefined ? {} : { mode }), + ...(nextState === undefined ? {} : { nextState }), + }); + } + + const desiredPaths = new Set(groups.keys()); + for (const [filePath, previous] of Object.entries(previousState.artifacts)) { + if (desiredPaths.has(filePath) || !selectedOwner(previous.owner, targets)) continue; + const absolute = resolveInside(root, filePath); + await assertRealPathInside(root, absolute); + const currentBytes = await readOptionalFile(absolute); + const current = currentBytes === undefined ? undefined : Buffer.from(currentBytes).toString("utf8"); + const rendered = renderArtifacts([], current, previous, options.force ?? false); + if (rendered.conflicts.length > 0) { + entries.push({ + path: filePath, + owner: previous.owner, + action: "conflict", + reason: rendered.conflicts.join(" "), + before: current, + after: typeof rendered.content === "string" ? rendered.content : undefined, + }); + nextArtifacts[filePath] = previous; + continue; + } + + const cleaned = previous.existedBefore ? rendered.content : undefined; + if (cleaned === undefined) { + entries.push({ path: filePath, owner: previous.owner, action: currentBytes === undefined ? "unchanged" : "delete", before: current }); + } else { + const action = bytesEqual(currentBytes, cleaned) ? "unchanged" : currentBytes === undefined ? "create" : "update"; + entries.push({ + path: filePath, + owner: previous.owner, + action, + before: current, + after: typeof cleaned === "string" ? cleaned : undefined, + content: cleaned, + binary: cleaned instanceof Uint8Array, + ...(previous.mode === undefined ? {} : { mode: previous.mode }), + }); + } + } + + const nextState: CanonfigState = { + version: 1, + generatedAt: new Date().toISOString(), + canonfigVersion: HARNESS_CONFIGURATION_VERSION, + artifacts: Object.fromEntries(Object.entries(nextArtifacts).sort(([left], [right]) => left.localeCompare(right))), + }; + return { root, targets, entries: entries.sort((a, b) => a.path.localeCompare(b.path)), diagnostics, nextState }; +} + +interface MissingFileSnapshot { + path: string; + kind: "missing"; +} + +interface RegularFileSnapshot { + path: string; + kind: "file"; + content: Uint8Array; + mode: number; +} + +interface SymlinkSnapshot { + path: string; + kind: "symlink"; + linkTarget: string; +} + +type FileSnapshot = MissingFileSnapshot | RegularFileSnapshot | SymlinkSnapshot; + +async function snapshotFile(root: string, relativePath: string): Promise { + const absolute = resolveInside(root, relativePath); + try { + const stats = await fs.lstat(absolute); + if (stats.isSymbolicLink()) { + return { path: relativePath, kind: "symlink", linkTarget: await fs.readlink(absolute) }; + } + return { + path: relativePath, + kind: "file", + content: await fs.readFile(absolute), + mode: stats.mode & 0o777, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path: relativePath, kind: "missing" }; + throw error; + } +} + +async function restoreSnapshots(root: string, snapshots: readonly FileSnapshot[]): Promise { + const failures: Array<{ path: string; error: unknown }> = []; + for (const snapshot of [...snapshots].reverse()) { + const absolute = resolveInside(root, snapshot.path); + try { + if (snapshot.kind === "missing") { + await removeFileAndEmptyParents(absolute, root); + } else if (snapshot.kind === "symlink") { + const parent = path.dirname(absolute); + await ensureDirectoryNoFollow(root, parent); + await assertNoSymlinkPathComponents(root, parent); + await fs.rm(absolute, { force: true }); + await assertNoSymlinkPathComponents(root, parent); + await fs.symlink(snapshot.linkTarget, absolute); + } else { + await atomicWrite(absolute, snapshot.content, snapshot.mode, root); + } + } catch (error) { + failures.push({ path: snapshot.path, error }); + } + } + if (failures.length > 0) { + throw new Error( + `Failed to restore ${failures.length} snapshot(s): ${failures.map(({ path: filePath, error }) => `${filePath}: ${String(error)}`).join("; ")}`, + ); + } +} + +export async function applyPlan(plan: Plan): Promise { + const conflicts = plan.entries.filter((entry) => entry.action === "conflict"); + const errors = plan.diagnostics.filter((diagnostic) => diagnostic.level === "error"); + if (conflicts.length > 0 || errors.length > 0) { + throw new CanonfigError("PLAN_CONFLICT", `Plan has ${conflicts.length} conflict(s) and ${errors.length} error diagnostic(s).`); + } + + const mutableEntries = plan.entries.filter((entry) => + entry.action === "create" || entry.action === "update" || entry.action === "delete" + ); + const snapshots: FileSnapshot[] = []; + for (const entry of mutableEntries) { + const absolute = resolveInside(plan.root, entry.path); + await assertRealPathInside(plan.root, absolute); + snapshots.push(await snapshotFile(plan.root, entry.path)); + } + + try { + for (const entry of mutableEntries) { + const absolute = resolveInside(plan.root, entry.path); + if (entry.action === "create" || entry.action === "update") { + if (entry.after === undefined && !entry.binary) { + throw new CanonfigError("PLAN_INVALID", `Missing output content for ${entry.path}`); + } + const content = entry.content ?? entry.after; + if (content === undefined) throw new CanonfigError("PLAN_INVALID", `Missing output content for ${entry.path}`); + await atomicWrite(absolute, content, entry.mode, plan.root); + } else { + await removeFileAndEmptyParents(absolute, plan.root); + } + } + await writeState(plan.root, plan.nextState); + } catch (error) { + try { + await restoreSnapshots(plan.root, snapshots); + } catch (rollbackError) { + throw new CanonfigError( + "APPLY_ROLLBACK_FAILED", + `Harness apply failed and rollback also failed: ${String(rollbackError)}`, + error, + ); + } + throw error; + } +} diff --git a/src/harness-configuration/core/render-cleanup.ts b/src/harness-configuration/core/render-cleanup.ts new file mode 100644 index 0000000..d214898 --- /dev/null +++ b/src/harness-configuration/core/render-cleanup.ts @@ -0,0 +1,125 @@ +import { sha256 } from "./hash.ts"; +import { restoreJsonCleanup } from "./render-json.ts"; +import { + commentMarkers, + locateBlock, + parseJsonDocument, + serializeJsonDocument, + tomlBlockMarkers, +} from "./render-utils.ts"; +import type { + ArtifactState, + CleanupInstruction, +} from "./types.ts"; + +function removeManagedText( + text: string, + cleanup: Extract, + force: boolean, + conflicts: string[], +): string { + const markers = commentMarkers(cleanup.marker, cleanup.comments); + const located = locateBlock(text, markers.begin, markers.end); + if (located === undefined) { + conflicts.push(`Managed block ${cleanup.marker} is missing.`); + return text; + } + if (sha256(located.block) !== cleanup.blockHash && !force) { + conflicts.push(`Managed block ${cleanup.marker} was edited outside Canonfig.`); + return text; + } + return `${text.slice(0, located.start)}${text.slice(located.end)}` + .replace(/^\s+$/u, ""); +} + +function removeTomlBlock( + text: string, + cleanup: Extract, + force: boolean, + conflicts: string[], +): string { + const markers = tomlBlockMarkers(cleanup.marker); + const located = locateBlock(text, markers.begin, markers.end); + if (located === undefined) { + conflicts.push(`Managed TOML block ${cleanup.marker} is missing.`); + return text; + } + if (sha256(located.block) !== cleanup.blockHash && !force) { + conflicts.push(`Managed TOML block ${cleanup.marker} was edited outside Canonfig.`); + return text; + } + return `${text.slice(0, located.start)}${text.slice(located.end)}`; +} + +function removeTomlKey( + text: string, + cleanup: Extract, + conflicts: string[], +): string { + const lines = text.split(/\r?\n/u); + const marker = `# canonfig:key ${cleanup.marker}`; + const index = lines.findIndex((line) => line.includes(marker)); + if (index < 0) { + conflicts.push( + `Managed TOML key ${cleanup.section}.${cleanup.key} is missing.`, + ); + return text; + } + if (cleanup.originalLine !== undefined) lines[index] = cleanup.originalLine; + else lines.splice(index, 1); + return lines.join("\n"); +} + +export function unapplyPrevious( + current: string | Uint8Array | undefined, + previous: ArtifactState | undefined, + force: boolean, + conflicts: string[], +): string | Uint8Array | undefined { + if (previous === undefined) return current; + let output = current; + const jsonCleanups = previous.cleanup.filter((cleanup) => + cleanup.kind.startsWith("json-") + ); + let jsonDocument: Record | undefined; + if (jsonCleanups.length > 0 && typeof output === "string" && output.trim() !== "") { + jsonDocument = parseJsonDocument(output, conflicts); + } + + for (const cleanup of previous.cleanup) { + if (cleanup.kind === "replace") { + if (output === undefined) continue; + if (sha256(output) !== previous.hash && !force) { + conflicts.push("Generated file was edited outside Canonfig."); + continue; + } + output = undefined; + continue; + } + if (output instanceof Uint8Array) { + conflicts.push("Cannot merge text cleanup into a binary file."); + continue; + } + if ( + cleanup.kind === "json-managed-map" + || cleanup.kind === "json-managed-array" + || cleanup.kind === "json-managed-hooks" + ) { + if (jsonDocument !== undefined) { + restoreJsonCleanup(jsonDocument, cleanup, force, conflicts); + } + continue; + } + const text = output ?? ""; + if (cleanup.kind === "managed-text") { + output = removeManagedText(text, cleanup, force, conflicts); + } else if (cleanup.kind === "toml-block") { + output = removeTomlBlock(text, cleanup, force, conflicts); + } else { + output = removeTomlKey(text, cleanup, conflicts); + } + } + + if (jsonDocument !== undefined && typeof output === "string") output = serializeJsonDocument(jsonDocument); + return output; +} diff --git a/src/harness-configuration/core/render-json.ts b/src/harness-configuration/core/render-json.ts new file mode 100644 index 0000000..5a3bddb --- /dev/null +++ b/src/harness-configuration/core/render-json.ts @@ -0,0 +1,230 @@ +import type { + CleanupInstruction, + DesiredArtifact, + JsonManagedMapCleanup, +} from "./types.ts"; +import { + containsMarker, + deepEqual, + getAtPath, + identityOf, + isRecord, + parseJsonDocument, + serializeJsonDocument, + setAtPath, +} from "./render-utils.ts"; + +export function restoreJsonCleanup( + document: Record, + cleanup: Exclude< + CleanupInstruction, + { kind: "replace" | "managed-text" | "toml-block" | "toml-key" } + >, + force: boolean, + conflicts: string[], +): void { + if (cleanup.kind === "json-managed-map") { + for (const [key, expected] of Object.entries(cleanup.entries)) { + const currentMap = getAtPath(document, cleanup.path); + const current = isRecord(currentMap) ? currentMap[key] : undefined; + if (!deepEqual(current, expected) && !force) { + conflicts.push( + `Managed JSON entry ${[...cleanup.path, key].join(".")} was edited outside Canonfig.`, + ); + continue; + } + const original = cleanup.originals[key]; + setAtPath( + document, + [...cleanup.path, key], + original?.existed ? original.value : undefined, + ); + } + return; + } + if (cleanup.kind === "json-managed-array") { + const current = getAtPath(document, cleanup.path); + if (!Array.isArray(current)) return; + const remaining = [...current]; + for (const expected of cleanup.values) { + const expectedIdentity = identityOf(expected, cleanup.identity); + const index = remaining.findIndex((candidate) => cleanup.identity === undefined + ? deepEqual(candidate, expected) + : deepEqual(identityOf(candidate, cleanup.identity), expectedIdentity)); + if (index >= 0) remaining.splice(index, 1); + } + setAtPath(document, cleanup.path, remaining); + return; + } + + const current = getAtPath(document, cleanup.path); + if (!isRecord(current)) return; + const next: Record = {}; + const managedEvents = cleanup.events === undefined + ? undefined + : new Set(cleanup.events); + for (const [event, entries] of Object.entries(current)) { + if (managedEvents !== undefined && !managedEvents.has(event)) { + next[event] = entries; + continue; + } + const filtered = Array.isArray(entries) + ? entries.filter((entry) => !containsMarker(entry, cleanup.marker)) + : entries; + const original = cleanup.originals?.[event]; + if (original?.existed === true && !Array.isArray(filtered)) next[event] = original.value; + else if (Array.isArray(filtered) && filtered.length > 0) next[event] = filtered; + else if (original?.existed === true) next[event] = original.value; + } + setAtPath(document, cleanup.path, next); +} + +export function applyJsonArtifact( + input: string, + artifact: Extract, + force: boolean, + conflicts: string[], +): { text: string; cleanup: CleanupInstruction[] } { + const document = parseJsonDocument(input.trim() === "" ? "{}" : input, conflicts); + const cleanup: CleanupInstruction[] = []; + const appliedRootDefaults: Record = {}; + const rootDefaultOriginals: JsonManagedMapCleanup["originals"] = {}; + + for (const [key, value] of Object.entries(artifact.rootDefaults ?? {})) { + if (document[key] === undefined) { + rootDefaultOriginals[key] = { existed: false }; + document[key] = value; + appliedRootDefaults[key] = value; + } + } + if (Object.keys(appliedRootDefaults).length > 0) { + cleanup.push({ + kind: "json-managed-map", + path: [], + entries: appliedRootDefaults, + originals: rootDefaultOriginals, + }); + } + + for (const operation of artifact.operations) { + if (operation.kind === "defaults") { + for (const entry of operation.entries) { + if (getAtPath(document, entry.path) === undefined) { + setAtPath(document, entry.path, entry.value); + } + } + continue; + } + if (operation.kind === "managed-map") { + const map = getAtPath(document, operation.path); + if (map !== undefined && !isRecord(map) && !force) { + conflicts.push(`JSON path ${operation.path.join(".")} is not an object and is not owned by Canonfig.`); + continue; + } + const object = isRecord(map) ? map : {}; + const originals: JsonManagedMapCleanup["originals"] = {}; + const applied: Record = {}; + for (const [key, value] of Object.entries(operation.entries)) { + const existed = Object.prototype.hasOwnProperty.call(object, key); + const current = object[key]; + originals[key] = existed + ? { existed: true, value: current } + : { existed: false }; + if ( + existed + && !deepEqual(current, value) + && operation.collision !== "replace" + && !force + ) { + conflicts.push( + `JSON entry ${[...operation.path, key].join(".")} already exists and is not owned by Canonfig.`, + ); + continue; + } + setAtPath(document, [...operation.path, key], value); + applied[key] = value; + } + if (Object.keys(applied).length > 0) { + cleanup.push({ + kind: "json-managed-map", + path: [...operation.path], + entries: applied, + originals, + }); + } + continue; + } + if (operation.kind === "managed-array") { + const found = getAtPath(document, operation.path); + if (found !== undefined && !Array.isArray(found) && !force) { + conflicts.push(`JSON path ${operation.path.join(".")} is not an array and is not owned by Canonfig.`); + continue; + } + const values = Array.isArray(found) ? [...found] : []; + const added: unknown[] = []; + for (const value of operation.values) { + const identity = identityOf(value, operation.identity); + const existingIndex = values.findIndex((candidate) => + operation.identity === undefined + ? deepEqual(candidate, value) + : deepEqual(identityOf(candidate, operation.identity), identity) + ); + if (existingIndex < 0) { + values.push(value); + added.push(value); + } else if ( + operation.identity !== undefined + && !deepEqual(values[existingIndex], value) + ) { + if (!force) { + conflicts.push( + `Array entry ${operation.path.join(".")} with ${operation.identity}=${String(identity)} already exists.`, + ); + } else { + values[existingIndex] = value; + } + } + } + setAtPath(document, operation.path, values); + if (added.length > 0) { + cleanup.push({ + kind: "json-managed-array", + path: [...operation.path], + values: added, + ...(operation.identity === undefined ? {} : { identity: operation.identity }), + }); + } + continue; + } + + const existingValue = getAtPath(document, operation.path); + if (existingValue !== undefined && !isRecord(existingValue) && !force) { + conflicts.push(`JSON path ${operation.path.join(".")} is not an object and is not owned by Canonfig.`); + continue; + } + const existing = isRecord(existingValue) ? existingValue : {}; + const next: Record = {}; + for (const [event, entries] of Object.entries(existing)) { + next[event] = Array.isArray(entries) + ? entries.filter((entry) => !containsMarker(entry, operation.marker)) + : entries; + } + const originals: Record = {}; + for (const [event, entries] of Object.entries(operation.hooks)) { + originals[event] = Object.prototype.hasOwnProperty.call(existing, event) + ? { existed: true, value: existing[event] } + : { existed: false }; + const current = Array.isArray(next[event]) ? next[event] as unknown[] : []; + next[event] = [...current, ...entries]; + } + setAtPath(document, operation.path, next); + cleanup.push({ + kind: "json-managed-hooks", + path: [...operation.path], + marker: operation.marker, + events: Object.keys(operation.hooks), + originals, + }); + } + return { text: serializeJsonDocument(document), cleanup }; +} diff --git a/src/harness-configuration/core/render-text.ts b/src/harness-configuration/core/render-text.ts new file mode 100644 index 0000000..4ca035a --- /dev/null +++ b/src/harness-configuration/core/render-text.ts @@ -0,0 +1,168 @@ +import { sha256 } from "./hash.ts"; +import { + commentMarkers, + findTomlSection, + locateBlock, + tomlBlockMarkers, +} from "./render-utils.ts"; +import type { + CleanupInstruction, + DesiredArtifact, + ManagedTextArtifact, + TomlEnsureKey, +} from "./types.ts"; + +export function appendManagedText( + text: string, + artifact: ManagedTextArtifact, + force: boolean, + conflicts: string[], +): { text: string; cleanup: CleanupInstruction } { + const markers = commentMarkers(artifact.marker, artifact.comments); + const existing = locateBlock(text, markers.begin, markers.end); + if (existing !== undefined) { + if (!force) { + conflicts.push(`An unmanaged block already uses marker ${artifact.marker}.`); + } + text = `${text.slice(0, existing.start)}${text.slice(existing.end)}`; + } + const block = `${markers.begin}\n${artifact.content.trim()}\n${markers.end}\n`; + const separator = text.trim() === "" + ? "" + : text.endsWith("\n\n") + ? "" + : text.endsWith("\n") + ? "\n" + : "\n\n"; + return { + text: artifact.placement === "start" + ? `${block}${separator}${text}` + : `${text}${separator}${block}`, + cleanup: { + kind: "managed-text", + marker: artifact.marker, + comments: artifact.comments, + blockHash: sha256(block), + }, + }; +} + +function parseTomlLiteralLine(line: string, key: string): string | undefined { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = line.match( + new RegExp(`^\\s*${escaped}\\s*=\\s*(.*?)\\s*(?:#.*)?$`, "u"), + ); + return match?.[1]?.trim(); +} + +function applyTomlEnsureKey( + input: string, + ensure: TomlEnsureKey, + force: boolean, + conflicts: string[], +): { text: string; cleanup?: CleanupInstruction } { + const lines = input.replace(/\r\n/gu, "\n").split("\n"); + let section = findTomlSection(lines, ensure.section); + if (section === undefined) { + if (lines.length > 0 && lines.at(-1) !== "") lines.push(""); + lines.push(`[${ensure.section}]`); + section = { header: lines.length - 1, end: lines.length }; + } + const escaped = ensure.key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const keyPattern = new RegExp(`^\\s*${escaped}\\s*=`, "u"); + let keyIndex = -1; + for (let index = section.header + 1; index < section.end; index += 1) { + if (keyPattern.test(lines[index] ?? "")) { + keyIndex = index; + break; + } + } + const marker = `# canonfig:key ${ensure.marker}`; + const desiredLine = `${ensure.key} = ${ensure.value} ${marker}`; + if (keyIndex >= 0) { + const currentLine = lines[keyIndex] ?? ""; + if (parseTomlLiteralLine(currentLine, ensure.key) === ensure.value) { + return { text: lines.join("\n") }; + } + if (ensure.collision !== "replace" && !force) { + conflicts.push(`TOML key ${ensure.section}.${ensure.key} already exists and differs.`); + return { text: lines.join("\n") }; + } + lines[keyIndex] = desiredLine; + return { + text: lines.join("\n"), + cleanup: { + kind: "toml-key", + section: ensure.section, + key: ensure.key, + marker: ensure.marker, + originalLine: currentLine, + }, + }; + } + lines.splice(section.end, 0, desiredLine); + return { + text: lines.join("\n"), + cleanup: { + kind: "toml-key", + section: ensure.section, + key: ensure.key, + marker: ensure.marker, + }, + }; +} + +export function applyTomlArtifact( + input: string, + artifact: Extract, + force: boolean, + conflicts: string[], +): { text: string; cleanup: CleanupInstruction[] } { + let text = input.replace(/\r\n/gu, "\n"); + const cleanup: CleanupInstruction[] = []; + for (const ensure of artifact.ensureKeys ?? []) { + const result = applyTomlEnsureKey(text, ensure, force, conflicts); + text = result.text; + if (result.cleanup !== undefined) cleanup.push(result.cleanup); + } + for (const managed of artifact.blocks ?? []) { + const markers = tomlBlockMarkers(managed.marker); + const existing = locateBlock(text, markers.begin, markers.end); + if (existing !== undefined) { + if (!force) { + conflicts.push( + `An unmanaged TOML block already uses marker ${managed.marker}.`, + ); + } + text = `${text.slice(0, existing.start)}${text.slice(existing.end)}`; + } + const sections = [...managed.content.matchAll(/^\s*\[([^\]]+)\]\s*$/gmu)] + .map((match) => match[1]) + .filter((section): section is string => section !== undefined); + for (const section of sections) { + if (findTomlSection(text.split("\n"), section) !== undefined) { + conflicts.push( + `TOML section [${section}] already exists outside Canonfig's managed block.`, + ); + } + } + const block = `${markers.begin}\n${managed.content.trim()}\n${markers.end}\n`; + const separator = text.trim() === "" + ? "" + : text.endsWith("\n\n") + ? "" + : text.endsWith("\n") + ? "\n" + : "\n\n"; + text = `${text}${separator}${block}`; + cleanup.push({ + kind: "toml-block", + marker: managed.marker, + blockHash: sha256(block), + }); + } + return { + text: text.endsWith("\n") ? text : `${text}\n`, + cleanup, + }; +} diff --git a/src/harness-configuration/core/render-utils.ts b/src/harness-configuration/core/render-utils.ts new file mode 100644 index 0000000..d0cbd0f --- /dev/null +++ b/src/harness-configuration/core/render-utils.ts @@ -0,0 +1,213 @@ +import type { JsonPath, ManagedTextArtifact } from "./types.ts"; + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length + && left.every((value, index) => deepEqual(value, right[index])); + } + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return deepEqual(leftKeys, rightKeys) + && leftKeys.every((key) => deepEqual(left[key], right[key])); + } + return false; +} + +function stripJsonComments(text: string): string { + let output = ""; + let index = 0; + let inString = false; + let escaped = false; + while (index < text.length) { + const character = text[index]!; + const next = text[index + 1]; + if (inString) { + output += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + index += 1; + continue; + } + if (character === '"') { + inString = true; + output += character; + index += 1; + continue; + } + if (character === "/" && next === "/") { + while (index < text.length && text[index] !== "\n") index += 1; + output += "\n"; + index += 1; + continue; + } + if (character === "/" && next === "*") { + index += 2; + while ( + index + 1 < text.length + && !(text[index] === "*" && text[index + 1] === "/") + ) { + if (text[index] === "\n") output += "\n"; + index += 1; + } + index += 2; + continue; + } + output += character; + index += 1; + } + return output; +} + +function removeTrailingCommas(text: string): string { + let output = ""; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (inString) { + output += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + output += character; + continue; + } + if (character === ",") { + let cursor = index + 1; + while (cursor < text.length && /\s/u.test(text[cursor]!)) cursor += 1; + if (text[cursor] === "}" || text[cursor] === "]") continue; + } + output += character; + } + return output; +} + +export function parseJsonDocument( + text: string, + conflicts: string[], +): Record { + try { + const parsed = JSON.parse(removeTrailingCommas(stripJsonComments(text))) as unknown; + if (!isRecord(parsed)) { + conflicts.push("JSON/JSONC root must be an object."); + return {}; + } + return parsed; + } catch (error) { + conflicts.push( + `Invalid JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`, + ); + return {}; + } +} + +export function serializeJsonDocument(document: Record): string { + return `${JSON.stringify(document, undefined, 2)}\n`; +} + +export function getAtPath(root: unknown, path: JsonPath): unknown { + let current = root; + for (const segment of path) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +export function setAtPath( + root: Record, + path: JsonPath, + value: unknown, +): void { + if (path.length === 0) { + if (!isRecord(value)) throw new TypeError("JSON root replacement must be an object"); + for (const key of Object.keys(root)) delete root[key]; + Object.assign(root, value); + return; + } + let parent = root; + for (const segment of path.slice(0, -1)) { + const current = parent[segment]; + if (!isRecord(current)) parent[segment] = {}; + parent = parent[segment] as Record; + } + const key = path[path.length - 1]!; + if (value === undefined) delete parent[key]; + else parent[key] = value; +} + +export function commentMarkers( + marker: string, + style: ManagedTextArtifact["comments"], +): { begin: string; end: string } { + const prefix = style === "html" ? "" : ""; + return { + begin: `${prefix}canonfig:begin ${marker}${suffix}`, + end: `${prefix}canonfig:end ${marker}${suffix}`, + }; +} + +export function locateBlock( + text: string, + begin: string, + end: string, +): { start: number; end: number; block: string } | undefined { + const start = text.indexOf(begin); + if (start < 0) return undefined; + const endStart = text.indexOf(end, start + begin.length); + if (endStart < 0) return undefined; + let endOffset = endStart + end.length; + if (text[endOffset] === "\r" && text[endOffset + 1] === "\n") endOffset += 2; + else if (text[endOffset] === "\n") endOffset += 1; + return { start, end: endOffset, block: text.slice(start, endOffset) }; +} + +export function identityOf(value: unknown, identity: string | undefined): unknown { + return identity === undefined || !isRecord(value) ? value : value[identity]; +} + +export function containsMarker(value: unknown, marker: string): boolean { + if (typeof value === "string") return value.includes(marker); + if (Array.isArray(value)) return value.some((item) => containsMarker(item, marker)); + return isRecord(value) + && Object.values(value).some((item) => containsMarker(item, marker)); +} + +export function tomlBlockMarkers(marker: string): { begin: string; end: string } { + return { + begin: `# canonfig:begin ${marker}`, + end: `# canonfig:end ${marker}`, + }; +} + +export function findTomlSection( + lines: string[], + section: string, +): { header: number; end: number } | undefined { + const escaped = section.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const target = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`, "u"); + for (let index = 0; index < lines.length; index += 1) { + if (!target.test(lines[index] ?? "")) continue; + let end = lines.length; + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + if (/^\s*\[\[?.+?\]\]?\s*(?:#.*)?$/u.test(lines[cursor] ?? "")) { + end = cursor; + break; + } + } + return { header: index, end }; + } + return undefined; +} diff --git a/src/harness-configuration/core/render.ts b/src/harness-configuration/core/render.ts new file mode 100644 index 0000000..7058a42 --- /dev/null +++ b/src/harness-configuration/core/render.ts @@ -0,0 +1,71 @@ +import { sha256 } from "./hash.ts"; +import { unapplyPrevious } from "./render-cleanup.ts"; +import { applyJsonArtifact } from "./render-json.ts"; +import { appendManagedText, applyTomlArtifact } from "./render-text.ts"; +import type { + ArtifactState, + CleanupInstruction, + DesiredArtifact, +} from "./types.ts"; + +export interface RenderResult { + content: string | Uint8Array | undefined; + cleanup: CleanupInstruction[]; + conflicts: string[]; +} + +export function renderArtifacts( + artifacts: readonly DesiredArtifact[], + current: string | Uint8Array | undefined, + previous: ArtifactState | undefined, + force = false, +): RenderResult { + const conflicts: string[] = []; + const output = unapplyPrevious(current, previous, force, conflicts); + const cleanup: CleanupInstruction[] = []; + if (artifacts.length === 0) return { content: output, cleanup, conflicts }; + + const replacements = artifacts.filter((artifact) => artifact.kind === "replace"); + if (replacements.length > 0) { + if (artifacts.length !== 1) { + conflicts.push("A replace artifact cannot share a path with merge artifacts."); + } + const replacement = replacements[0]; + if (replacement === undefined) return { content: output, cleanup, conflicts }; + if ( + previous === undefined + && current !== undefined + && sha256(current) !== sha256(replacement.content) + && !force + ) { + conflicts.push("File already exists and is not owned by Canonfig."); + } + return { + content: replacement.content, + cleanup: [{ kind: "replace" }], + conflicts, + }; + } + + if (output instanceof Uint8Array) { + conflicts.push("Cannot merge text configuration into an existing binary file."); + return { content: output, cleanup, conflicts }; + } + let text = output ?? ""; + for (const artifact of artifacts) { + if (artifact.kind === "managed-text") { + const result = appendManagedText(text, artifact, force, conflicts); + text = result.text; + cleanup.push(result.cleanup); + } else if (artifact.kind === "json") { + const result = applyJsonArtifact(text, artifact, force, conflicts); + text = result.text; + cleanup.push(...result.cleanup); + } else if (artifact.kind === "toml") { + const result = applyTomlArtifact(text, artifact, force, conflicts); + text = result.text; + cleanup.push(...result.cleanup); + } + } + return { content: text, cleanup, conflicts }; +} diff --git a/src/harness-configuration/core/scaffold.ts b/src/harness-configuration/core/scaffold.ts new file mode 100644 index 0000000..fe1ace0 --- /dev/null +++ b/src/harness-configuration/core/scaffold.ts @@ -0,0 +1,113 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import YAML from "yaml"; +import { TARGET_IDS, type TargetId } from "./types.ts"; +import { CanonfigError } from "./errors.ts"; +import { assertRealPathInside, resolveInside } from "./path.ts"; + +export interface ScaffoldOptions { + targets?: readonly TargetId[] | undefined; + force?: boolean | undefined; +} + +const ROOT_INSTRUCTIONS = `# Repository instructions + +Describe the project, architecture, validation commands, constraints, and definition of done here. +`; + +const EXAMPLE_RULE = `# Source-code rules + +- Keep changes scoped. +- Run the smallest relevant validation command before finishing. +`; + +const EXAMPLE_AGENT = `Review the requested change for correctness, security, regressions, and missing tests. +Return concrete findings before general commentary. +`; + +const EXAMPLE_COMMAND = `Inspect the current changes, run relevant checks, and produce a release-readiness report. +`; + +const EXAMPLE_SKILL = `--- +name: repository-checks +description: Discover and run the repository's relevant validation commands. +--- + +# Repository checks + +1. Inspect package and build metadata. +2. Select the narrowest relevant checks. +3. Report commands, results, and unresolved failures. +`; + +async function writeNew(root: string, relativePath: string, content: string, force: boolean): Promise { + const filePath = resolveInside(root, relativePath); + await assertRealPathInside(root, filePath); + try { + await fs.access(filePath); + if (!force) return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await assertRealPathInside(root, filePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await assertRealPathInside(root, filePath); + await fs.writeFile(filePath, content, "utf8"); + return true; +} + +export async function scaffoldProject(root: string, options: ScaffoldOptions = {}): Promise { + const targets = [...new Set(options.targets ?? TARGET_IDS)]; + if (targets.length === 0) throw new CanonfigError("TARGET_EMPTY", "At least one target is required for init."); + const force = options.force ?? false; + + const config = { + version: 1, + project: { name: path.basename(path.resolve(root)) }, + targets: Object.fromEntries(targets.map((target) => [target, { enabled: true, options: {} }])), + instructions: { + root: "instructions/AGENTS.md", + rules: [{ + id: "source", + file: "rules/source.md", + paths: ["src/**", "tests/**"], + activation: "path", + description: "Rules for source and test files", + }], + }, + skills: { roots: ["skills"] }, + mcp: { servers: {} }, + hooks: [], + agents: [{ + id: "reviewer", + file: "agents/reviewer.md", + description: "Reviews changes for correctness and regressions", + model: "inherit", + tools: ["read", "search", "test", "git"], + writable: false, + }], + commands: [{ + id: "release-check", + file: "commands/release-check.md", + description: "Run a release-readiness review", + argumentHint: "[scope]", + }], + permissions: { rules: [] }, + extensions: {}, + }; + + const files: Array<[string, string]> = [ + [".canonfig/harness.yaml", YAML.stringify(config, { lineWidth: 120 })], + [".canonfig/instructions/AGENTS.md", ROOT_INSTRUCTIONS], + [".canonfig/rules/source.md", EXAMPLE_RULE], + [".canonfig/agents/reviewer.md", EXAMPLE_AGENT], + [".canonfig/commands/release-check.md", EXAMPLE_COMMAND], + [".canonfig/skills/repository-checks/SKILL.md", EXAMPLE_SKILL], + ]; + + const written: string[] = []; + for (const [relativePath, content] of files) { + if (await writeNew(root, relativePath, content, force)) written.push(relativePath); + } + return written; +} diff --git a/src/harness-configuration/core/schema-components.ts b/src/harness-configuration/core/schema-components.ts new file mode 100644 index 0000000..53e33ac --- /dev/null +++ b/src/harness-configuration/core/schema-components.ts @@ -0,0 +1,319 @@ +import { TARGET_IDS, type TargetId } from "./types.ts"; +import { + booleanValue, + enumValue, + idValue, + objectValue, + optionalString, + positiveInteger, + relativePath, + secretRecord, + stringArray, + stringValue, + type PathPart, + type Validator, +} from "./schema-runtime.ts"; +import { + CAPABILITIES, + HOOK_EVENTS, + type Agent, + type CanonfigConfig, + type Capability, + type Command, + type Hook, + type HookMatcher, + type McpServer, + type PermissionRule, + type Rule, + type TargetEntry, +} from "./schema-types.ts"; + +export const MCP_NAME_PATTERN = /^[A-Za-z0-9._-]+$/u; + +export function parseMcpServer( + input: unknown, + validator: Validator, + path: PathPart[], +): McpServer { + const value = objectValue(input, validator, path); + const transport = enumValue( + value.transport, + ["stdio", "streamable-http", "sse"] as const, + validator, + [...path, "transport"], + "stdio", + ); + const enabled = booleanValue(value.enabled, validator, [...path, "enabled"], true); + const timeoutMs = positiveInteger(value.timeoutMs, validator, [...path, "timeoutMs"]); + const enabledTools = value.enabledTools === undefined + ? undefined + : stringArray(value.enabledTools, validator, [...path, "enabledTools"]); + const disabledTools = value.disabledTools === undefined + ? undefined + : stringArray(value.disabledTools, validator, [...path, "disabledTools"]); + const common = { + enabled, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + ...(enabledTools === undefined ? {} : { enabledTools }), + ...(disabledTools === undefined ? {} : { disabledTools }), + }; + + if (transport === "stdio") { + const cwd = optionalString(value.cwd, validator, [...path, "cwd"]); + return { + ...common, + transport, + command: stringValue(value.command, validator, [...path, "command"], { + min: 1, + }), + args: stringArray(value.args, validator, [...path, "args"]), + ...(cwd === undefined ? {} : { cwd }), + env: secretRecord(value.env, validator, [...path, "env"]), + }; + } + + const url = stringValue(value.url, validator, [...path, "url"], { min: 1 }); + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + validator.issue([...path, "url"], "Expected an HTTP(S) URL."); + } + } catch { + validator.issue([...path, "url"], "Expected a valid URL."); + } + return { + ...common, + transport, + url, + headers: secretRecord(value.headers, validator, [...path, "headers"]), + }; +} + +export function parseHook( + input: unknown, + validator: Validator, + path: PathPart[], +): Hook { + const value = objectValue(input, validator, path); + const matcherValue = value.matcher === undefined + ? {} + : objectValue(value.matcher, validator, [...path, "matcher"]); + const inputRegex = optionalString( + matcherValue.inputRegex, + validator, + [...path, "matcher", "inputRegex"], + ); + let capabilities: Capability[] = []; + if (matcherValue.capabilities !== undefined) { + if (!Array.isArray(matcherValue.capabilities)) { + validator.issue([...path, "matcher", "capabilities"], "Expected an array."); + } else { + capabilities = matcherValue.capabilities.map((item, index) => + enumValue( + item, + CAPABILITIES, + validator, + [...path, "matcher", "capabilities", index], + "read", + ) + ); + } + } + const matcher: HookMatcher = { + capabilities, + tools: stringArray( + matcherValue.tools, + validator, + [...path, "matcher", "tools"], + ), + ...(inputRegex === undefined ? {} : { inputRegex }), + }; + const run = stringArray(value.run, validator, [...path, "run"]); + if (run.length === 0) { + validator.issue([...path, "run"], "Expected at least one command argument."); + } + return { + id: idValue(value.id, validator, [...path, "id"]), + event: enumValue( + value.event, + HOOK_EVENTS, + validator, + [...path, "event"], + "before_tool", + ), + enabled: booleanValue(value.enabled, validator, [...path, "enabled"], true), + matcher, + run, + timeoutMs: positiveInteger( + value.timeoutMs, + validator, + [...path, "timeoutMs"], + 600_000, + ) ?? 10_000, + onFailure: value.onFailure === undefined + ? "block" + : enumValue( + value.onFailure, + ["block", "warn", "ignore"] as const, + validator, + [...path, "onFailure"], + "block", + ), + }; +} + +export function parseRule( + input: unknown, + validator: Validator, + path: PathPart[], +): Rule { + const value = objectValue(input, validator, path); + const activation = value.activation === undefined + ? undefined + : enumValue( + value.activation, + ["always", "path", "manual", "model"] as const, + validator, + [...path, "activation"], + "always", + ); + const description = optionalString( + value.description, + validator, + [...path, "description"], + ); + return { + id: idValue(value.id, validator, [...path, "id"]), + file: relativePath(value.file, validator, [...path, "file"]), + paths: stringArray(value.paths, validator, [...path, "paths"]), + ...(activation === undefined ? {} : { activation }), + ...(description === undefined ? {} : { description }), + }; +} + +export function parseAgent( + input: unknown, + validator: Validator, + path: PathPart[], +): Agent { + const value = objectValue(input, validator, path); + const toolInput = value.tools === undefined ? ["read", "search"] : value.tools; + let tools: Capability[] = []; + if (!Array.isArray(toolInput)) { + validator.issue([...path, "tools"], "Expected an array."); + } else { + tools = toolInput.map((item, index) => + enumValue( + item, + CAPABILITIES, + validator, + [...path, "tools", index], + "read", + ) + ); + } + return { + id: idValue(value.id, validator, [...path, "id"]), + file: relativePath(value.file, validator, [...path, "file"]), + description: stringValue( + value.description, + validator, + [...path, "description"], + { min: 1 }, + ), + model: value.model === undefined + ? "inherit" + : stringValue(value.model, validator, [...path, "model"]), + tools, + writable: booleanValue(value.writable, validator, [...path, "writable"], false), + }; +} + +export function parseCommand( + input: unknown, + validator: Validator, + path: PathPart[], +): Command { + const value = objectValue(input, validator, path); + const argumentHint = optionalString( + value.argumentHint, + validator, + [...path, "argumentHint"], + ); + return { + id: idValue(value.id, validator, [...path, "id"]), + file: relativePath(value.file, validator, [...path, "file"]), + description: stringValue( + value.description, + validator, + [...path, "description"], + { min: 1 }, + ), + ...(argumentHint === undefined ? {} : { argumentHint }), + }; +} + +export function parsePermissionRule( + input: unknown, + validator: Validator, + path: PathPart[], +): PermissionRule { + const value = objectValue(input, validator, path); + const reason = optionalString(value.reason, validator, [...path, "reason"]); + return { + pattern: stringValue(value.pattern, validator, [...path, "pattern"], { + min: 1, + }), + action: enumValue( + value.action, + ["allow", "ask", "deny"] as const, + validator, + [...path, "action"], + "ask", + ), + ...(reason === undefined ? {} : { reason }), + }; +} + +export function parseTargetEntry( + input: unknown, + validator: Validator, + path: PathPart[], +): TargetEntry { + const value = objectValue(input, validator, path); + return { + enabled: booleanValue(value.enabled, validator, [...path, "enabled"], true), + options: value.options === undefined + ? {} + : objectValue(value.options, validator, [...path, "options"]), + }; +} + +export function parseTargets( + input: unknown, + validator: Validator, + path: PathPart[], +): CanonfigConfig["targets"] { + if (Array.isArray(input)) { + return input.map((value, index) => + enumValue(value, TARGET_IDS, validator, [...path, index], "codex") + ); + } + const value = objectValue(input, validator, path); + const output: Partial> = {}; + for (const [key, entry] of Object.entries(value)) { + if (!TARGET_IDS.includes(key as TargetId)) { + validator.issue( + [...path, key], + `Unknown target. Expected one of: ${TARGET_IDS.join(", ")}.`, + ); + continue; + } + output[key as TargetId] = parseTargetEntry( + entry, + validator, + [...path, key], + ); + } + return output; +} diff --git a/src/harness-configuration/core/schema-config.ts b/src/harness-configuration/core/schema-config.ts new file mode 100644 index 0000000..3904382 --- /dev/null +++ b/src/harness-configuration/core/schema-config.ts @@ -0,0 +1,191 @@ +import { TARGET_IDS, type TargetId } from "./types.ts"; +import { + objectValue, + optionalString, + relativePath, + type PathPart, + type Validator, +} from "./schema-runtime.ts"; +import { + MCP_NAME_PATTERN, + parseAgent, + parseCommand, + parseHook, + parseMcpServer, + parsePermissionRule, + parseRule, + parseTargets, +} from "./schema-components.ts"; +import type { + Agent, + CanonfigConfig, + Command, + Hook, + McpServer, + PermissionRule, + Rule, +} from "./schema-types.ts"; + +function parsedArray( + input: unknown, + validator: Validator, + path: PathPart[], + parser: (value: unknown, validator: Validator, path: PathPart[]) => T, +): T[] { + if (input === undefined) return []; + if (!Array.isArray(input)) { + validator.issue(path, "Expected an array."); + return []; + } + return input.map((item, index) => parser(item, validator, [...path, index])); +} + +function duplicateIds( + validator: Validator, + label: string, + items: ReadonlyArray<{ id: string }>, +): void { + const seen = new Set(); + for (const item of items) { + if (seen.has(item.id)) validator.issue([], `Duplicate ${label} id: ${item.id}`); + seen.add(item.id); + } +} + +export function parseConfig( + input: unknown, + validator: Validator, + path: PathPart[], +): CanonfigConfig { + const value = objectValue(input, validator, path); + if (value.version !== 1) validator.issue(["version"], "Expected literal value 1."); + + const projectValue = value.project === undefined + ? {} + : objectValue(value.project, validator, ["project"]); + const projectName = optionalString( + projectValue.name, + validator, + ["project", "name"], + ); + + const instructionsValue = value.instructions === undefined + ? {} + : objectValue(value.instructions, validator, ["instructions"]); + const rules: Rule[] = parsedArray( + instructionsValue.rules, + validator, + ["instructions", "rules"], + parseRule, + ); + + const skillsValue = value.skills === undefined + ? {} + : objectValue(value.skills, validator, ["skills"]); + const rootsInput = skillsValue.roots === undefined ? ["skills"] : skillsValue.roots; + let roots: string[] = []; + if (!Array.isArray(rootsInput)) { + validator.issue(["skills", "roots"], "Expected an array."); + } else { + roots = rootsInput.map((item, index) => + relativePath(item, validator, ["skills", "roots", index]) + ); + } + + const mcpValue = value.mcp === undefined + ? {} + : objectValue(value.mcp, validator, ["mcp"]); + const serversValue = mcpValue.servers === undefined + ? {} + : objectValue(mcpValue.servers, validator, ["mcp", "servers"]); + const servers: Record = {}; + for (const [name, server] of Object.entries(serversValue)) { + if (!MCP_NAME_PATTERN.test(name)) { + validator.issue(["mcp", "servers", name], "Invalid MCP server name."); + } + servers[name] = parseMcpServer( + server, + validator, + ["mcp", "servers", name], + ); + } + + const hooks: Hook[] = parsedArray( + value.hooks, + validator, + ["hooks"], + parseHook, + ); + const agents: Agent[] = parsedArray( + value.agents, + validator, + ["agents"], + parseAgent, + ); + const commands: Command[] = parsedArray( + value.commands, + validator, + ["commands"], + parseCommand, + ); + + const permissionsValue = value.permissions === undefined + ? {} + : objectValue(value.permissions, validator, ["permissions"]); + const permissionRules: PermissionRule[] = parsedArray( + permissionsValue.rules, + validator, + ["permissions", "rules"], + parsePermissionRule, + ); + + const extensionsValue = value.extensions === undefined + ? {} + : objectValue(value.extensions, validator, ["extensions"]); + const extensions: Partial>> = {}; + for (const [key, extension] of Object.entries(extensionsValue)) { + if (!TARGET_IDS.includes(key as TargetId)) { + validator.issue( + ["extensions", key], + `Unknown target. Expected one of: ${TARGET_IDS.join(", ")}.`, + ); + continue; + } + extensions[key as TargetId] = objectValue( + extension, + validator, + ["extensions", key], + ); + } + + duplicateIds(validator, "rule", rules); + duplicateIds(validator, "hook", hooks); + duplicateIds(validator, "agent", agents); + duplicateIds(validator, "command", commands); + + const targets = value.targets === undefined + ? (validator.issue(["targets"], "Required."), []) + : parseTargets(value.targets, validator, ["targets"]); + + return { + version: 1, + project: projectName === undefined ? {} : { name: projectName }, + targets, + instructions: { + root: relativePath( + instructionsValue.root, + validator, + ["instructions", "root"], + "instructions/AGENTS.md", + ), + rules, + }, + skills: { roots }, + mcp: { servers }, + hooks, + agents, + commands, + permissions: { rules: permissionRules }, + extensions, + }; +} diff --git a/src/harness-configuration/core/schema-runtime.ts b/src/harness-configuration/core/schema-runtime.ts new file mode 100644 index 0000000..d082322 --- /dev/null +++ b/src/harness-configuration/core/schema-runtime.ts @@ -0,0 +1,246 @@ +import type { SecretValue } from "./schema-types.ts"; + +export interface ValidationIssue { + path: Array; + message: string; +} + +export class SchemaValidationError extends Error { + readonly issues: ValidationIssue[]; + + constructor(issues: ValidationIssue[]) { + super( + issues + .map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`) + .join("\n"), + ); + this.name = "SchemaValidationError"; + this.issues = issues; + } +} + +export type SafeParseResult = + | { success: true; data: T } + | { success: false; error: SchemaValidationError }; + +export interface RuntimeSchema { + parse(input: unknown): T; + safeParse(input: unknown): SafeParseResult; +} + +export type PathPart = string | number; + +export class Validator { + readonly issues: ValidationIssue[] = []; + + issue(path: readonly PathPart[], message: string): void { + this.issues.push({ path: [...path], message }); + } + + finish(value: T): T { + if (this.issues.length > 0) throw new SchemaValidationError(this.issues); + return value; + } +} + +export function schema( + parser: (input: unknown, validator: Validator, path: PathPart[]) => T, +): RuntimeSchema { + return { + parse(input: unknown): T { + const validator = new Validator(); + return validator.finish(parser(input, validator, [])); + }, + safeParse(input: unknown): SafeParseResult { + try { + return { success: true, data: this.parse(input) }; + } catch (error) { + if (error instanceof SchemaValidationError) { + return { success: false, error }; + } + throw error; + } + }, + }; +} + +export function isRecord(input: unknown): input is Record { + return input !== null && typeof input === "object" && !Array.isArray(input); +} + +export function objectValue( + input: unknown, + validator: Validator, + path: PathPart[], +): Record { + if (!isRecord(input)) { + validator.issue(path, "Expected an object."); + return {}; + } + return input; +} + +export function stringValue( + input: unknown, + validator: Validator, + path: PathPart[], + options: { min?: number; pattern?: RegExp } = {}, +): string { + if (typeof input !== "string") { + validator.issue(path, "Expected a string."); + return ""; + } + if (options.min !== undefined && input.length < options.min) { + validator.issue(path, `Expected at least ${options.min} character(s).`); + } + if (options.pattern && !options.pattern.test(input)) { + validator.issue(path, "Invalid format."); + } + return input; +} + +export function optionalString( + input: unknown, + validator: Validator, + path: PathPart[], +): string | undefined { + return input === undefined ? undefined : stringValue(input, validator, path); +} + +export function booleanValue( + input: unknown, + validator: Validator, + path: PathPart[], + fallback: boolean, +): boolean { + if (input === undefined) return fallback; + if (typeof input !== "boolean") { + validator.issue(path, "Expected a boolean."); + return fallback; + } + return input; +} + +export function positiveInteger( + input: unknown, + validator: Validator, + path: PathPart[], + max?: number, +): number | undefined { + if (input === undefined) return undefined; + if ( + typeof input !== "number" + || !Number.isInteger(input) + || input <= 0 + || (max !== undefined && input > max) + ) { + validator.issue( + path, + max === undefined + ? "Expected a positive integer." + : `Expected a positive integer no greater than ${max}.`, + ); + return undefined; + } + return input; +} + +export function enumValue( + input: unknown, + values: T, + validator: Validator, + path: PathPart[], + fallback: T[number], +): T[number] { + if (typeof input === "string" && values.includes(input)) { + return input as T[number]; + } + validator.issue(path, `Expected one of: ${values.join(", ")}.`); + return fallback; +} + +export function stringArray( + input: unknown, + validator: Validator, + path: PathPart[], + fallback: string[] = [], +): string[] { + if (input === undefined) return [...fallback]; + if (!Array.isArray(input)) { + validator.issue(path, "Expected an array of strings."); + return [...fallback]; + } + return input.map((value, index) => + stringValue(value, validator, [...path, index], { min: 1 }) + ); +} + +export function relativePath( + input: unknown, + validator: Validator, + path: PathPart[], + fallback?: string, +): string { + if (input === undefined && fallback !== undefined) return fallback; + const value = stringValue(input, validator, path, { min: 1 }); + if ( + value.startsWith("/") + || value.startsWith("\\") + || /^[A-Za-z]:[\\/]/u.test(value) + || value.split(/[\\/]+/u).includes("..") + ) { + validator.issue(path, "Path must stay inside .canonfig/."); + } + return value; +} + +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u; + +export function idValue( + input: unknown, + validator: Validator, + path: PathPart[], +): string { + return stringValue(input, validator, path, { + min: 1, + pattern: ID_PATTERN, + }); +} + +export function secretValue( + input: unknown, + validator: Validator, + path: PathPart[], +): SecretValue { + if (typeof input === "string") return input; + const value = objectValue(input, validator, path); + const fromEnv = stringValue( + value.fromEnv, + validator, + [...path, "fromEnv"], + { min: 1 }, + ); + const fallback = optionalString( + value.default, + validator, + [...path, "default"], + ); + return fallback === undefined + ? { fromEnv } + : { fromEnv, default: fallback }; +} + +export function secretRecord( + input: unknown, + validator: Validator, + path: PathPart[], +): Record { + if (input === undefined) return {}; + const value = objectValue(input, validator, path); + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + secretValue(item, validator, [...path, key]), + ]), + ); +} diff --git a/src/harness-configuration/core/schema-types.ts b/src/harness-configuration/core/schema-types.ts new file mode 100644 index 0000000..0d80e3a --- /dev/null +++ b/src/harness-configuration/core/schema-types.ts @@ -0,0 +1,112 @@ +import type { TargetId } from "./types.ts"; + +export const HOOK_EVENTS = [ + "session_start", "session_end", "prompt_submit", "before_agent", "after_agent", + "before_tool", "after_tool", "before_compact", "after_compact", "stop", + "subagent_start", "subagent_stop", +] as const; + +export const CAPABILITIES = [ + "read", "write", "search", "shell", "web", "mcp", "subagent", "test", "git", +] as const; + +export type HookEvent = (typeof HOOK_EVENTS)[number]; +export type Capability = (typeof CAPABILITIES)[number]; + +export interface SecretReference { + fromEnv: string; + default?: string; +} + +export type SecretValue = string | SecretReference; + +interface McpBase { + enabled: boolean; + timeoutMs?: number; + enabledTools?: string[]; + disabledTools?: string[]; +} + +export interface StdioMcpServer extends McpBase { + transport: "stdio"; + command: string; + args: string[]; + cwd?: string; + env: Record; +} + +export interface RemoteMcpServer extends McpBase { + transport: "streamable-http" | "sse"; + url: string; + headers: Record; +} + +export type McpServer = StdioMcpServer | RemoteMcpServer; + +export interface HookMatcher { + capabilities: Capability[]; + tools: string[]; + inputRegex?: string; +} + +export interface Hook { + id: string; + event: HookEvent; + enabled: boolean; + matcher: HookMatcher; + run: string[]; + timeoutMs: number; + onFailure: "block" | "warn" | "ignore"; +} + +export interface Rule { + id: string; + file: string; + paths: string[]; + activation?: "always" | "path" | "manual" | "model"; + description?: string; +} + +export interface Agent { + id: string; + file: string; + description: string; + model: string; + tools: Capability[]; + writable: boolean; +} + +export interface Command { + id: string; + file: string; + description: string; + argumentHint?: string; +} + +export interface PermissionRule { + pattern: string; + action: "allow" | "ask" | "deny"; + reason?: string; +} + +export interface TargetEntry { + enabled: boolean; + options: Record; +} + +export interface CanonfigConfig { + version: 1; + project: { name?: string }; + targets: TargetId[] | Partial>; + instructions: { + root: string; + rules: Rule[]; + }; + skills: { roots: string[] }; + mcp: { servers: Record }; + hooks: Hook[]; + agents: Agent[]; + commands: Command[]; + permissions: { rules: PermissionRule[] }; + extensions: Partial>>; +} diff --git a/src/harness-configuration/core/schema.ts b/src/harness-configuration/core/schema.ts new file mode 100644 index 0000000..ba031af --- /dev/null +++ b/src/harness-configuration/core/schema.ts @@ -0,0 +1,34 @@ +import { TARGET_IDS, type TargetId } from "./types.ts"; +import { + enumValue, + schema, +} from "./schema-runtime.ts"; +import { + parseAgent, + parseCommand, + parseHook, + parseMcpServer, + parseRule, +} from "./schema-components.ts"; +import { parseConfig } from "./schema-config.ts"; +import type { + Agent, + CanonfigConfig, + Command, + Hook, + McpServer, + Rule, +} from "./schema-types.ts"; + +export * from "./schema-types.ts"; +export * from "./schema-runtime.ts"; + +export const TargetIdSchema = schema((input, validator, path) => + enumValue(input, TARGET_IDS, validator, path, "codex") +); +export const McpServerSchema = schema(parseMcpServer); +export const HookSchema = schema(parseHook); +export const RuleSchema = schema(parseRule); +export const AgentSchema = schema(parseAgent); +export const CommandSchema = schema(parseCommand); +export const CanonfigConfigSchema = schema(parseConfig); diff --git a/src/harness-configuration/core/state.ts b/src/harness-configuration/core/state.ts new file mode 100644 index 0000000..21a5116 --- /dev/null +++ b/src/harness-configuration/core/state.ts @@ -0,0 +1,39 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { CANONFIG_DIR, STATE_FILENAME } from "./config.ts"; +import type { CanonfigState } from "./types.ts"; +import { CanonfigError } from "./errors.ts"; +import { assertNoSymlinkPathComponents, atomicWrite } from "./filesystem.ts"; + +export const HARNESS_CONFIGURATION_VERSION = "1"; + +export function emptyState(): CanonfigState { + return { version: 1, generatedAt: new Date(0).toISOString(), canonfigVersion: HARNESS_CONFIGURATION_VERSION, artifacts: {} }; +} + +export async function loadState(root: string): Promise { + const statePath = path.join(root, CANONFIG_DIR, STATE_FILENAME); + try { + const raw = await fs.readFile(statePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== 1 || !parsed.artifacts || typeof parsed.artifacts !== "object") { + throw new CanonfigError("STATE_INVALID", `Unsupported state file: ${path.relative(root, statePath)}`); + } + return parsed as CanonfigState; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyState(); + if (error instanceof CanonfigError) throw error; + throw new CanonfigError("STATE_INVALID", `Could not read ${path.relative(root, statePath)}: ${String(error)}`, error); + } +} + +export async function writeState(root: string, state: CanonfigState): Promise { + const statePath = path.join(root, CANONFIG_DIR, STATE_FILENAME); + if (Object.keys(state.artifacts).length === 0) { + await assertNoSymlinkPathComponents(root, path.dirname(statePath)); + try { await fs.unlink(statePath); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + return; + } + await atomicWrite(statePath, `${JSON.stringify(state, null, 2)}\n`, 0o600, root); +} diff --git a/src/harness-configuration/core/types.ts b/src/harness-configuration/core/types.ts new file mode 100644 index 0000000..ee9babd --- /dev/null +++ b/src/harness-configuration/core/types.ts @@ -0,0 +1,200 @@ +import type { CanonfigConfig } from "./schema.ts"; + +export const TARGET_IDS = [ + "codex", "claude-code", "amp", "oh-my-pi", "pi", "factory-droid", + "cursor", "devin", "opencode", "grok-build", "antigravity", "copilot-cli", +] as const; + +export type TargetId = (typeof TARGET_IDS)[number]; +export type ArtifactOwner = TargetId | "common"; +export type SupportLevel = "native" | "portable" | "translated" | "shim" | "lossy" | "unsupported"; +export type Feature = "instructions" | "rules" | "skills" | "mcp" | "hooks" | "agents" | "commands" | "permissions"; + +export interface HarnessDescriptor { + id: TargetId; + name: string; + executables: readonly string[]; + docs: readonly string[]; + verifiedAt: string; + capabilities: Readonly>; + notes?: readonly string[]; +} + +export interface Diagnostic { + level: "info" | "warning" | "error"; + code: string; + message: string; + target?: TargetId; + path?: string; +} + +export type JsonPath = readonly string[]; + +export interface ReplaceArtifact { + kind: "replace"; + path: string; + owner: ArtifactOwner; + content: string | Uint8Array; + mode?: number; + description?: string; +} + +export interface ManagedTextArtifact { + kind: "managed-text"; + path: string; + owner: ArtifactOwner; + content: string; + marker: string; + comments: "html" | "hash" | "slash"; + placement?: "start" | "end"; + description?: string; +} + +export interface JsonDefaultsOperation { + kind: "defaults"; + entries: ReadonlyArray<{ path: JsonPath; value: unknown }>; +} + +export interface JsonManagedMapOperation { + kind: "managed-map"; + path: JsonPath; + entries: Readonly>; + collision?: "error" | "replace"; +} + +export interface JsonManagedArrayOperation { + kind: "managed-array"; + path: JsonPath; + values: readonly unknown[]; + identity?: string; +} + +export interface JsonManagedHooksOperation { + kind: "managed-hooks"; + path: JsonPath; + hooks: Readonly>; + marker: string; +} + +export type JsonOperation = + | JsonDefaultsOperation + | JsonManagedMapOperation + | JsonManagedArrayOperation + | JsonManagedHooksOperation; + +export interface JsonArtifact { + kind: "json"; + path: string; + owner: ArtifactOwner; + operations: readonly JsonOperation[]; + rootDefaults?: Readonly>; + description?: string; +} + +export interface TomlManagedBlock { + marker: string; + content: string; +} + +export interface TomlEnsureKey { + section: string; + key: string; + value: string; + marker: string; + collision?: "error" | "replace"; +} + +export interface TomlArtifact { + kind: "toml"; + path: string; + owner: ArtifactOwner; + blocks?: readonly TomlManagedBlock[]; + ensureKeys?: readonly TomlEnsureKey[]; + description?: string; +} + +export type DesiredArtifact = ReplaceArtifact | ManagedTextArtifact | JsonArtifact | TomlArtifact; + +export interface ManagedTextCleanup { + kind: "managed-text"; + marker: string; + comments: ManagedTextArtifact["comments"]; + blockHash: string; +} +export interface JsonManagedMapCleanup { + kind: "json-managed-map"; + path: string[]; + entries: Record; + originals: Record; +} +export interface JsonManagedArrayCleanup { + kind: "json-managed-array"; path: string[]; values: unknown[]; identity?: string; +} +export interface JsonManagedHooksCleanup { + kind: "json-managed-hooks"; + path: string[]; + marker: string; + events?: string[]; + originals?: Record; +} +export interface TomlBlockCleanup { kind: "toml-block"; marker: string; blockHash: string; } +export interface TomlKeyCleanup { + kind: "toml-key"; section: string; key: string; marker: string; originalLine?: string; +} +export interface ReplaceCleanup { kind: "replace"; } + +export type CleanupInstruction = + | ManagedTextCleanup | JsonManagedMapCleanup | JsonManagedArrayCleanup + | JsonManagedHooksCleanup | TomlBlockCleanup | TomlKeyCleanup | ReplaceCleanup; + +export interface ArtifactState { + owner: ArtifactOwner; + hash: string; + existedBefore: boolean; + mode?: number; + cleanup: CleanupInstruction[]; +} + +export interface CanonfigState { + version: 1; + generatedAt: string; + canonfigVersion: string; + artifacts: Record; +} + +export type PlanAction = "create" | "update" | "delete" | "unchanged" | "conflict"; + +export interface PlanEntry { + path: string; + owner: ArtifactOwner; + action: PlanAction; + reason?: string | undefined; + before?: string | undefined; + after?: string | undefined; + content?: string | Uint8Array | undefined; + binary?: boolean | undefined; + mode?: number | undefined; + nextState?: ArtifactState | undefined; +} + +export interface Plan { + root: string; + targets: TargetId[]; + entries: PlanEntry[]; + diagnostics: Diagnostic[]; + nextState: CanonfigState; +} + +export interface BuildContext { + root: string; + canonfigDir: string; + config: CanonfigConfig; + target: TargetId; + targetOptions: Record; +} + +export interface BuildResult { artifacts: DesiredArtifact[]; diagnostics: Diagnostic[]; } +export interface HarnessAdapter { + descriptor: HarnessDescriptor; + build(context: BuildContext): Promise; +} diff --git a/src/harness-configuration/core/validation.ts b/src/harness-configuration/core/validation.ts new file mode 100644 index 0000000..a791d36 --- /dev/null +++ b/src/harness-configuration/core/validation.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CanonfigConfig } from "./schema.ts"; +import type { Diagnostic } from "./types.ts"; +import { parseSkill } from "./frontmatter.ts"; +import { walkFiles } from "./filesystem.ts"; +import { assertSafeRelativePath } from "./path.ts"; + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function validateProject(root: string, config: CanonfigConfig): Promise { + const diagnostics: Diagnostic[] = []; + const canonfigDir = path.join(root, ".canonfig"); + const referenced: Array<{ kind: string; relative: string }> = [ + { kind: "instruction", relative: config.instructions.root }, + ...config.instructions.rules.map((rule) => ({ kind: `rule ${rule.id}`, relative: rule.file })), + ...config.agents.map((agent) => ({ kind: `agent ${agent.id}`, relative: agent.file })), + ...config.commands.map((command) => ({ kind: `command ${command.id}`, relative: command.file })), + ]; + + for (const item of referenced) { + const safe = assertSafeRelativePath(item.relative); + if (!await exists(path.join(canonfigDir, safe))) { + diagnostics.push({ + level: "error", + code: "SOURCE_MISSING", + path: `.canonfig/${safe}`, + message: `Missing source for ${item.kind}: .canonfig/${safe}`, + }); + } + } + + const skillNames = new Map(); + for (const rootRelative of config.skills.roots) { + const safeRoot = assertSafeRelativePath(rootRelative); + const absoluteRoot = path.join(canonfigDir, safeRoot); + if (!await exists(absoluteRoot)) { + diagnostics.push({ + level: "info", + code: "SKILL_ROOT_MISSING", + path: `.canonfig/${safeRoot}`, + message: `Skill root .canonfig/${safeRoot} does not exist; it contributes no skills.`, + }); + continue; + } + + const manifests = (await walkFiles(absoluteRoot)).filter((file) => path.basename(file) === "SKILL.md"); + for (const manifest of manifests.sort()) { + const relative = `.canonfig/${safeRoot}/${manifest}`; + try { + const source = await fs.readFile(path.join(absoluteRoot, manifest), "utf8"); + const parsed = parseSkill(source); + const directoryName = path.basename(path.dirname(manifest)); + if (parsed.data.name !== directoryName) { + diagnostics.push({ + level: "warning", + code: "SKILL_NAME_DIRECTORY_MISMATCH", + path: relative, + message: `Skill name ${parsed.data.name} does not match its directory ${directoryName}.`, + }); + } + const previous = skillNames.get(parsed.data.name); + if (previous) { + diagnostics.push({ + level: "error", + code: "SKILL_NAME_DUPLICATE", + path: relative, + message: `Skill name ${parsed.data.name} is duplicated by ${previous} and ${relative}.`, + }); + } else { + skillNames.set(parsed.data.name, relative); + } + } catch (error) { + diagnostics.push({ + level: "error", + code: "SKILL_INVALID", + path: relative, + message: `Invalid Agent Skill manifest ${relative}: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + + for (const hook of config.hooks) { + if (hook.matcher.inputRegex) { + try { + new RegExp(hook.matcher.inputRegex); + } catch (error) { + diagnostics.push({ + level: "error", + code: "HOOK_REGEX_INVALID", + message: `Hook ${hook.id} has an invalid inputRegex: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + + for (const [name, server] of Object.entries(config.mcp.servers)) { + const values = server.transport === "stdio" ? Object.values(server.env) : Object.values(server.headers); + for (const value of values) { + if (typeof value !== "string" && value.default !== undefined) { + diagnostics.push({ + level: "warning", + code: "SECRET_DEFAULT_PRESENT", + message: `MCP server ${name} gives ${value.fromEnv} a default value. Generated files may therefore contain a credential-like literal.`, + }); + } + } + } + + return diagnostics; +} diff --git a/src/harness-configuration/templates/runtime.ts b/src/harness-configuration/templates/runtime.ts new file mode 100644 index 0000000..5524538 --- /dev/null +++ b/src/harness-configuration/templates/runtime.ts @@ -0,0 +1,228 @@ +import type { Agent, Hook } from "../core/schema.ts"; +import type { TargetId } from "../core/types.ts"; + +export interface AgentDocument { + agent: Agent; + content: string; + tools?: string[]; +} + +export const AMP_PLUGIN_EVENT_MAP: Partial> = { + before_tool: "tool.call", + after_tool: "tool.result", + session_start: "session.start", + before_agent: "agent.start", + after_agent: "agent.end", +}; + +export const PI_PLUGIN_EVENT_MAP: Partial> = { + before_tool: "tool_call", + after_tool: "tool_result", + session_start: "session_start", + session_end: "session_shutdown", + before_agent: "before_agent_start", + before_compact: "session_before_compact", + stop: "agent_end", +}; + +export function hookRegistryJson(hooks: Hook[]): string { + return `${JSON.stringify({ version: 1, hooks }, null, 2)}\n`; +} + +export function hookRunnerSource(): string { + return `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const runtimeDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(runtimeDir, "../.."); +let registry; +try { registry = JSON.parse(fs.readFileSync(path.join(runtimeDir, "hooks.json"), "utf8")); } +catch { process.exit(0); } +const hooks = Array.isArray(registry?.hooks) ? registry.hooks : []; +const args = process.argv.slice(2); +const arg = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; }; +const hookId = arg("--hook"); +const target = arg("--target") || "unknown"; +const event = arg("--event") || "unknown"; +const hook = hooks.find((candidate) => candidate.id === hookId && candidate.enabled !== false); +if (!hook) process.exit(0); + +let rawText = ""; +try { rawText = fs.readFileSync(0, "utf8"); } catch { rawText = ""; } +let raw; +try { raw = rawText.trim() ? JSON.parse(rawText) : {}; } catch { raw = { raw: rawText }; } +const toolName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? raw.name ?? raw.input?.tool ?? raw.input?.toolName ?? ""); +const toolInput = raw.tool_input ?? raw.toolInput ?? raw.input ?? raw.args ?? {}; +const serialized = JSON.stringify({ toolName, toolInput, raw }); + +const capabilityPatterns = { + shell: /(bash|shell|terminal|execute|exec|command|run_command)/i, + read: /(read|view|cat|glob|grep|search|list|find)/i, + write: /(write|edit|patch|create|delete|move|replace)/i, + search: /(grep|glob|search|find|list)/i, + web: /(web|fetch|browser|http)/i, + mcp: /(mcp|server)/i, + subagent: /(task|agent|subagent)/i, + test: /(test|spec|check|verify)/i, + git: /(git|commit|branch|diff|push|pull)/i, +}; +const matchesTool = !hook.matcher?.tools?.length || hook.matcher.tools.some((name) => { + if (name === toolName) return true; + try { return new RegExp(name).test(toolName); } catch { return false; } +}); +const matchesCapability = !hook.matcher?.capabilities?.length || hook.matcher.capabilities.some((capability) => capabilityPatterns[capability]?.test(toolName)); +let matchesInput = true; +if (hook.matcher?.inputRegex) { + try { matchesInput = new RegExp(hook.matcher.inputRegex).test(serialized); } + catch (error) { console.error(\`Invalid inputRegex for hook \${hook.id}: \${error.message}\`); process.exit(2); } +} +if (!matchesTool || !matchesCapability || !matchesInput) process.exit(0); + +const normalized = { + version: 1, + hookId: hook.id, + target, + event, + repositoryRoot: repoRoot, + toolName, + toolInput, + raw, +}; +const [command, ...commandArgs] = hook.run; +const result = spawnSync(command, commandArgs, { + cwd: repoRoot, + env: { + ...process.env, + CANONFIG_ROOT: repoRoot, + CANONFIG_HOOK_ID: hook.id, + CANONFIG_TARGET: target, + CANONFIG_EVENT: event, + CANONFIG_TOOL_NAME: toolName, + }, + input: JSON.stringify(normalized), + encoding: "utf8", + timeout: hook.timeoutMs, + maxBuffer: 16 * 1024 * 1024, +}); + +let output; +try { output = result.stdout?.trim() ? JSON.parse(result.stdout) : undefined; } catch { output = undefined; } +const reason = String(output?.reason ?? result.stderr?.trim() ?? result.error?.message ?? \`Canonfig hook \${hook.id} failed\`); +const denied = result.status === 2 || output?.decision === "deny" || output?.permission === "deny" || output?.block === true; +const failed = result.error || result.status === null || (result.status !== 0 && result.status !== 2); +if (denied || (failed && hook.onFailure === "block")) { + console.error(reason); + process.exit(2); +} +if (failed && hook.onFailure === "warn") console.error(reason); +if (output?.message) console.log(String(output.message)); +else if (result.stdout && !output) process.stdout.write(result.stdout); +process.exit(0); +`; +} + +function pluginPreamble(target: TargetId): string { + return `// Generated by Canonfig. Edit .canonfig/harness.yaml instead. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; + +function findRoot() { + let current = path.dirname(fileURLToPath(import.meta.url)); + while (true) { + if (fs.existsSync(path.join(current, ".canonfig", ".runtime", "hook-runner.mjs"))) return current; + const parent = path.dirname(current); + if (parent === current) return process.cwd(); + current = parent; + } +} +const root = findRoot(); +function runCanonfig(hookId, event, payload, timeoutMs) { + return new Promise((resolve) => { + const runner = path.join(root, ".canonfig", ".runtime", "hook-runner.mjs"); + const child = execFile( + process.execPath, + [runner, "--hook", hookId, "--target", "${target}", "--event", event], + { + cwd: root, + encoding: "utf8", + timeout: Math.max(1, timeoutMs + 1000), + maxBuffer: 16 * 1024 * 1024, + }, + (error, stdout, stderr) => { + const reason = String(stderr || stdout || error?.message || "Blocked by Canonfig hook").trim(); + resolve({ blocked: error !== null, reason }); + }, + ); + child.stdin?.on("error", () => {}); + child.stdin?.end(JSON.stringify(payload ?? {})); + }); +} +`; +} + +export function ampPluginSource(hooks: Hook[], agents: AgentDocument[] = []): string { + const enabled = hooks.filter((hook) => hook.enabled); + const registrations = enabled.map((hook) => { + const event = AMP_PLUGIN_EVENT_MAP[hook.event]; + if (!event) return ""; + const rejection = event === "tool.call" + ? "if (result.blocked) return { action: \"reject-and-continue\", message: result.reason };" + : "if (result.blocked) throw new Error(result.reason);"; + return ` amp.on(${JSON.stringify(event)}, async (payload) => { const result = await runCanonfig(${JSON.stringify(hook.id)}, ${JSON.stringify(hook.event)}, payload, ${hook.timeoutMs}); ${rejection} });`; + }).filter(Boolean); + + const agentRegistrations = agents.flatMap(({ agent, content, tools }) => { + const variable = `canonfigAgent_${agent.id.replaceAll(/[^A-Za-z0-9_$]/g, "_")}`; + const toolName = `canonfig_${agent.id.replaceAll(/[^A-Za-z0-9_]/g, "_")}_subagent`; + const definition = [ + ` const ${variable} = amp.createAgent({`, + ` name: ${JSON.stringify(agent.id)},`, + ...(agent.model === "inherit" ? [] : [` model: ${JSON.stringify(agent.model)},`]), + ` instructions: ${JSON.stringify(content.trim())},`, + ` tools: ${tools?.length ? JSON.stringify(tools) : JSON.stringify("all")},`, + ` display: { label: ${JSON.stringify(agent.id)} },`, + " });", + "", + " amp.registerTool({", + ` name: ${JSON.stringify(toolName)},`, + ` description: ${JSON.stringify(agent.description)},`, + " inputSchema: { type: \"object\", properties: { request: { type: \"string\" } }, required: [\"request\"] },", + " async execute(input, ctx) {", + " const request = typeof input.request === \"string\" ? input.request : \"\";", + " if (!request.trim()) return \"Missing subagent request.\";", + ` const result = await ${variable}.run(request, { parentThreadID: ctx.thread.id, timeoutMs: 10 * 60 * 1000 });`, + " return result.text;", + " },", + " });", + ]; + return definition; + }); + + return `${pluginPreamble("amp")}\nimport type { PluginAPI } from "@ampcode/plugin";\n\nexport default function canonfig(amp: PluginAPI) {\n${[...registrations, ...agentRegistrations].join("\n")}\n}\n`; +} + +export function piPluginSource(target: "pi" | "oh-my-pi", hooks: Hook[]): string { + const registrations = hooks.filter((hook) => hook.enabled).map((hook) => { + const event = PI_PLUGIN_EVENT_MAP[hook.event]; + if (!event) return ""; + const result = hook.event === "before_tool" + ? "if (result.blocked) return { block: true, reason: result.reason };" + : "if (result.blocked) throw new Error(result.reason);"; + return ` pi.on(${JSON.stringify(event)}, async (payload) => { const result = await runCanonfig(${JSON.stringify(hook.id)}, ${JSON.stringify(hook.event)}, payload, ${hook.timeoutMs}); ${result} });`; + }).filter(Boolean).join("\n"); + const packageName = target === "pi" ? "@earendil-works/pi-coding-agent" : "@oh-my-pi/pi-coding-agent"; + return `${pluginPreamble(target)}\nimport type { ExtensionAPI } from ${JSON.stringify(packageName)};\n\nexport default function canonfig(pi: ExtensionAPI) {\n${registrations}\n}\n`; +} + +export function openCodePluginSource(hooks: Hook[]): string { + const before = hooks.filter((hook) => hook.enabled && hook.event === "before_tool"); + const after = hooks.filter((hook) => hook.enabled && hook.event === "after_tool"); + const beforeBody = before.map((hook) => ` { const result = await runCanonfig(${JSON.stringify(hook.id)}, "before_tool", { ...input, ...output }, ${hook.timeoutMs}); if (result.blocked) throw new Error(result.reason); }`).join("\n"); + const afterBody = after.map((hook) => ` { const result = await runCanonfig(${JSON.stringify(hook.id)}, "after_tool", { ...input, ...output }, ${hook.timeoutMs}); if (result.blocked) throw new Error(result.reason); }`).join("\n"); + return `${pluginPreamble("opencode")}\nexport const CanonfigPlugin = async () => ({\n "tool.execute.before": async (input, output) => {\n${beforeBody}\n },\n "tool.execute.after": async (input, output) => {\n${afterBody}\n },\n});\n`; +} diff --git a/src/runtime/main.ts b/src/runtime/main.ts index ac72b48..44d97b4 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -4,6 +4,10 @@ import { NodeRuntime } from "@effect/platform-node"; import { Effect } from "effect"; import { evaluateCli, runCli, type CliIo } from "../cli/cli.ts"; +import { + isHarnessConfigurationCommand, + runHarnessConfigurationCli, +} from "../harness-configuration/cli.ts"; const warningListeners = process.listeners("warning"); process.removeAllListeners("warning"); @@ -24,30 +28,39 @@ const nodeCliIo: CliIo = { }; const arguments_ = process.argv.slice(2); -const outcome = evaluateCli(arguments_); -if (outcome._tag === "Command") { +if (isHarnessConfigurationCommand(arguments_)) { NodeRuntime.runMain( - Effect.promise(() => import("./layers.ts")).pipe( - Effect.flatMap(({ runtimeLayer }) => - runCli(arguments_, nodeCliIo).pipe( - Effect.andThen( - outcome.command._tag === "SourceServe" - ? Effect.never - : Effect.void, - ), - Effect.provide(runtimeLayer()), - ) - ), + Effect.promise(() => + runHarnessConfigurationCli(arguments_.slice(1), nodeCliIo) ), ); } else { - NodeRuntime.runMain(Effect.sync(() => { - if (outcome._tag === "Help" || outcome._tag === "Version") { - nodeCliIo.writeStdout(`${outcome.text}\n`); - } else { - nodeCliIo.writeStderr(`${outcome.message}\n`); - } - nodeCliIo.setExitCode(outcome.exitCode); - })); + const outcome = evaluateCli(arguments_); + + if (outcome._tag === "Command") { + NodeRuntime.runMain( + Effect.promise(() => import("./layers.ts")).pipe( + Effect.flatMap(({ runtimeLayer }) => + runCli(arguments_, nodeCliIo).pipe( + Effect.andThen( + outcome.command._tag === "SourceServe" + ? Effect.never + : Effect.void, + ), + Effect.provide(runtimeLayer()), + ) + ), + ), + ); + } else { + NodeRuntime.runMain(Effect.sync(() => { + if (outcome._tag === "Help" || outcome._tag === "Version") { + nodeCliIo.writeStdout(`${outcome.text}\n`); + } else { + nodeCliIo.writeStderr(`${outcome.message}\n`); + } + nodeCliIo.setExitCode(outcome.exitCode); + })); + } } diff --git a/tests/harness-configuration.test.ts b/tests/harness-configuration.test.ts new file mode 100644 index 0000000..db103b5 --- /dev/null +++ b/tests/harness-configuration.test.ts @@ -0,0 +1,334 @@ +import { lstat, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + HarnessConfigurationCompiler, + createDefaultRegistry, +} from "../src/harness-configuration/core/compiler.ts"; +import { + applyPlan, + createPlan, +} from "../src/harness-configuration/core/planner.ts"; +import { parseMarkdownDocument } from "../src/harness-configuration/core/frontmatter.ts"; +import { applyJsonArtifact } from "../src/harness-configuration/core/render-json.ts"; +import { unapplyPrevious } from "../src/harness-configuration/core/render-cleanup.ts"; +import { findTomlSection } from "../src/harness-configuration/core/render-utils.ts"; +import { scaffoldProject } from "../src/harness-configuration/core/scaffold.ts"; +import { + TARGET_IDS, + type ArtifactState, + type Plan, + type TargetId, +} from "../src/harness-configuration/core/types.ts"; + +const temporaryRoots: string[] = []; + +const temporaryRoot = async (prefix = "canonfig-harness-"): Promise => { + const root = await import("node:fs/promises").then(({ mkdtemp }) => + mkdtemp(path.join(tmpdir(), prefix)) + ); + temporaryRoots.push(root); + return root; +}; + +const write = async ( + root: string, + relative: string, + content: string, +): Promise => { + const file = path.join(root, relative); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content, "utf8"); +}; + +const fixture = async ( + targets: ReadonlyArray = TARGET_IDS, +): Promise => { + const root = await temporaryRoot(); + const config = { + version: 1, + project: { name: "fixture" }, + targets: Object.fromEntries(targets.map((target) => [ + target, + { + enabled: true, + options: target === "pi" + ? { mcpPackage: "@canonfig/pi-mcp" } + : {}, + }, + ])), + instructions: { + root: "instructions/AGENTS.md", + rules: [{ + id: "source", + file: "rules/source.md", + paths: ["src/**", "tests/**"], + activation: "path", + description: "Source rules", + }], + }, + skills: { roots: ["skills"] }, + mcp: { + servers: { + local: { + transport: "stdio", + command: "node", + args: ["tools/server.mjs"], + env: { API_KEY: { fromEnv: "TEST_API_KEY" } }, + }, + docs: { + transport: "streamable-http", + url: "https://example.invalid/mcp", + headers: { Authorization: { fromEnv: "DOCS_TOKEN" } }, + }, + }, + }, + hooks: [{ + id: "guard-shell", + event: "before_tool", + matcher: { + capabilities: ["shell", "git"], + tools: [], + inputRegex: "git\\s+push", + }, + run: ["node", ".canonfig/hooks/guard.mjs"], + timeoutMs: 10_000, + onFailure: "block", + }], + agents: [{ + id: "reviewer", + file: "agents/reviewer.md", + description: "Review changes", + model: "inherit", + tools: ["read", "search", "test", "git"], + writable: false, + }], + commands: [{ + id: "release-check", + file: "commands/release-check.md", + description: "Check release readiness", + argumentHint: "[scope]", + }], + permissions: { rules: [] }, + extensions: {}, + }; + + await write(root, ".canonfig/harness.json", `${JSON.stringify(config, undefined, 2)}\n`); + await write(root, ".canonfig/instructions/AGENTS.md", "# Canonical instructions\n\nRun tests before finishing.\n"); + await write(root, ".canonfig/rules/source.md", "# Source rules\n\nKeep changes scoped.\n"); + await write(root, ".canonfig/agents/reviewer.md", "Review correctness, regressions, and tests.\n"); + await write(root, ".canonfig/commands/release-check.md", "Run checks and report release blockers.\n"); + await write(root, ".canonfig/hooks/guard.mjs", "process.exit(0);\n"); + await write(root, ".canonfig/skills/repository-checks/SKILL.md", [ + "---", + "name: repository-checks", + "description: Run repository validation.", + "---", + "", + "# Repository checks", + "", + "Run the smallest relevant checks.", + "", + ].join("\n")); + return root; +}; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => + rm(root, { recursive: true, force: true }) + )); +}); + +describe("harness configuration compiler", () => { + it("compiles every requested target and becomes idempotent", async () => { + const root = await fixture(); + const compiler = new HarnessConfigurationCompiler(); + + const first = await compiler.plan({ root }); + expect(first.diagnostics.filter((diagnostic) => diagnostic.level === "error")) + .toEqual([]); + expect(first.entries.some((entry) => entry.action === "conflict")).toBe(false); + await applyPlan(first); + + await expect(readFile(path.join(root, "AGENTS.md"), "utf8")) + .resolves.toContain("Canonical instructions"); + await expect(readFile(path.join(root, ".codex/config.toml"), "utf8")) + .resolves.toContain("canonfig:begin"); + await expect(readFile(path.join(root, ".claude/settings.json"), "utf8")) + .resolves.toContain("PreToolUse"); + await expect(readFile(path.join(root, ".cursor/mcp.json"), "utf8")) + .resolves.toContain("mcpServers"); + await expect(readFile(path.join(root, ".agents/mcp_config.json"), "utf8")) + .resolves.toContain("mcpServers"); + + const second = await compiler.plan({ root }); + expect(second.entries.filter((entry) => entry.action !== "unchanged")) + .toEqual([]); + }); + + it("preserves unrelated JSON keys and removes only owned material", async () => { + const root = await fixture(["claude-code"]); + await write( + root, + ".claude/settings.json", + `${JSON.stringify({ + theme: "dark", + hooks: { Existing: [{ command: "echo keep" }] }, + }, undefined, 2)}\n`, + ); + const compiler = new HarnessConfigurationCompiler(); + + await applyPlan(await compiler.plan({ root })); + const applied = JSON.parse( + await readFile(path.join(root, ".claude/settings.json"), "utf8"), + ) as { + theme: string; + hooks: Record; + }; + expect(applied.theme).toBe("dark"); + expect(applied.hooks.Existing).toEqual([{ command: "echo keep" }]); + expect(applied.hooks.PreToolUse).toBeDefined(); + + await applyPlan(await createPlan(root, ["claude-code"], [], [])); + const cleaned = JSON.parse( + await readFile(path.join(root, ".claude/settings.json"), "utf8"), + ); + expect(cleaned).toEqual({ + theme: "dark", + hooks: { Existing: [{ command: "echo keep" }] }, + }); + }); + + it("rejects extension-backed shims in strict mode", async () => { + const root = await fixture(["pi"]); + const plan = await new HarnessConfigurationCompiler().plan({ + root, + strict: true, + }); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + target: "pi", + code: "FEATURE_SHIM", + level: "error", + })); + }); + + it("registers the complete requested harness set", () => { + const registered = createDefaultRegistry() + .list() + .map((adapter) => adapter.descriptor.id) + .sort(); + expect(registered).toEqual([...TARGET_IDS].sort()); + }); + + it.skipIf(process.platform === "win32")("restores symlink identity when apply rolls back", async () => { + const root = await temporaryRoot(); + await write(root, "target.txt", "original\n"); + await symlink("target.txt", path.join(root, "link.txt")); + + const plan: Plan = { + root, + targets: ["codex"], + diagnostics: [], + entries: [ + { path: "link.txt", owner: "common", action: "update", after: "changed\n", content: "changed\n" }, + { path: "later.txt", owner: "common", action: "create" }, + ], + nextState: { + version: 1, + generatedAt: new Date().toISOString(), + canonfigVersion: "1", + artifacts: {}, + }, + }; + + await expect(applyPlan(plan)).rejects.toThrow("Missing output content"); + expect((await lstat(path.join(root, "link.txt"))).isSymbolicLink()).toBe(true); + await expect(readlink(path.join(root, "link.txt"))).resolves.toBe("target.txt"); + await expect(readFile(path.join(root, "target.txt"), "utf8")).resolves.toBe("original\n"); + }); + + it("accepts empty frontmatter and a closing fence at EOF", () => { + expect(parseMarkdownDocument("---\n---")).toEqual({ data: {}, content: "" }); + expect(parseMarkdownDocument("---\nname: test\n---")).toEqual({ data: { name: "test" }, content: "" }); + }); + + it("stops TOML sections at the next single-bracket table", () => { + expect(findTomlSection(["[one]", "value = 1", "[two]", "value = 2"], "one")) + .toEqual({ header: 0, end: 2 }); + }); + + it("conflicts instead of replacing wrong-type managed JSON containers", () => { + const mapConflicts: string[] = []; + const mapped = applyJsonArtifact( + '{"mcpServers":[]}\n', + { + kind: "json", + path: ".mcp.json", + owner: "common", + operations: [{ kind: "managed-map", path: ["mcpServers"], entries: { local: {} } }], + }, + false, + mapConflicts, + ); + expect(mapConflicts).toHaveLength(1); + expect(JSON.parse(mapped.text)).toEqual({ mcpServers: [] }); + + const arrayConflicts: string[] = []; + const arrayed = applyJsonArtifact( + '{"packages":{}}\n', + { + kind: "json", + path: "settings.json", + owner: "common", + operations: [{ kind: "managed-array", path: ["packages"], values: ["pkg"] }], + }, + false, + arrayConflicts, + ); + expect(arrayConflicts).toHaveLength(1); + expect(JSON.parse(arrayed.text)).toEqual({ packages: {} }); + }); + + it("does not recreate absent or empty JSON during cleanup", () => { + const previous: ArtifactState = { + owner: "common", + hash: "unused", + existedBefore: true, + cleanup: [{ + kind: "json-managed-map", + path: ["mcpServers"], + entries: { local: {} }, + originals: { local: { existed: false } }, + }], + }; + const missingConflicts: string[] = []; + expect(unapplyPrevious(undefined, previous, false, missingConflicts)).toBeUndefined(); + expect(missingConflicts).toEqual([]); + + const emptyConflicts: string[] = []; + expect(unapplyPrevious("", previous, false, emptyConflicts)).toBe(""); + expect(emptyConflicts).toEqual([]); + }); + + it.skipIf(process.platform === "win32")("rejects scaffold writes through symlink escapes", async () => { + const root = await temporaryRoot("canonfig-scaffold-root-"); + const outside = await temporaryRoot("canonfig-scaffold-outside-"); + await symlink(outside, path.join(root, ".canonfig"), "dir"); + + await expect(scaffoldProject(root, { force: true })).rejects.toMatchObject({ code: "SYMLINK_ESCAPE" }); + await expect(readFile(path.join(outside, "harness.yaml"), "utf8")).rejects.toThrow(); + + await rm(path.join(root, ".canonfig")); + await mkdir(path.join(root, ".canonfig"), { recursive: true }); + const outsideTarget = path.join(outside, "existing.yaml"); + await writeFile(outsideTarget, "keep\n", "utf8"); + await symlink(outsideTarget, path.join(root, ".canonfig", "harness.yaml")); + + await expect(scaffoldProject(root, { force: true })).rejects.toMatchObject({ code: "SYMLINK_ESCAPE" }); + await expect(readFile(outsideTarget, "utf8")).resolves.toBe("keep\n"); + }); +}); diff --git a/tools/release/validate-release.ts b/tools/release/validate-release.ts index 776d51d..4cbd719 100644 --- a/tools/release/validate-release.ts +++ b/tools/release/validate-release.ts @@ -193,7 +193,7 @@ const validatePackageContents = ( if (artifact.name !== "@microck/canonfig" || artifact.version !== "2.0.0") { fail(`unexpected packed identity: ${artifact.name}@${artifact.version}`); } - if (artifact.size > 200_000 || artifact.unpackedSize > 1_000_000) { + if (artifact.size > 225_000 || artifact.unpackedSize > 1_125_000) { fail( `package exceeds release budget: ${artifact.size} packed, ${artifact.unpackedSize} unpacked`, ); diff --git a/tsconfig.json b/tsconfig.json index 742e100..d308f34 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "src/cli/**/*.ts", "src/domain/**/*.ts", "src/enrollment/**/*.ts", + "src/harness-configuration/**/*.ts", "src/machine/**/*.ts", "src/profile/**/*.ts", "src/runtime/**/*.ts",