akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
251 lines (250 loc) • 11.8 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* The shared input contract, generalizing the pure module
* `src/workflows/ir/params.ts` (workflow parameters) into a contract-shaped
* vocabulary both workflow params AND task source v4's `inputs:`
* declarations consume — one validation/coercion implementation, injected
* per-caller diagnostics (D3-N3). Binding boundary: `src/execution/**` must
* never import `src/workflows/**` (D3-N1) — `INPUT_NAME_PATTERN` and the
* canonical-JSON helpers are therefore defined locally rather than imported
* from the workflow side (D3-N1/D3-N2), verified by
* `tests/execution/input-contract.test.ts`'s purity/byte-equality scans.
* Pure module: no IO, no engine imports.
*
* See docs/architecture/decisions/0004-task-input-contract-and-flag-coercion.md
* for the full generalization history and the D3-N1/D3-N2/D3-N3 rationale.
*/
import { createHash } from "node:crypto";
import { validateJsonSchemaSubset } from "../core/json-schema.js";
/**
* Input/param names must be addressable as a plain identifier
* (`params.<ident>` / `inputs.<ident>`): a letter or underscore, then
* letters, digits, or underscores. Byte-identical source/flags to
* `src/workflows/parser.ts`'s `PROGRAM_PARAM_NAME_PATTERN`, which
* re-exports this constant (D3-N1) rather than defining its own copy.
*/
export const INPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Apply declared defaults on top of supplied values. A supplied value always
* wins, including an explicit falsy one (`false`, `0`, `""`) — PRESENCE, not
* truthiness, decides. A declared input with no `default` that is absent from
* `values` is left absent from the result (never filled with `undefined`); a
* value not named by the contract passes through unchanged — defaults are
* additive, not a filter. Returns a NEW object; `values` is never mutated.
*/
export function applyInputDefaults(contract, values) {
const result = { ...values };
for (const [name, declaration] of Object.entries(contract)) {
if (Object.hasOwn(result, name))
continue;
if (Object.hasOwn(declaration, "default"))
result[name] = declaration.default;
}
return result;
}
/**
* Validate `values` against the contract's declared schemas plus its
* `required` flags. Builds the same synthetic `{type:"object",
* properties:<schemas>}` object schema the workflow param validator built,
* re-roots {@link validateJsonSchemaSubset}'s leading `$` at `pathRoot`
* (default `"$"`), and additionally appends one
* `"<pathRoot>.<name>: is required"` string per declared-required input
* absent from `values` (schema violations first, missing-required after). A
* name absent from the contract is never constrained. Returns `[]` when
* `values` fully satisfies the contract.
*/
export function validateInputs(contract, values, options) {
const pathRoot = options?.pathRoot ?? "$";
const properties = {};
for (const [name, declaration] of Object.entries(contract))
properties[name] = declaration.schema;
const schemaErrors = Object.keys(properties).length === 0
? []
: // redactValues: true — declared inputs can carry credentials, and this
// contract-violation text lands in stderr envelopes that get pasted into
// CI logs. See `coerceFlagValue`'s matching comment above.
validateJsonSchemaSubset(values, { type: "object", properties }, { redactValues: true }).map((error) => error.replace(/^\$/, pathRoot));
const missingRequired = [];
for (const [name, declaration] of Object.entries(contract)) {
if (declaration.required && !Object.hasOwn(values, name)) {
missingRequired.push(`${pathRoot}.${name}: is required`);
}
}
return [...schemaErrors, ...missingRequired];
}
/**
* Materialize exact-name CLI input flags against a contract. The CLI
* deliberately carries RAW string/boolean flag values to this one boundary
* so type coercion cannot race or drift from the declared contract:
* exact-name matching (a mistyped or undeclared flag name, or one that fails
* {@link INPUT_NAME_PATTERN}, is `diagnostics.unknownFlag` — the full
* declared set, sorted), array grouping (repeated flags on an array-declared
* input collect into an array in supplied order), the `[`-prefixed
* JSON-array shorthand, repeated-flag rejection on a non-array declaration
* (`diagnostics.duplicateNonArray`), and string-preserving coercion (a union
* type that permits `string` keeps the caller's exact text — `--version 001`
* on `type:"string"` stays the string `"001"`, never a silently-converted
* number, B-30). Ends by running {@link validateInputs} over the
* materialized result and raising `diagnostics.contractViolation` with its
* (still `"$"`-rooted) errors when non-empty.
*
* Returns `{}` immediately for zero flags, regardless of the contract, and
* calls no diagnostic — callers combine this with
* {@link applyInputDefaults} and a separate {@link validateInputs} call
* (after defaults are applied) to catch a `required` input that was never
* supplied as a flag at all.
*/
export function materializeInputFlags(contract, flags, diagnostics) {
if (flags.length === 0)
return {};
const declared = new Set(Object.keys(contract));
const grouped = new Map();
for (const flag of flags) {
if (!INPUT_NAME_PATTERN.test(flag.name) || !declared.has(flag.name)) {
throw diagnostics.unknownFlag(flag.name, [...declared].sort());
}
const values = grouped.get(flag.name) ?? [];
values.push(flag.value);
grouped.set(flag.name, values);
}
const entries = [];
for (const [name, rawValues] of grouped) {
entries.push([name, materializeFlagValues(name, rawValues, contract[name]?.schema, diagnostics)]);
}
const values = Object.fromEntries(entries);
const errors = validateInputs(contract, values);
if (errors.length > 0)
throw diagnostics.contractViolation(errors);
return values;
}
function materializeFlagValues(name, values, schema, diagnostics) {
const types = schemaTypes(schema);
if (types.includes("array")) {
if (values.length === 1 && typeof values[0] === "string" && values[0].trim().startsWith("[")) {
const parsed = parseJsonFlag(name, values[0], diagnostics);
if (!Array.isArray(parsed))
throw diagnostics.invalidValue(name, "must be a JSON array");
return parsed;
}
// "array" is the sole declared type, or there is more than one supplied
// flag occurrence: a lone-array declaration always wraps its value(s),
// and repeated flags on an array-CAPABLE declaration always group,
// regardless of what else the type union permits. A union that ALSO
// permits a scalar type (e.g. `["array","string"]`) must not force a
// single, non-bracketed value into this branch, though — F1: without
// this guard, `--x hello` against `type:["array","string"]` silently
// became `["hello"]` instead of staying the string "hello", because the
// per-element map below coerces each value against `items` (or nothing)
// rather than trying the union's scalar alternatives first. Route that
// single-value case through `coerceFlagValue` with the FULL schema
// instead, so its string-preservation (B-30) and null/number/boolean/
// object arms run before "array" is ever assumed.
if (types.length === 1 || values.length > 1) {
const itemSchema = isRecord(schema?.items) ? schema.items : undefined;
return values.map((value) => coerceFlagValue(name, value, itemSchema, diagnostics));
}
return coerceFlagValue(name, values[0], schema, diagnostics);
}
if (values.length > 1)
throw diagnostics.duplicateNonArray(name);
return coerceFlagValue(name, values[0], schema, diagnostics);
}
function coerceFlagValue(name, raw, schema, diagnostics) {
const types = schemaTypes(schema);
if (types.length === 0)
return raw;
if (typeof raw === "boolean") {
if (types.includes("boolean"))
return raw;
if (types.includes("string"))
return String(raw);
throw diagnostics.invalidValue(name, `requires a value of type ${types.join(" | ")}`);
}
// A union that permits strings keeps the caller's exact text. This
// prevents a value such as "001" from being silently converted to a
// number (B-30).
if (types.includes("string"))
return raw;
for (const type of types) {
switch (type) {
case "boolean":
if (raw === "true")
return true;
if (raw === "false")
return false;
break;
case "number": {
const value = Number(raw);
if (raw.trim() !== "" && Number.isFinite(value))
return value;
break;
}
case "integer": {
const value = Number(raw);
if (raw.trim() !== "" && Number.isSafeInteger(value))
return value;
break;
}
case "null":
if (raw === "null")
return null;
break;
case "object": {
const parsed = parseJsonFlag(name, raw, diagnostics);
if (isRecord(parsed))
return parsed;
break;
}
case "array": {
const parsed = parseJsonFlag(name, raw, diagnostics);
if (Array.isArray(parsed))
return parsed;
break;
}
}
}
// Never echo the supplied value: typed-input flags can carry credentials,
// and this detail lands in stderr envelopes that get pasted into CI logs.
throw diagnostics.invalidValue(name, `must be ${types.join(" | ")}`);
}
function schemaTypes(schema) {
const declared = schema?.type;
if (typeof declared === "string")
return [declared];
return Array.isArray(declared) ? declared.filter((value) => typeof value === "string") : [];
}
function parseJsonFlag(name, raw, diagnostics) {
try {
return JSON.parse(raw);
}
catch {
throw diagnostics.malformedJson(name);
}
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Canonical JSON used for input hashing: object keys recursively sorted, so
* two structurally-equal input value sets hash identically regardless of key
* insertion order. Byte-equal to `canonicalJson`
* (`src/workflows/ir/plan-hash.ts`) — see D3-N2 above.
*/
export function canonicalInputJson(value) {
return JSON.stringify(sortInputJsonKeys(value));
}
/** sha256 hex digest of {@link canonicalInputJson}'s output — a stable input-value fingerprint (B-39, for P2b execution identity). */
export function canonicalInputHash(value) {
return createHash("sha256").update(canonicalInputJson(value)).digest("hex");
}
function sortInputJsonKeys(value) {
if (value === null || typeof value !== "object")
return value;
if (Array.isArray(value))
return value.map((item) => sortInputJsonKeys(item));
return Object.fromEntries(Object.entries(value)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key, child]) => [key, sortInputJsonKeys(child)]));
}