UNPKG

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

507 lines (506 loc) • 23.3 kB
// 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 GitHub-shaped YAML workflow grammar: a strict `name`/`on`/`jobs` * document with exactly one job, compiled straight to a {@link WorkflowPlan}. * It is an AKM workflow format executed by AKM's own engine, not GitHub * Actions: no expressions, no service events, `runs-on: [self-hosted]` only. */ import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml"; import { WORKFLOW_PLAN_VERSION, } from "./plan.js"; import { utf8Bytes, WORKFLOW_MAX_SOURCE_BYTES } from "./resource-limits.js"; import { canonicalizeWorkflowCron, canonicalizeWorkflowRun, canonicalizeWorkflowWorkingDirectory, classifyWorkflowStepUses, validateWorkflowBuiltinCommand, WorkflowSourceSemanticError, workflowShellCommand, } from "./source-semantics.js"; /** A located compile failure; `compile.ts` turns it into a `WorkflowSourceError`. */ export class WorkflowSourceFailure extends Error { error; constructor(code, message, source) { super(message); this.name = "WorkflowSourceFailure"; this.error = { code, message, path: source.path, line: source.start }; } } const ROOT_KEYS = ["name", "on", "jobs"]; const TRIGGER_KEYS = ["schedule", "workflow_dispatch"]; const SCHEDULE_KEYS = ["cron"]; const JOB_KEYS = ["name", "needs", "runs-on", "steps"]; const STEP_KEYS = ["id", "name", "uses", "run", "with", "env", "shell", "working-directory"]; const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); const HOST_SHELLS = new Set(["bash", "sh", "zsh", "pwsh", "powershell", "cmd"]); const SOURCE_ID = /^[A-Za-z_][A-Za-z0-9_-]{0,127}$/; const INPUT_KEY = /^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/; const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; const MAX_YAML_DEPTH = 32; const MAX_YAML_NODES = 20_000; const MAX_STEPS_PER_JOB = 256; /** A bounded, non-expanding ownership probe. Validation belongs to compilation. */ export function looksLikeGithubWorkflowSource(source) { if (utf8Bytes(source) > WORKFLOW_MAX_SOURCE_BYTES) return false; let doc; try { doc = parseDocument(source, { strict: true, uniqueKeys: true, version: "1.2" }); } catch { return false; } if (!isMap(doc.contents)) return false; const keys = new Set(); for (const pair of doc.contents.items) { if (isScalar(pair.key) && typeof pair.key.value === "string") keys.add(pair.key.value); } return keys.has("on") && keys.has("jobs"); } export function parseGithubWorkflowSource(source, options) { const documentSource = wholeSourceSpan(source, options.path); if (utf8Bytes(source) > WORKFLOW_MAX_SOURCE_BYTES) { throw new WorkflowSourceFailure("source-size-limit", "Workflow source exceeds the 1 MiB limit.", documentSource); } const lineCounter = new LineCounter(); let doc; try { doc = parseDocument(source, { lineCounter, strict: true, uniqueKeys: true, version: "1.2", }); } catch { throw new WorkflowSourceFailure("invalid-yaml", "Workflow YAML could not be parsed.", documentSource); } if (doc.errors.length > 0) { const problem = doc.errors[0]; if (!problem) throw new WorkflowSourceFailure("invalid-yaml", "Invalid YAML.", documentSource); const offset = Array.isArray(problem.pos) ? problem.pos[0] : 0; const errorSource = spanAtOffset(options.path, lineCounter, offset); const code = /Map keys must be unique/i.test(problem.message) ? "duplicate-key" : "invalid-yaml"; throw new WorkflowSourceFailure(code, cleanYamlMessage(problem.message), errorSource); } const firstWarning = doc.warnings[0]; if (firstWarning && /tag/i.test(firstWarning.message)) { throw new WorkflowSourceFailure("yaml-custom-tag", cleanYamlMessage(firstWarning.message), documentSource); } const rootNode = doc.contents; if (!isMap(rootNode)) { throw new WorkflowSourceFailure("mapping-root-required", "GitHub workflow YAML root must be a mapping.", documentSource); } const parsedRoot = rootNode; const reader = new StrictYamlReader(options.path, lineCounter); reader.rejectAliases(parsedRoot); reader.checkTree(parsedRoot); const root = reader.fields(parsedRoot, ROOT_KEYS, "workflow"); reader.requiredString(root, "name", "workflow"); const schedules = parseTriggers(reader, reader.required(root, "on", "workflow")); const steps = parseJob(reader, reader.required(root, "jobs", "workflow"), options); return { irVersion: WORKFLOW_PLAN_VERSION, title: options.title, schedules, steps }; } class StrictYamlReader { filePath; lineCounter; nodes = 0; constructor(filePath, lineCounter) { this.filePath = filePath; this.lineCounter = lineCounter; } span(node) { const range = node?.range; if (!range) return { path: this.filePath, start: 1, end: 1 }; const start = this.lineCounter.linePos(range[0]).line; const end = this.lineCounter.linePos(Math.max(range[0], range[1] - 1)).line; return { path: this.filePath, start, end: Math.max(start, end) }; } rejectAliases(root) { const pending = [{ node: root, depth: 0 }]; let nodes = 0; while (pending.length > 0) { const current = pending.pop(); if (!current) break; nodes++; if (nodes > MAX_YAML_NODES) this.fail("yaml-node-limit", "YAML exceeds the node limit.", current.node); if (current.depth > MAX_YAML_DEPTH) { this.fail("yaml-depth-limit", "YAML exceeds the depth limit.", current.node); } if (isAlias(current.node)) this.fail("yaml-alias", "YAML aliases are not supported.", current.node); if (isSeq(current.node)) { for (const item of current.node.items) { if (item) pending.push({ node: item, depth: current.depth + 1 }); } } else if (isMap(current.node)) { for (const pair of current.node.items) { if (pair.key) pending.push({ node: pair.key, depth: current.depth + 1 }); if (pair.value) pending.push({ node: pair.value, depth: current.depth + 1 }); } } } } checkTree(node, depth = 0) { this.nodes++; if (this.nodes > MAX_YAML_NODES) this.fail("yaml-node-limit", "YAML exceeds the node limit.", node); if (depth > MAX_YAML_DEPTH) this.fail("yaml-depth-limit", "YAML exceeds the depth limit.", node); if (isAlias(node)) this.fail("yaml-alias", "YAML aliases are not supported.", node); if ("anchor" in node && typeof node.anchor === "string" && node.anchor !== "") { this.fail("yaml-anchor", "YAML anchors are not supported.", node); } if (node.tag !== undefined) this.fail("yaml-custom-tag", "Explicit YAML tags are not supported.", node); if (isScalar(node)) { if (typeof node.value === "string" && node.value.includes("${{")) { this.fail("unsupported-github-expression", "GitHub expressions and contexts are not supported.", node); } return; } if (isSeq(node)) { for (const item of node.items) if (item) this.checkTree(item, depth + 1); return; } if (isMap(node)) { for (const pair of node.items) { if (pair.key) this.checkTree(pair.key, depth + 1); if (pair.value) this.checkTree(pair.value, depth + 1); } } } fields(node, allowed, context) { if (!isMap(node)) this.fail("mapping-required", `${context} must be a mapping.`, node); const accepted = new Set(allowed); const out = new Map(); for (const pair of node.items) { if (!isScalar(pair.key) || typeof pair.key.value !== "string" || pair.key.value.trim() === "") { this.fail("string-key-required", `${context} keys must be non-empty strings.`, pair.key); } const key = pair.key.value; if (UNSAFE_KEYS.has(key)) this.fail("unsafe-key", `${context} contains unsafe key ${JSON.stringify(key)}.`, pair.key); if (!accepted.has(key)) this.fail("unknown-key", `${context} contains unknown key ${JSON.stringify(key)}.`, pair.key); out.set(key, pair); } return out; } arbitraryFields(node, context) { if (!isMap(node)) this.fail("mapping-required", `${context} must be a mapping.`, node); const out = new Map(); for (const pair of node.items) { if (!isScalar(pair.key) || typeof pair.key.value !== "string" || pair.key.value.trim() === "") { this.fail("string-key-required", `${context} keys must be non-empty strings.`, pair.key); } const key = pair.key.value; if (UNSAFE_KEYS.has(key)) this.fail("unsafe-key", `${context} contains unsafe key ${JSON.stringify(key)}.`, pair.key); out.set(key, pair); } return out; } plain(node, context) { if (node === null) return null; if (isScalar(node)) return node.value; if (isSeq(node)) { return node.items.map((item) => { if (!item) this.fail("sparse-yaml-sequence", `${context} may not contain empty entries.`, node); return this.plain(item, context); }); } if (isMap(node)) { const out = {}; for (const [key, pair] of this.arbitraryFields(node, context)) { out[key] = this.plain(pair.value, `${context}.${key}`); } return out; } this.fail("invalid-yaml-node", `${context} contains an unsupported YAML node.`, node); } required(fields, key, context) { const pair = fields.get(key); if (!pair) this.fail("missing-key", `${context} is missing required key ${JSON.stringify(key)}.`, undefined); return pair.value; } requiredString(fields, key, context) { return this.string(this.required(fields, key, context), `${context}.${key}`); } optionalString(fields, key, context) { const pair = fields.get(key); return pair ? this.string(pair.value, `${context}.${key}`) : undefined; } string(node, context) { if (!isScalar(node) || typeof node.value !== "string" || node.value.trim() === "") { this.fail("string-required", `${context} must be a non-empty string.`, node); } return node.value; } scalar(node, context, allowNull) { if (node === null || (isScalar(node) && node.value === null)) { if (allowNull) return null; this.fail("scalar-required", `${context} may not be null.`, node); } if (!isScalar(node)) this.fail("scalar-required", `${context} must be a scalar.`, node); const value = node.value; if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { this.fail("scalar-required", `${context} must be a string, number, or boolean.`, node); } if (typeof value === "number" && !Number.isFinite(value)) { this.fail("scalar-required", `${context} must be a finite number.`, node); } return value; } sequence(node, context, max, allowEmpty = false) { if (!isSeq(node) || (!allowEmpty && node.items.length === 0)) { this.fail("sequence-required", `${context} must be ${allowEmpty ? "an" : "a non-empty"} list.`, node); } if (node.items.length > max) this.fail("step-count-limit", `${context} exceeds ${max} entries.`, node); return node.items.map((item) => { if (!item) this.fail("sparse-yaml-sequence", `${context} may not contain empty entries.`, node); return item; }); } fail(code, message, node) { throw new WorkflowSourceFailure(code, message, this.span(node)); } } function parseTriggers(reader, node) { const fields = reader.arbitraryFields(node, "workflow.on"); for (const [key, pair] of fields) { if (!TRIGGER_KEYS.includes(key)) { reader.fail("unsupported-service-event", `GitHub service event ${JSON.stringify(key)} is unsupported for local execution.`, pair.key); } } if (fields.size === 0) reader.fail("trigger-required", "workflow.on must declare schedule or workflow_dispatch.", node); const schedules = []; const schedule = fields.get("schedule"); if (schedule) { const records = reader.sequence(schedule.value, "workflow.on.schedule", 64); for (const [ordinal, record] of records.entries()) { const scheduleFields = reader.fields(record, SCHEDULE_KEYS, `workflow.on.schedule[${ordinal}]`); const cronNode = reader.required(scheduleFields, "cron", `workflow.on.schedule[${ordinal}]`); const cron = validateCron(reader, reader.string(cronNode, `workflow.on.schedule[${ordinal}].cron`), cronNode); schedules.push({ cron, ordinal, line: reader.span(cronNode).start }); } } const manual = fields.get("workflow_dispatch"); if (manual) { const nullValue = manual.value === null || (isScalar(manual.value) && manual.value.value === null); if (!nullValue) { if (!isMap(manual.value)) { reader.fail("workflow-dispatch-inputs-unsupported", "workflow_dispatch must be null or an empty mapping; inputs are unsupported.", manual.value); } if (manual.value.items.length > 0) { reader.fail("workflow-dispatch-inputs-unsupported", "workflow_dispatch inputs are not supported for local execution.", manual.value.items[0]?.key); } } } return schedules; } function validateCron(reader, cron, node) { try { return canonicalizeWorkflowCron(cron); } catch (cause) { semanticReaderFail(reader, cause, node); } } /** * Exactly one job: AKM's YAML is an AKM workflow format executed by AKM's * native engine, not a GitHub Actions job graph. */ function parseJob(reader, node, options) { const jobs = reader.arbitraryFields(node, "workflow.jobs"); const [first, second] = [...jobs]; if (jobs.size !== 1 || !first) { reader.fail("multi-job-unsupported", `AKM workflow YAML requires exactly one job; this document declares ${jobs.size}. AKM's YAML is an AKM workflow format executed by AKM's native engine, not GitHub Actions — split the jobs into separate workflows.`, second ? second[1].key : node); } const [id, pair] = first; const jobNode = pair.value; if (!SOURCE_ID.test(id)) reader.fail("invalid-job-id", `Invalid job id ${JSON.stringify(id)}.`, jobNode); const fields = reader.fields(jobNode, JOB_KEYS, `workflow.jobs.${id}`); const runner = reader.required(fields, "runs-on", `workflow.jobs.${id}`); if (!isSeq(runner) || runner.items.length !== 1 || !isScalar(runner.items[0]) || runner.items[0].value !== "self-hosted") { reader.fail("unsupported-runner", `Job ${JSON.stringify(id)} must declare exactly runs-on: [self-hosted].`, runner); } const needs = fields.get("needs"); if (needs) { const values = isSeq(needs.value) ? reader .sequence(needs.value, `workflow.jobs.${id}.needs`, 256) .map((item) => reader.string(item, `workflow.jobs.${id}.needs`)) : [reader.string(needs.value, `workflow.jobs.${id}.needs`)]; for (const need of values) if (!SOURCE_ID.test(need)) reader.fail("invalid-job-id", `Invalid needs id ${need}.`, needs.value); if (values.length > 0) { reader.fail("multi-job-unsupported", `Job ${id} declares needs, but an AKM workflow has exactly one job; remove needs.`, pair.key); } } reader.optionalString(fields, "name", `workflow.jobs.${id}`); const stepNodes = reader.sequence(reader.required(fields, "steps", `workflow.jobs.${id}`), `workflow.jobs.${id}.steps`, MAX_STEPS_PER_JOB); const stepIds = new Set(); return stepNodes.map((step, index) => parseStep(reader, step, id, index, stepIds, options)); } function parseStep(reader, node, jobId, index, stepIds, options) { const context = `workflow.jobs.${jobId}.steps[${index}]`; const fields = reader.fields(node, STEP_KEYS, context); const id = reader.requiredString(fields, "id", context); if (!SOURCE_ID.test(id)) reader.fail("invalid-step-id", `Invalid step id ${JSON.stringify(id)}.`, fields.get("id")?.value); if (stepIds.has(id)) reader.fail("duplicate-step-id", `Job ${jobId} has duplicate step id ${id}.`, fields.get("id")?.value); stepIds.add(id); const usesPair = fields.get("uses"); const runPair = fields.get("run"); if ((usesPair === undefined) === (runPair === undefined)) { reader.fail("step-target-xor", `${context} must declare exactly one of uses or run.`, node); } reader.optionalString(fields, "name", context); const env = parseScalarMap(reader, fields.get("env"), `${context}.env`, ENV_KEY, false); const target = usesPair ? parseUsesStep(reader, usesPair, fields) : parseRunStep(reader, runPair, fields, options); const spec = { ...target, ...(env ? { env } : {}), source: reader.span(node) }; return { stepId: id, title: id, sequenceIndex: index, spec, gate: { kind: "gate", id: `${id}.gate`, stepId: id, criteria: [], maxLoops: 1, frozenJudge: null }, }; } function parseUsesStep(reader, usesPair, fields) { if (fields.has("shell") || fields.has("working-directory")) { reader.fail("uses-field-conflict", "shell and working-directory are legal only with run.", usesPair.value); } const uses = reader.string(usesPair.value, "step.uses"); let target; try { target = classifyWorkflowStepUses(uses); } catch (cause) { semanticReaderFail(reader, cause, usesPair.value); } // A tasks/<ref> or workflows/<ref> step's with: may bind any JSON value the // composed target's declared input/param needs (an object/array literal, or // a {from: "..."} reference); every other target keeps the scalar grammar. const withValues = target.kind === "task" || target.kind === "workflow" ? parsePlainMap(reader, fields.get("with"), "step.with", INPUT_KEY) : parseScalarMap(reader, fields.get("with"), "step.with", INPUT_KEY, true); const commandMode = target.kind === "builtin-command" ? validateBuiltinCommand(reader, withValues, fields.get("with")?.value ?? usesPair.value) : undefined; return { uses, ...(commandMode ? { commandMode } : {}), ...(withValues ? { with: withValues } : {}), }; } function validateBuiltinCommand(reader, values, node) { try { const action = validateWorkflowBuiltinCommand(values); if (action.kind === "stored") return "stored-ref"; return action.arguments !== undefined || action.content.includes("$ARGUMENTS") ? "portable-template" : "literal"; } catch (cause) { semanticReaderFail(reader, cause, node); } } /** A `run:` step lowers to its shell's argv now; `instructions` keeps the authored command for display. */ function parseRunStep(reader, runPair, fields, options) { if (fields.has("with")) reader.fail("run-field-conflict", "with is legal only with uses.", fields.get("with")?.value); let run; try { run = canonicalizeWorkflowRun(reader.string(runPair.value, "step.run")); } catch (cause) { semanticReaderFail(reader, cause, runPair.value); } const shell = fields.has("shell") ? reader.requiredString(fields, "shell", "step") : undefined; if (shell !== undefined && !HOST_SHELLS.has(shell)) reader.fail("unsupported-shell", `Unsupported shell ${shell}.`, fields.get("shell")?.value); let workingDirectory = fields.has("working-directory") ? reader.requiredString(fields, "working-directory", "step") : undefined; if (workingDirectory !== undefined) { try { workingDirectory = canonicalizeWorkflowWorkingDirectory(workingDirectory, options.workspaceRoot); } catch (cause) { semanticReaderFail(reader, cause, fields.get("working-directory")?.value); } } return { exec: { command: workflowShellCommand(shell ?? "sh", run), ...(workingDirectory ? { cwd: workingDirectory } : {}) }, instructions: `Run ${run}.`, }; } function parseScalarMap(reader, pair, context, keyPattern, allowNull) { if (!pair) return undefined; const fields = reader.arbitraryFields(pair.value, context); const out = {}; for (const [key, valuePair] of fields) { if (!keyPattern.test(key)) reader.fail("invalid-mapping-key", `${context} has invalid key ${JSON.stringify(key)}.`, valuePair.key); out[key] = reader.scalar(valuePair.value, `${context}.${key}`, allowNull); } return out; } /** Like {@link parseScalarMap} but any JSON value per key (bounded document-wide by `checkTree`). */ function parsePlainMap(reader, pair, context, keyPattern) { if (!pair) return undefined; const fields = reader.arbitraryFields(pair.value, context); const out = {}; for (const [key, valuePair] of fields) { if (!keyPattern.test(key)) reader.fail("invalid-mapping-key", `${context} has invalid key ${JSON.stringify(key)}.`, valuePair.key); out[key] = reader.plain(valuePair.value, `${context}.${key}`); } return out; } function wholeSourceSpan(source, filePath) { return { path: filePath, start: 1, end: Math.max(1, source.split(/\r?\n/).length) }; } function spanAtOffset(filePath, counter, offset) { const line = counter.linePos(Math.max(0, offset)).line; return { path: filePath, start: line, end: line }; } function cleanYamlMessage(message) { return message.replace(/\s+at line \d+, column \d+:[\s\S]*$/i, "").trim(); } function semanticReaderFail(reader, cause, node) { if (cause instanceof WorkflowSourceSemanticError) reader.fail(cause.code, cause.message, node); reader.fail("invalid-workflow-source", cause instanceof Error ? cause.message : String(cause), node); }