// compile.mjs — the agent-profile registry compiler (zero-dependency, build-time). // // Reads every profiles/*.json, validates it against the profile contract // (fail-closed: an unknown wire_protocol or injection method is a build error, // never a guess — the honesty no-placeholder rule), and emits two artifacts: // // - agents.json the published registry (one consumer today: the CLI; // forward-looking for docs/web registry views) // - ../cli/src/agents.generated.ts the CLI's EMBEDDED copy (keeps the CLI zero-runtime-dep: // it imports a generated module, never reads a file at runtime) // // Run from the CLI build/test (`node ../agents/compile.mjs`). Output is deterministic // (profiles sorted by id), so re-running on unchanged input produces no diff. import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const siblingCliDir = join(here, "..", "cli"); const packageCliDir = join(here, "..", "packages", "cli"); const cliDir = process.env.CAVEMAN_CLI_DIR ? resolve(process.env.CAVEMAN_CLI_DIR) : existsSync(join(siblingCliDir, "package.json")) ? siblingCliDir : packageCliDir; const profilesDir = process.env.CAVEMAN_PROFILES_DIR ? resolve(process.env.CAVEMAN_PROFILES_DIR) : join(here, "profiles"); const schemaFile = join(here, "profiles", "schema.json"); const reservedFile = join(here, "reserved-verbs.json"); const WIRE_PROTOCOLS = new Set(["anthropic-messages", "openai-chat", "openai-responses", "gemini-generatecontent"]); const INJECTION_METHODS = new Set(["env", "config-env-content", "config-file", "native-extension"]); // native-extension is a closed allowlist on every field: the CLI resolves the asset to a // file it ships and appends the loader flag itself — a profile can never name an arbitrary // executable, path, or flag (fail-closed on anything unknown). const NATIVE_EXTENSION_HOSTS = new Set(["pi"]); const NATIVE_EXTENSION_ASSETS = new Set(["caveman-pi-extension"]); const NATIVE_EXTENSION_LOADER_FLAGS = new Set(["--extension"]); const PLATFORM_CONFIG_DEFAULTS = new Set(["qwen-system-settings"]); const COMMAND_HOOK_METHODS = new Set(["claude-pretooluse", "codex-pretooluse", "gemini-beforetool", "opencode-plugin", "hermes-plugin", "openclaw-plugin", "pi-extension", "instruction-note"]); const MEMORY_HOOK_METHODS = new Set(["claude-userpromptsubmit"]); const SKILL_FORMATS = new Set(["skill-md"]); const ENV_KEY_PATTERN = "^[A-Z][A-Z0-9_]*_(BASE_URL|API_BASE|API_KEY|AUTH_TOKEN|HOST)$"; const ENV_VALUE_PATTERN = "^(?:\\{\\{cave_(?:proxy_url|api_key|org_id)\\}\\}|\\{\\{cave_base_url\\}\\}(?:/[A-Za-z0-9._-]+)*|[A-Za-z0-9._-]+)$"; const PROFILE_PATH_PATTERN = "^~/\\.[a-z0-9][a-z0-9-]*/[A-Za-z0-9._/-]+$"; const ENV_KEY_RE = new RegExp(ENV_KEY_PATTERN); const ENV_VALUE_RE = new RegExp(ENV_VALUE_PATTERN); const PROFILE_PATH_RE = new RegExp(PROFILE_PATH_PATTERN); const OPTIONAL_OPENAI_KEY_ENV_TEMPLATE = "{{cave_optional_openai_key_env}}"; const TEMPLATE_RE = /\{\{cave_(?:base_url|proxy_url|api_key|org_id|optional_openai_key_env)\}\}/g; const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/; function die(msg) { console.error(`agent-profile compile failed: ${msg}`); process.exit(1); } // The three cross-repo-coupled honesty gates (catalog pricing, injection_completeness, // derived CI pins) are HARD on normal builds/PRs, but the profiles-backsync workflow // recompiles PUBLIC-repo profiles that this repo's CI pins/catalog may legitimately lead; // it sets CAVEMAN_REGISTRY_GATES_ADVISORY=1 so a cross-repo skew surfaces as a warning in // the human-reviewed sync patch instead of a hard failure. The load-bearing SECURITY checks // (env-key allowlist, loader-control-key rejection, path confinement, shell-metachar) are // NEVER advisory — they always use die(). const GATES_ADVISORY = process.env.CAVEMAN_REGISTRY_GATES_ADVISORY === "1"; function gate(cond, msg) { if (cond) return; if (GATES_ADVISORY) console.error(`agent-profile compile WARNING (advisory, cross-repo gate): ${msg}`); else die(msg); } function readJSON(file, label) { try { return JSON.parse(readFileSync(file, "utf8")); } catch (error) { die(`${label}: invalid JSON — ${error.message}`); } } const schema = readJSON(schemaFile, "schema.json"); const schemaProperties = new Set(Object.keys(schema?.properties ?? {})); const schemaPatterns = schema?.$defs ?? {}; for (const [name, expected] of [ ["envKey", ENV_KEY_PATTERN], ["envValue", ENV_VALUE_PATTERN], ["profilePath", PROFILE_PATH_PATTERN], ]) { if (schemaPatterns[name]?.pattern !== expected) { die(`schema.json: $defs.${name}.pattern must equal ${JSON.stringify(expected)}`); } } const reservedDocument = readJSON(reservedFile, "reserved-verbs.json"); if (reservedDocument?.schema_version !== "1" || !Array.isArray(reservedDocument.verbs)) { die('reserved-verbs.json: schema_version must be "1" and verbs must be an array'); } const reservedVerbs = [...reservedDocument.verbs].sort(); if (reservedVerbs.length === 0 || reservedVerbs.some((verb) => typeof verb !== "string" || verb.length === 0)) { die("reserved-verbs.json: every verb must be a non-empty string"); } if (new Set(reservedVerbs).size !== reservedVerbs.length) die("reserved-verbs.json: duplicate verb"); function loaderControlKey(key) { return key === "NODE_OPTIONS" || key === "PATH" || key === "NODE_PATH" || key === "PYTHONSTARTUP" || key === "PERL5OPT" || key.startsWith("LD_") || key.startsWith("DYLD_") || key.endsWith("_PROXY"); } function profilePathAllowed(value, id) { if (!PROFILE_PATH_RE.test(value) || !value.startsWith(`~/.${id}/`)) return false; return !value.split("/").includes(".."); } function validateEnvVariableName(value, need, label) { need(typeof value === "string" && ENV_VAR_RE.test(value) && !loaderControlKey(value), `${label} is not a safe environment variable name`); } function validateKnownKeys(value, allowed, need, label) { need(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); for (const key of Object.keys(value)) { need(allowed.has(key), `${label} has unknown key "${key}"`); } } function optionalOpenAIKeyEnvPathAllowed(profileId, segments) { const qwenManagedHeader = profileId === "qwen" && segments.length === 9 && segments[0] === "injection" && segments[1] === "config_overlay" && segments[2] === "managed" && segments[3] === "modelProviders" && segments[4] === "openai" && Number.isInteger(segments[5]) && segments[6] === "generationConfig" && segments[7] === "customHeaders" && segments[8] === "x-cave-upstream-key"; const kiloManagedHeader = profileId === "kilo" && segments.length === 8 && segments[0] === "injection" && segments[1] === "config_content" && segments[2] === "managed" && segments[3] === "provider" && segments[4] === "caveman" && segments[5] === "options" && segments[6] === "headers" && segments[7] === "x-cave-upstream-key"; return qwenManagedHeader || kiloManagedHeader; } function validateConfigStrings(value, need, path = "injection config", profileId = "", segments = []) { if (typeof value === "string") { const tokens = value.match(/\{\{[^}]+\}\}/g) ?? []; const knownTokens = value.match(TEMPLATE_RE) ?? []; need(tokens.length === knownTokens.length, `${path} contains an unknown template token`); need(!/[`;]|\$\(|\r|\n|\0/.test(value), `${path} contains a shell or loader metacharacter`); const key = path.split(".").at(-1) ?? ""; if (value.includes(OPTIONAL_OPENAI_KEY_ENV_TEMPLATE)) { need(value === OPTIONAL_OPENAI_KEY_ENV_TEMPLATE, `${path} must use ${OPTIONAL_OPENAI_KEY_ENV_TEMPLATE} as the entire value`); need( optionalOpenAIKeyEnvPathAllowed(profileId, segments), `${path} may use ${OPTIONAL_OPENAI_KEY_ENV_TEMPLATE} only as Qwen or Kilo's closed managed OpenAI provider x-cave-upstream-key`, ); return; } if (/^(baseurl|base_url|api_base|host|endpoint|url)$/i.test(key)) { const remainder = value.replace(TEMPLATE_RE, ""); need(knownTokens.length > 0 && /^[A-Za-z0-9._/-]*$/.test(remainder), `${path} must route through a cave template token`); } else if (value.includes("://")) { need(key === "$schema" && /^https:\/\/[A-Za-z0-9._/-]+$/.test(value), `${path} contains a literal URL`); } return; } if (Array.isArray(value)) { value.forEach((item, index) => validateConfigStrings(item, need, `${path}[${index}]`, profileId, [...segments, index])); return; } if (value && typeof value === "object") { for (const [key, item] of Object.entries(value)) { validateConfigStrings(item, need, `${path}.${key}`, profileId, [...segments, key]); } } } // ---- provider-catalog cross-check (fail-closed on an unpriced model) ---- // A profile that pins a model id (opencode's inline config, e.g.) must pin one the // catalog PRICES: an unpriced id books at a zero/unpriced rate and under-reports the // operator's spend on traffic our own wrap sent. The catalog is public data (the source // of truth for cost math), parsed by a minimal line scan so the compiler stays zero-dep. const siblingCatalogFile = join(here, "..", "shared", "provider-catalog", "catalog", "current.yaml"); const packagedCatalogFile = join(here, "..", "packages", "shared", "provider-catalog", "catalog", "current.yaml"); const catalogFile = process.env.CAVEMAN_CATALOG_FILE ? resolve(process.env.CAVEMAN_CATALOG_FILE) : existsSync(siblingCatalogFile) ? siblingCatalogFile : packagedCatalogFile; function loadCatalogModelIds(file) { let text; try { text = readFileSync(file, "utf8"); } catch (error) { die(`provider catalog not found at ${file} — cannot verify model pricing (fail-closed): ${error.message}`); } const ids = new Set(); for (const match of text.matchAll(/^ {2}model:\s*(.+?)\s*$/gm)) ids.add(match[1].trim()); if (ids.size === 0) die(`provider catalog at ${file} yielded no model ids (fail-closed)`); return ids; } // Strip a single leading `provider/` prefix (opencode default is `caveman/`). function bareModelId(id) { return id.replace(/^[a-z0-9][a-z0-9-]*\//, ""); } const CATALOG_MODEL_IDS = loadCatalogModelIds(catalogFile); // Collect model ids from every supported profile shape: values of a `model` key, // keys of a `models` map (AI SDK/OpenCode), and entries in a `modelProviders` // array (Qwen Code). Missing one shape would let routed traffic bypass catalog // pricing validation and later book at an unpriced zero. function collectInjectionModelIds(node, out) { if (Array.isArray(node)) { for (const v of node) collectInjectionModelIds(v, out); return; } if (node && typeof node === "object") { for (const [k, v] of Object.entries(node)) { if (k === "model" && typeof v === "string" && v) out.add(bareModelId(v)); if (k === "models" && v && typeof v === "object" && !Array.isArray(v)) for (const mk of Object.keys(v)) out.add(bareModelId(mk)); if (k === "modelProviders" && v && typeof v === "object" && !Array.isArray(v)) { for (const models of Object.values(v)) { if (!Array.isArray(models)) continue; for (const model of models) { if (model && typeof model === "object" && typeof model.id === "string" && model.id) out.add(bareModelId(model.id)); } } } collectInjectionModelIds(v, out); } } } // ---- code-assisted builder manifest (generated by scanning the CLI) ---- // The doc claim "adding an agent is a data change, not a code change" is only true for // agents the CLI routes purely from their profile. Several are NOT: their real routing // lives in a code builder. This manifest is DERIVED from index.ts so a profile cannot // claim `declarative` while a builder actually handles it (issue #135). Signals: // - `overlayBuilders. =` a config-file overlay builder that overrides the profile. // - a named agent-specific wrap builder present in the source. // If index.ts is absent (a layout with no CLI) the manifest is unknown and the id-based // tier check is skipped; the inert-injection rule (below) still applies unconditionally. const BUILDER_FUNCTIONS = { buildCodexEphemeralWrapEnv: "codex", applyHermesAuthEnv: "hermes", applyClaudeBedrockWrap: "claude" }; // Builders that MUST resolve when index.ts is present: if the scrape misses one, a wrap // builder was renamed/removed and the manifest (plus the affected profile's tier) is stale. // A silent miss would let a now-builder-routed profile keep a `declarative` claim, so we // fail LOUD here rather than trust a degraded manifest. const EXPECTED_BUILDER_IDS = ["openclaw", "codex", "hermes", "claude"]; function loadBuilderManifest() { const indexFile = join(cliDir, "src", "index.ts"); let text; try { text = readFileSync(indexFile, "utf8"); } catch { return null; } const ids = new Set(); for (const m of text.matchAll(/overlayBuilders\.([a-z0-9-]+)\s*=/g)) ids.add(m[1]); for (const [fn, id] of Object.entries(BUILDER_FUNCTIONS)) if (text.includes(fn)) ids.add(id); // Zero-builder guard (mirrors loadCatalogModelIds' zero-guard): index.ts present but no // builder matched means the scrape shape changed — never silently trust an empty manifest. if (ids.size === 0) die(`builder-manifest scrape of ${indexFile} found ZERO builders — the CLI's builder shape changed; update loadBuilderManifest before trusting injection_completeness (fail-closed)`); for (const id of EXPECTED_BUILDER_IDS) { if (!ids.has(id)) die(`builder-manifest scrape did not resolve expected builder "${id}" in ${indexFile} — a wrap builder was renamed/removed; update loadBuilderManifest and that profile's injection_completeness together (fail-closed)`); } return ids; } const BUILDER_MANIFEST = loadBuilderManifest(); const INJECTION_COMPLETENESS = new Set(["declarative", "builder-assisted", "code-only"]); function injectionIsInert(p) { return p.injection && p.injection.method === "env" && p.injection.env && typeof p.injection.env === "object" && Object.keys(p.injection.env).length === 0; } // The tier the profile's own data + the CLI manifest imply. Returns null only when the // manifest is unknown AND the injection is not inert (cannot decide). function derivedCompleteness(p) { if (injectionIsInert(p)) return "code-only"; if (BUILDER_MANIFEST === null) return null; return BUILDER_MANIFEST.has(p.id) ? "builder-assisted" : "declarative"; } // A declared last_verified_at older than this many days fails closed (re-verify or drop). const STALENESS_DAYS = 365; // validate enforces the load-bearing invariants. It is intentionally small (no ajv // dependency) but fails closed on exactly the things that would otherwise ship a // silently-broken or guessed profile. function validate(p, file) { const need = (cond, msg) => { if (!cond) die(`${file}: ${msg}`); }; need(p && typeof p === "object", "profile is not an object"); for (const key of Object.keys(p)) need(schemaProperties.has(key), `unknown top-level key "${key}"`); need(p.schema_version === "1", `schema_version must be "1"`); need(typeof p.id === "string" && /^[a-z0-9][a-z0-9-]*$/.test(p.id), "id must be kebab-case"); need(p.id.length <= 64, "id must fit the proxy's 64-byte agent slug limit"); need(!reservedVerbs.includes(p.id), `id "${p.id}" collides with a reserved command`); for (const k of ["display_name", "vendor", "homepage", "install"]) { need(typeof p[k] === "string" && p[k].length > 0, `${k} must be a non-empty string`); } need(Array.isArray(p.binary_names) && p.binary_names.length > 0 && p.binary_names.every((name) => typeof name === "string" && name.length > 0), "binary_names must be a non-empty string array"); for (const name of p.binary_names) need(!reservedVerbs.includes(name), `binary name "${name}" collides with a reserved command`); need(WIRE_PROTOCOLS.has(p.wire_protocol), `unknown wire_protocol "${p.wire_protocol}" (fail-closed)`); const inj = p.injection; need(inj && typeof inj === "object", "injection must be an object"); need(INJECTION_METHODS.has(inj.method), `unknown injection.method "${inj.method}" (fail-closed)`); if (inj.method === "env") { validateKnownKeys(inj, new Set(["method", "env"]), need, "injection"); need(inj.env && typeof inj.env === "object" && !Array.isArray(inj.env), "injection.env must be an object"); for (const [key, value] of Object.entries(inj.env)) { need(ENV_KEY_RE.test(key) && !loaderControlKey(key), `injection.env key "${key}" is not allowlisted`); need(typeof value === "string" && ENV_VALUE_RE.test(value), `injection.env.${key} must be one cave template token with an optional safe base-URL path, or a safe literal`); if (typeof value === "string" && value.startsWith("{{cave_base_url}}/")) { need(key.endsWith("_BASE_URL") || key.endsWith("_API_BASE"), `injection.env.${key} cannot append a path to cave_base_url`); } } } else if (inj.method === "config-env-content") { validateKnownKeys(inj, new Set(["method", "env_var", "config_content"]), need, "injection"); validateEnvVariableName(inj.env_var, need, "injection.env_var"); validateKnownKeys(inj.config_content, new Set(["local", "managed"]), need, "injection.config_content"); need(inj.config_content.local && typeof inj.config_content.local === "object" && !Array.isArray(inj.config_content.local), "injection.config_content.local is required"); if (inj.config_content.managed !== undefined) { need(inj.config_content.managed && typeof inj.config_content.managed === "object" && !Array.isArray(inj.config_content.managed), "injection.config_content.managed must be an object"); } validateConfigStrings(inj.config_content, need, "injection.config_content", p.id, ["injection", "config_content"]); } else if (inj.method === "config-file") { validateKnownKeys(inj, new Set(["method", "env_var", "base_config", "config_overlay"]), need, "injection"); validateEnvVariableName(inj.env_var, need, "injection.env_var"); if (inj.base_config !== undefined) { validateKnownKeys(inj.base_config, new Set(["path", "env_var", "state_dir", "platform_default"]), need, "injection.base_config"); need(typeof inj.base_config.path === "string" && profilePathAllowed(inj.base_config.path, p.id), `injection.base_config.path must stay under ~/.${p.id}/ without ..`); if (inj.base_config.env_var !== undefined) validateEnvVariableName(inj.base_config.env_var, need, "injection.base_config.env_var"); if (inj.base_config.state_dir !== undefined) { validateKnownKeys(inj.base_config.state_dir, new Set(["env_var", "filename"]), need, "injection.base_config.state_dir"); validateEnvVariableName(inj.base_config.state_dir.env_var, need, "injection.base_config.state_dir.env_var"); need(typeof inj.base_config.state_dir.filename === "string" && inj.base_config.state_dir.filename.length > 0, "injection.base_config.state_dir.filename must be a non-empty string"); } if (inj.base_config.platform_default !== undefined) { need(PLATFORM_CONFIG_DEFAULTS.has(inj.base_config.platform_default), `injection.base_config.platform_default "${inj.base_config.platform_default}" is not allowlisted`); need(inj.base_config.platform_default === `${p.id}-system-settings`, "injection.base_config.platform_default must belong to the profile id"); } } validateKnownKeys(inj.config_overlay, new Set(["local", "managed"]), need, "injection.config_overlay"); need(Object.prototype.hasOwnProperty.call(inj.config_overlay, "local"), "injection.config_overlay.local is required"); validateConfigStrings(inj.config_overlay, need, "injection.config_overlay", p.id, ["injection", "config_overlay"]); } else if (inj.method === "native-extension") { validateKnownKeys(inj, new Set(["method", "host", "asset", "loader_flag"]), need, "injection"); need(NATIVE_EXTENSION_HOSTS.has(inj.host), `injection.host "${inj.host}" is not an allowlisted native-extension host (fail-closed)`); need(inj.host === p.id, `injection.host must equal the profile id (got "${inj.host}" for "${p.id}")`); need(NATIVE_EXTENSION_ASSETS.has(inj.asset), `injection.asset "${inj.asset}" is not an allowlisted extension asset (fail-closed)`); need(NATIVE_EXTENSION_LOADER_FLAGS.has(inj.loader_flag), `injection.loader_flag "${inj.loader_flag}" is not an allowlisted loader flag (fail-closed)`); } // command_hook is optional, but if present its method must be one we can honor — // an unknown method fails the build rather than claim a hook we'd silently no-op. if (p.command_hook !== undefined) { const ch = p.command_hook; need(ch && typeof ch === "object", "command_hook must be an object"); need(COMMAND_HOOK_METHODS.has(ch.method), `unknown command_hook.method "${ch.method}" (fail-closed)`); validateKnownKeys(ch, new Set(ch.method === "instruction-note" || ch.method === "codex-pretooluse" ? ["method", "file"] : ["method"]), need, "command_hook"); if (ch.method === "instruction-note" || ch.method === "codex-pretooluse") { need(typeof ch.file === "string" && profilePathAllowed(ch.file, p.id), `command_hook.file must stay under ~/.${p.id}/ without ..`); } } // memory_hook is optional (opt-in auto-recall); if present its method must be one // we can honor — an unknown method fails the build rather than claim a hook. if (p.memory_hook !== undefined) { const mh = p.memory_hook; need(mh && typeof mh === "object", "memory_hook must be an object"); need(MEMORY_HOOK_METHODS.has(mh.method), `unknown memory_hook.method "${mh.method}" (fail-closed)`); validateKnownKeys(mh, new Set(["method"]), need, "memory_hook"); } // skills is optional (the agent's on-disk skill surface for `caveman convert`); // if present its format must be one we can parse — unknown fails the build. if (p.skills !== undefined) { const sk = p.skills; need(sk && typeof sk === "object", "skills must be an object"); validateKnownKeys(sk, new Set(["format", "user_dirs", "project_dirs"]), need, "skills"); need(SKILL_FORMATS.has(sk.format), `unknown skills.format "${sk.format}" (fail-closed)`); need(Array.isArray(sk.user_dirs) && sk.user_dirs.length > 0 && sk.user_dirs.every((d) => typeof d === "string" && d.length > 0), "skills.user_dirs must be a non-empty string array"); if (sk.project_dirs !== undefined) { need(Array.isArray(sk.project_dirs) && sk.project_dirs.every((d) => typeof d === "string" && d.length > 0), "skills.project_dirs must be a string array"); } } if (p.attribution !== undefined) { validateKnownKeys(p.attribution, new Set(["header"]), need, "attribution"); if (p.attribution.header !== undefined) need(typeof p.attribution.header === "string", "attribution.header must be a string"); } // Every model id pinned in the injection config must be priced by the provider catalog // (issue #136): an unpriced pin under-reports the operator's spend. Fail closed. const pinnedModels = new Set(); collectInjectionModelIds(p.injection, pinnedModels); for (const id of pinnedModels) { gate(CATALOG_MODEL_IDS.has(id), `${file}: injection pins model "${id}" which is not priced in the provider catalog (fail-closed: no unpriced model in a shipped profile)`); } // injection_completeness is REQUIRED for every profile and must match the tier the CLI's // real routing implies (issue #135) — a profile cannot OMIT it (an omitted tier on a // builder-routed agent would silently escape the gate), cannot claim `declarative` while // a builder handles it, nor `declarative` while its declared injection is inert. gate(p.injection_completeness !== undefined, `${file}: injection_completeness is required for every profile (declarative | builder-assisted | code-only)`); if (p.injection_completeness !== undefined) { gate(INJECTION_COMPLETENESS.has(p.injection_completeness), `${file}: unknown injection_completeness "${p.injection_completeness}" (fail-closed)`); if (injectionIsInert(p)) { gate(p.injection_completeness === "code-only", `${file}: injection_completeness must be "code-only": injection.env is empty, so all routing is code (got "${p.injection_completeness}")`); } else if (p.injection.method === "native-extension") { // A native-extension injection is builder-assisted by construction: the profile // declares host/asset/flag as data, but the CLI resolves the shipped asset and // appends the loader flag in code. Neither purer nor darker tiers are honest. gate(p.injection_completeness === "builder-assisted", `${file}: injection_completeness must be "builder-assisted" for native-extension injection (got "${p.injection_completeness}")`); } else if (BUILDER_MANIFEST !== null) { if (BUILDER_MANIFEST.has(p.id)) { gate(p.injection_completeness !== "declarative", `${file}: injection_completeness cannot be "declarative": the CLI has a code builder for "${p.id}" (declare "builder-assisted" or "code-only")`); } else { gate(p.injection_completeness === "declarative", `${file}: injection_completeness must be "declarative": the CLI has no builder for "${p.id}" (got "${p.injection_completeness}")`); } } } // last_verified_at / verified_by (optional): a claimed verification must be well-formed // and inside the staleness budget — an expired claim fails closed (re-verify or drop it). if (p.last_verified_at !== undefined || p.verified_by !== undefined) { need(typeof p.last_verified_at === "string" && /^\d{4}-\d{2}-\d{2}/.test(p.last_verified_at), "last_verified_at must be an ISO date (YYYY-MM-DD…) when verified_by is set"); need(typeof p.verified_by === "string" && p.verified_by.length > 0, "verified_by must be a non-empty string accompanying last_verified_at"); const when = Date.parse(p.last_verified_at); need(!Number.isNaN(when), "last_verified_at must be a parseable date"); const ageDays = (Date.now() - when) / 86_400_000; need(ageDays <= STALENESS_DAYS, `last_verified_at is ${Math.floor(ageDays)}d old, over the ${STALENESS_DAYS}d staleness budget — re-verify and bump it`); } } // pinnedVersions extracts every semver pin an install command carries, the one // spelling shared by npm (@x.y.z) and pip (==x.y.z). One rule for both the // conformance matrix and a profile's own install command, so the two cannot // disagree about what counts as a pin. function pinnedVersions(install) { return [...String(install).matchAll(/(?:@|==)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=$|[^0-9A-Za-z.-])/g)] .map((match) => match[1]); } // derivablePin reports the profile pin a matrix or install pin must equal, or // undefined when there is nothing to derive from (absent, "x", or a non-semver // pin such as a git sha). function derivablePin(profile) { const pv = profile?.tested_agent_version; if (!pv || pv === "x") return undefined; return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pv) ? pv : undefined; } // checkProfileInstallPins covers the pin the conformance gate below cannot see. // Most profiles give an unpinned install command, but kilo and qwen pin theirs, // and nothing derived that pin from tested_agent_version — so a bump could ship // a profile that claims to test one version while telling users to install // another. An unpinned command stays legal; a pinned one must agree. function checkProfileInstallPins(agentsById) { for (const id of [...agentsById.keys()].sort()) { const profile = agentsById.get(id); const pv = derivablePin(profile); if (!pv || !profile.install) continue; const versions = pinnedVersions(profile.install); if (versions.length === 0) continue; // unpinned install command — nothing to derive gate( versions.length === 1, `agents/profiles/${id}.json: install command must carry at most one version pin, matching tested_agent_version ${pv} (found ${versions.length})`, ); if (versions.length === 1 && versions[0] !== pv) { gate(false, `agents/profiles/${id}.json: ${id} install pins ${versions[0]} but profile tested_agent_version is ${pv} — a pinned install command must equal the profile pin`); } } } // checkConformancePins derives every agent-conformance matrix pin from the profile's // tested_agent_version so the two cannot silently diverge (issue #135). This compiler runs // from the source registry, where the workflow is part of the contract: a missing or // unparseable workflow must fail rather than silently disable the pin gate. function checkConformancePins(agentsById) { const wf = process.env.CAVEMAN_CONFORMANCE_WORKFLOW ? resolve(process.env.CAVEMAN_CONFORMANCE_WORKFLOW) : join(here, "..", ".github", "workflows", "agent-conformance.yml"); let text; try { text = readFileSync(wf, "utf8"); } catch (error) { die(`conformance workflow not readable at ${wf} — cannot verify shipped profile pins (fail-closed): ${error.message}`); } // Parse only pinned-upstream-binary.matrix.include. A repository-wide `- id:` scan // would mix in the @latest matrix and could let one lane accidentally satisfy another. const jobMatch = /^ pinned-upstream-binary:\s*$/m.exec(text); if (!jobMatch) die(`${wf}: missing pinned-upstream-binary job (fail-closed)`); const jobStart = jobMatch.index + jobMatch[0].length; const afterJob = text.slice(jobStart); const nextJob = /^ [A-Za-z0-9_-]+:\s*$/m.exec(afterJob); const job = nextJob ? afterJob.slice(0, nextJob.index) : afterJob; const includeMatch = /^ {8}include:\s*$/m.exec(job); if (!includeMatch) die(`${wf}: pinned-upstream-binary matrix.include is missing or unparseable (fail-closed)`); const includeStart = includeMatch.index + includeMatch[0].length; const afterInclude = job.slice(includeStart); const includeEnd = /^ {4}[A-Za-z0-9_-]+:\s*$/m.exec(afterInclude); const include = includeEnd ? afterInclude.slice(0, includeEnd.index) : afterInclude; const declaredIds = [...include.matchAll(/^ {10}- id:\s*(\S+)\s*$/gm)].map((match) => match[1]); const entries = [...include.matchAll(/^ {10}- id:\s*(\S+)\s*\r?\n {12}install:\s*(\S[^\r\n]*)$/gm)] .map((match) => ({ id: match[1], install: match[2].trim() })); if (declaredIds.length === 0 || entries.length !== declaredIds.length) { die(`${wf}: pinned-upstream-binary matrix entries must each be exactly an id followed by one single-line install command (fail-closed)`); } const duplicateIds = declaredIds.filter((id, index) => declaredIds.indexOf(id) !== index); if (duplicateIds.length > 0) die(`${wf}: duplicate pinned profile id(s): ${[...new Set(duplicateIds)].join(", ")}`); const entryById = new Map(entries.map((entry) => [entry.id, entry])); const shippedIds = [...agentsById.keys()].sort(); const missing = shippedIds.filter((id) => !entryById.has(id)); const unknown = declaredIds.filter((id) => !agentsById.has(id)); gate(missing.length === 0, `${wf}: pinned-upstream-binary is missing shipped profile id(s): ${missing.join(", ")}`); gate(unknown.length === 0, `${wf}: pinned-upstream-binary contains unknown profile id(s): ${unknown.join(", ")}`); for (const id of shippedIds) { const profile = agentsById.get(id); const entry = entryById.get(id); if (!entry) continue; const pv = derivablePin(profile); if (!pv) continue; // absent, "x", or a non-semver pin (e.g. a git sha) — not derivable const versions = pinnedVersions(entry.install); gate(versions.length === 1, `${wf}: pinned install for ${id} must carry exactly one parseable version pin matching tested_agent_version ${pv} (found ${versions.length})`); if (versions.length === 1 && versions[0] !== pv) { gate(false, `${wf} pins ${id}@${versions[0]} but profile tested_agent_version is ${pv} — the CI pin must equal the profile pin (issue #135)`); } } } const checkProfileAt = process.argv.indexOf("--check-profile"); if (checkProfileAt !== -1) { const file = process.argv[checkProfileAt + 1]; if (!file) die("--check-profile requires a JSON file"); const parsed = readJSON(file, file); validate(parsed, file); console.error(`agent profile valid: ${parsed.id}`); process.exit(0); } const files = readdirSync(profilesDir).filter((f) => f.endsWith(".json") && f !== "schema.json").sort(); const agents = []; const seen = new Set(); for (const f of files) { let parsed; try { parsed = JSON.parse(readFileSync(join(profilesDir, f), "utf8")); } catch (e) { die(`${f}: invalid JSON — ${e.message}`); } validate(parsed, f); if (seen.has(parsed.id)) die(`${f}: duplicate id "${parsed.id}"`); seen.add(parsed.id); if (!Array.isArray(parsed.args)) parsed.args = []; agents.push(parsed); } agents.sort((a, b) => a.id.localeCompare(b.id)); // The CI upstream-binary matrix pins must equal the profile pins they claim to test. const agentsById = new Map(agents.map((a) => [a.id, a])); checkProfileInstallPins(agentsById); checkConformancePins(agentsById); // agents.json — the published registry. const registry = { schema_version: "1", agents }; writeFileSync(join(here, "agents.json"), JSON.stringify(registry, null, 2) + "\n"); // agents.generated.ts — the CLI's embedded, typed copy. The type preamble is // static; only the data array changes with the profiles. const PREAMBLE = `// GENERATED by agents/compile.mjs from agents/profiles/*.json — DO NOT EDIT. // Run \`node scripts/compile-registries.mjs\` (wired into the CLI build/test) to regenerate. export type WireProtocol = "anthropic-messages" | "openai-chat" | "openai-responses" | "gemini-generatecontent"; export type Injection = | { method: "env"; env: Record } | { method: "config-env-content"; env_var: string; config_content: { local: unknown; managed?: unknown } } | { method: "config-file"; env_var: string; base_config?: { path: string; env_var?: string; state_dir?: { env_var: string; filename: string }; platform_default?: "qwen-system-settings" }; config_overlay: { local: unknown; managed?: unknown } } | { method: "native-extension"; host: string; asset: string; loader_flag: string }; export type CommandHook = | { method: "claude-pretooluse" } | { method: "codex-pretooluse"; file: string } | { method: "gemini-beforetool" } | { method: "opencode-plugin" } | { method: "hermes-plugin" } | { method: "openclaw-plugin" } | { method: "pi-extension" } | { method: "instruction-note"; file: string }; export type MemoryHook = | { method: "claude-userpromptsubmit" }; export type SkillsSurface = { format: "skill-md"; user_dirs: string[]; project_dirs?: string[] }; export interface AgentProfile { schema_version: string; id: string; display_name: string; vendor: string; homepage: string; binary_names: string[]; args: string[]; install: string; wire_protocol: WireProtocol; injection: Injection; command_hook?: CommandHook; memory_hook?: MemoryHook; skills?: SkillsSurface; attribution?: { header?: string }; tested_agent_version?: string; injection_completeness?: "declarative" | "builder-assisted" | "code-only"; last_verified_at?: string; verified_by?: string; fallback?: string; maintainer?: string | null; } export const PROFILES: AgentProfile[] = `; writeFileSync(join(cliDir, "src", "agents.generated.ts"), PREAMBLE + JSON.stringify(agents, null, 2) + ";\n"); const RESERVED_PREAMBLE = `// GENERATED by agents/compile.mjs from agents/reserved-verbs.json — DO NOT EDIT. // Run \`node scripts/compile-registries.mjs\` (wired into CLI build/test) to regenerate. export const RESERVED_VERBS = new Set(`; writeFileSync( join(cliDir, "src", "reserved-verbs.generated.ts"), RESERVED_PREAMBLE + JSON.stringify(reservedVerbs, null, 2) + ");\n", ); console.error(`compiled ${agents.length} agent profile(s): ${agents.map((a) => a.id).join(", ")}`);