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
453 lines (452 loc) • 20.4 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/.
/**
* Per-`type` validate checks for the `akm` adapter — akm 0.9.0 chunk-2, WI-C,
* implementing spec §6 (the per-`type` validation column) as a
* behavior-preserving port of `src/commands/lint/*`'s type linters. The `akm`
* adapter's `validate` = shared base checks (WI-A `shared.ts`) + these per-type
* extra checks, keyed on the winning `type`, reproducing today's
* `getLinterForType(subdir).lint(ctx)` dispatch. The FROZEN lint golden
* (`tests/fixtures/goldens/lint/all-types.json`, `perType`) is the conformance
* gate.
*
* ── type → linter mapping (registry.ts `getLinterForType`) ──
*
* command → CommandLinter | agent → AgentLinter (missing-name-or-type +
* invalid `type` value); fact → FactLinter (missing-category); task →
* TaskLinter (invalid-task-yaml); workflow(.md) → WorkflowLinter
* (placeholder-stub READ-ONLY + invalid-workflow-structure); memory →
* MemoryLinter (orphaned-stub READ-ONLY); skill → SkillLinter directory check
* (missing-skill-md, see {@link skillDirectoryDiagnostics}); env/secret → the
* env dangerous-key scan (lint/index.ts:191-218, `.env`-suffix-narrow);
* knowledge/lesson/script/secret/wiki/session → DefaultLinter (base only).
*
* ── READ-ONLY discipline ──
*
* `placeholder-stub` and `orphaned-stub` carry a `--fix` DELETE in the live
* linters; `BundleAdapter.validate` MUST NOT write, so both are emitted as
* non-fixable Diagnostics here (never delete). All reads route through
* `ValidateContext` (the run snapshot + pending overlay) — the live linters'
* `fs.existsSync` sibling/SKILL.md probes become `ctx.readFile` lookups.
*
* ── env/secret dangerous-key narrowness (PRESERVED, spec §6) ──
*
* Today's scan (`collectEnvFiles`) only visits `.env`-SUFFIXED files under
* `env/` and `secrets/`, so `secrets/<bare-name>` is NOT scanned. This port
* keeps that exact narrowness: {@link dangerousEnvKeyDiagnostics} runs only when
* the path's basename ends in `.env`. NOT widened.
*
* ── Cycle-safety (chunk-2 ratchet, baseline 18) ──
*
* Imported ONLY by `akm-adapter.ts` (no inbound `src/` edge) → can never join a
* cycle. It VALUE-imports the one `compileWorkflowSource` frontend and the pure
* predicate `isDangerousEnvKey`
* from `commands/lint/env-key-rules` — the predicate is imported, not copied,
* precisely so the 40+ security-sensitive dangerous-key names cannot drift from
* the canonical set; importing it is ratchet-neutral (verified: 18). The small
* key-scan / suppression-comment logic IS ported (content-based, reads the
* overlay `raw`, not disk). `type` determination stays in `akm-adapter.ts`
* (which owns `recognizeMatch`) so this leaf never imports back into the
* adapter.
*/
import path from "node:path";
import { isDangerousEnvKey } from "../../../commands/lint/env-key-rules.js";
import { parseTaskSource } from "../../../tasks/source/parse-task-source.js";
import { taskSourceErrorDetail } from "../../../tasks/source-v3.js";
import { checkWorkflowPlan, compileWorkflowSource } from "../../../workflows/compile.js";
import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
import { isAkmRegistryCachePath, scanEnvKeyNames } from "../../common.js";
/** Recommended `category` values for facts — `commands/lint/fact-linter.ts:9`. */
const KNOWN_CATEGORIES = new Set(["personal", "team", "project", "convention", "meta"]);
/** Placeholder markers a workflow stub carries — `commands/lint/workflow-linter.ts:10`. */
const PLACEHOLDER_STRINGS = ["Describe what this workflow accomplishes", "Example Workflow"];
/** Inline suppression token — `commands/lint/env-key-rules.ts:138` (not exported there; reproduced verbatim). */
const SUPPRESSION_COMMENT = "# akm-lint-ok: dangerous-env-key";
// ── BaseLinter protected-method ports (base-linter.ts:520-551) ───────────────
/** Port of `BaseLinter.checkMissingNameOrType` (`:520-527`). */
function checkMissingNameOrType(data, frontmatter) {
if (!frontmatter)
return null;
const missingFields = [];
if (!("name" in data) || !data.name)
missingFields.push("name");
if (!("type" in data) || !data.type)
missingFields.push("type");
if (missingFields.length === 0)
return null;
return `missing fields: ${missingFields.join(", ")}`;
}
/** Port of `BaseLinter.checkInvalidTypeValue` (`:534-539`). */
function checkInvalidTypeValue(data, allowedTypes) {
if (!("type" in data) || !data.type)
return null;
const value = String(data.type);
if (allowedTypes.includes(value))
return null;
return `type field has invalid value '${value}'; expected one of: ${allowedTypes.join(", ")}`;
}
/** Port of `BaseLinter.suggestSlug` (`:544-551`). */
function suggestSlug(filePath) {
return path
.basename(filePath, ".md")
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// ── name/type linter (CommandLinter / AgentLinter) ───────────────────────────
/** Reproduce CommandLinter/AgentLinter's extra checks (`command-linter.ts` / `agent-linter.ts`). */
export function nameOrTypeDiagnostics(relPath, data, frontmatter, allowedTypes) {
const missingFieldDetail = checkMissingNameOrType(data, frontmatter);
if (missingFieldDetail) {
const slug = suggestSlug(relPath);
return [
{
file: relPath,
issue: "missing-name-or-type",
detail: `${missingFieldDetail}; suggested slug: ${slug}`,
fixed: false,
},
];
}
const invalidTypeDetail = checkInvalidTypeValue(data, allowedTypes);
if (invalidTypeDetail) {
return [{ file: relPath, issue: "missing-name-or-type", detail: invalidTypeDetail, fixed: false }];
}
return [];
}
// ── env dangerous-key scan (content-based port of env-key-rules.ts) ──────────
/** Port of `env-key-rules.ts#collectSuppressedKeys` (`:144-164`) — content-based. */
function collectSuppressedKeys(raw) {
const suppressed = new Set();
const lines = raw.split(/\r?\n/);
let prevNonEmpty = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === "")
continue;
const keyMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
if (keyMatch && prevNonEmpty.toLowerCase() === SUPPRESSION_COMMENT) {
suppressed.add(keyMatch[1]);
}
prevNonEmpty = trimmed;
}
return suppressed;
}
/**
* env/secret dangerous-key scan (`lint/index.ts:191-218` + `env-key-rules.ts#checkEnvForDangerousKeys`),
* keyed on `type` and preserving the `.env`-suffix narrowness (see file header).
* Reads the overlay `raw`, not disk.
*
* The emitted `Ref:` comes from `conceptIdForStashFile` — the one place that
* spells a diagnostic ref the way `akm show` accepts it. It used to be
* hand-built as `env:<base>` / `secret:<base>`, a colon grammar the 0.9.0 ref
* parser rejects outright — a dead-end ref on a *security* finding.
*/
export function dangerousEnvKeyDiagnostics(type, relPath, raw) {
if (type !== "env" && type !== "secret")
return [];
const baseNameWithExt = path.basename(relPath);
if (!baseNameWithExt.endsWith(".env"))
return []; // NARROWNESS: collectEnvFiles only visits *.env
// `relPath` is already stash-root-relative, so "." IS the stash root here.
const ref = conceptIdForStashFile(type, ".", relPath);
const keys = scanEnvKeyNames(raw);
const suppressed = collectSuppressedKeys(raw);
const diagnostics = [];
for (const key of keys) {
if (!isDangerousEnvKey(key))
continue;
if (suppressed.has(key))
continue;
diagnostics.push({
file: relPath,
issue: "dangerous-env-key",
detail: `Env key \`${key}\` can be used to hijack process execution when injected via \`akm env run\`. Ref: ${ref}. Review this file before running \`akm env run\` commands against untrusted stashes. (suppress with: ${SUPPRESSION_COMMENT} on previous line)`,
fixed: false,
});
}
return diagnostics;
}
// ── skill directory check (SkillLinter.lintDirectory) ────────────────────────
/** The akm-native skill placement dir — the default gate for {@link skillDirectoryDiagnostics}. */
const AKM_SKILL_DIRS = new Set(["skills"]);
/**
* Reproduce `SkillLinter.lintDirectory` (`skill-linter.ts:31-45`) in the
* change-set model: for a change under `<skillDir>/<name>/…`, emit
* `missing-skill-md` when `<skillDir>/<name>/SKILL.md` is absent from the
* overlay. `seen` dedups so a dir with multiple changed files reports once
* (matching the per-subdir call). `file`/`detail` mirror the live check exactly
* (relDir + `no SKILL.md in <relDir>/`).
*
* `skillDirs` defaults to the akm-native `skills/` placement dir. Tool-dir
* adapters pass their own canonical placement directory.
*/
export async function skillDirectoryDiagnostics(relPath, seen, ctx, skillDirs = AKM_SKILL_DIRS) {
const segments = relPath
.replace(/\\/g, "/")
.split("/")
.filter((s) => s.length > 0);
if (segments.length < 3 || !skillDirs.has(segments[0]))
return []; // must be <skillDir>/<name>/<file…>
const skillDir = `${segments[0]}/${segments[1]}`;
if (seen.has(skillDir))
return [];
seen.add(skillDir);
const skillMd = await ctx.readFile(`${skillDir}/SKILL.md`);
if (skillMd !== null)
return [];
return [{ file: skillDir, issue: "missing-skill-md", detail: `no SKILL.md in ${skillDir}/`, fixed: false }];
}
/**
* The winning `type`'s per-type EXTRA validate checks (base checks run
* separately in `akm-adapter.ts`). Async: memory's orphaned-stub sibling probe
* routes through `ctx`.
*/
export async function perTypeValidateChecks(args) {
const { type, relPath, raw, data, frontmatter, body, ext, ctx, workspaceRoot } = args;
switch (type) {
case "command":
return nameOrTypeDiagnostics(relPath, data, frontmatter, ["command"]);
case "agent":
return nameOrTypeDiagnostics(relPath, data, frontmatter, ["agent"]);
case "fact":
return factDiagnostics(relPath, data);
case "task":
return taskDiagnostics(relPath, raw, workspaceRoot);
case "workflow":
// Markdown lint handles `.md`; peer GitHub-shaped `.yml` sources enter through the workflow source adapter.
return ext === ".md" ? workflowDiagnostics(relPath, raw, body) : [];
case "memory":
return memoryDiagnostics(relPath, data, body, ctx);
case "env":
case "secret":
return dangerousEnvKeyDiagnostics(type, relPath, raw);
default:
// knowledge / lesson / script / wiki / session → DefaultLinter (base only).
return [];
}
}
/** FactLinter extra check (`fact-linter.ts:23-44`). */
export function factDiagnostics(relPath, data) {
const category = typeof data.category === "string" ? data.category.trim() : "";
if (!category) {
return [
{
file: relPath,
issue: "missing-category",
detail: "fact is missing a `category` (personal|team|project|convention|meta)",
fixed: false,
},
];
}
if (!KNOWN_CATEGORIES.has(category)) {
return [
{
file: relPath,
issue: "missing-category",
detail: `unrecognized category "${category}" (expected one of: ${[...KNOWN_CATEGORIES].join(", ")})`,
fixed: false,
},
];
}
return [];
}
/**
* Task validation has one semantic owner: the version-routed task source
* parser (spec docs/plans/specs/p2a-task-source-v4.md §3.6) — `version: 3`
* through the strict task-v3 grammar, `version: 4` through task source v4.
* Keeping raw YAML at this boundary preserves duplicate-key, alias/tag,
* source-location, descriptor, resource-bound, and migration-hint behavior.
*/
export function taskDiagnostics(relPath, raw, workspaceRoot) {
try {
parseTaskSource({
filePath: relPath,
yaml: raw,
...(workspaceRoot ? { workspaceRoot } : {}),
});
return [];
}
catch (cause) {
return [
{
file: relPath,
issue: "invalid-task-yaml",
detail: taskSourceErrorDetail(cause),
fixed: false,
},
];
}
}
/**
* The first placeholder marker present in a workflow body, or `null`
* (`workflow-linter.ts:80-85` `#checkPlaceholderStub`). Shared with the live
* `akmLint --fix` path (`commands/lint/index.ts`) so the placeholder-stub RULE
* has ONE home; the caller decides fixability (validate flags, the CLI deletes).
*/
export function matchWorkflowPlaceholder(body) {
return PLACEHOLDER_STRINGS.find((p) => body.includes(p)) ?? null;
}
/**
* WorkflowLinter's `invalid-workflow-structure` check (`workflow-linter.ts:48-77`):
* the ERROR half of {@link workflowFrontendDiagnostics}, for the read-only
* adapter `validate` path. A caller that ALSO surfaces advisories must call
* {@link workflowFrontendDiagnostics} once instead of pairing this with a
* second view. NEVER writes.
*/
export function workflowStructureDiagnostics(relPath, raw, parsePath) {
return workflowFrontendDiagnostics(relPath, raw, parsePath).errors;
}
/**
* The `Diagnostic.line` fragment for a line-anchored workflow finding. Every
* `WorkflowError` carries a 1-indexed `line`; this used to be DROPPED here, so
* an author linting a 300-line workflow got a message with no location while
* the same error rendered as `path:line — message` on the `workflow create`
* path. Spread (`...lineOf(err)`) rather than assigned, so a nonsense line
* never materializes the optional key on a whole-file finding.
*/
function lineOf(err) {
return typeof err.line === "number" && Number.isFinite(err.line) && err.line > 0 ? { line: err.line } : {};
}
/**
* Compile one peer GitHub-shaped `.yml` source through the shared source-IR
* frontend. YAML has no Markdown frontmatter/base/stub pass and currently
* emits fatal source diagnostics only; keeping the same two-channel result
* shape lets the ordinary sweep and both workflow adapters route it exactly
* like the established Markdown frontend without inventing another parser.
*/
export function workflowYamlSourceDiagnostics(relPath, raw, parsePath, workspaceRoot) {
if (isAkmRegistryCachePath(parsePath))
return { errors: [], warnings: [] };
const compiled = compileWorkflowSource(raw, { path: parsePath, workspaceRoot });
if (compiled.ok)
return { errors: [], warnings: [] };
return {
errors: compiled.errors.map((error) => ({
file: relPath,
issue: "invalid-workflow-structure",
detail: error.message,
fixed: false,
...lineOf(error),
})),
warnings: [],
};
}
/**
* ONE parse+compile of a workflow through the unified frontend, returning both
* halves of what it produces: fatal `invalid-workflow-structure` findings, and
* `compileWorkflowPlan`'s non-fatal `workflow-warning` advisories (a step with
* no `output:` schema, a reference to an undeclared param). The read-only
* `/.cache/` + `/registry/` cached copies are skipped, and nothing is written.
*
* `parsePath` is the source identity handed to `compileWorkflowSource` (the
* adapter passes the change relPath; the CLI passes the absolute filePath).
*
* A caller that surfaces BOTH halves must call this once and route the result
* itself. The frontend is expensive — instruction bodies reach
* `WORKFLOW_MAX_INSTRUCTION_BYTES` — so asking for each half through its own
* view parses and compiles every workflow in the stash twice.
*/
export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
const none = { errors: [], warnings: [] };
if (isAkmRegistryCachePath(parsePath))
return none;
const errors = [];
const warnings = [];
try {
const result = compileWorkflowSource(raw, { path: parsePath });
if (!result.ok) {
for (const err of result.errors ?? []) {
errors.push({
file: relPath,
issue: "invalid-workflow-structure",
detail: err.message ?? String(err),
fixed: false,
...lineOf(err),
});
}
return { errors, warnings };
}
const compiled = checkWorkflowPlan(result.plan);
if (!compiled.ok) {
for (const err of compiled.errors) {
errors.push({
file: relPath,
issue: "invalid-workflow-structure",
detail: err.message,
fixed: false,
...lineOf(err),
});
}
return { errors, warnings };
}
for (const warning of compiled.warnings) {
warnings.push({
file: relPath,
issue: "workflow-warning",
detail: warning.message,
fixed: false,
...lineOf(warning),
});
}
}
catch (e) {
errors.push({
file: relPath,
issue: "invalid-workflow-structure",
detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
fixed: false,
});
}
return { errors, warnings };
}
/**
* WorkflowLinter extra checks (`workflow-linter.ts:22-79`), READ-ONLY:
* `placeholder-stub` is NEVER deleted here (validate MUST NOT write) — emitted
* as a non-fixable Diagnostic. `invalid-workflow-structure` parses and compiles
* the whole `raw` through the unified workflow frontend.
*/
function workflowDiagnostics(relPath, raw, body) {
const diagnostics = [];
const placeholderMatch = matchWorkflowPlaceholder(body);
if (placeholderMatch) {
diagnostics.push({
file: relPath,
issue: "placeholder-stub",
detail: `placeholder text: "${placeholderMatch}"`,
fixed: false,
});
}
diagnostics.push(...workflowStructureDiagnostics(relPath, raw, relPath));
return diagnostics;
}
/** The non-fixable orphaned-stub finding detail (`memory-linter.ts` no-fix branch). Shared with the live linter. */
export const ORPHANED_STUB_DETAIL = "inferenceProcessed stub with no derived sibling";
/**
* The CONTENT half of MemoryLinter's orphaned-stub predicate
* (`memory-linter.ts:67-73` `#isOrphanedStub`, minus the sibling probe):
* `inferenceProcessed: true` AND a body under 100 chars. The `.derived.md`
* sibling existence is I/O and is checked by the caller (the CLI via
* `fs.existsSync`, the adapter via `ctx.readFile`), so the RULE stays here while
* each caller supplies its own read.
*/
export function memoryOrphanStubApplies(data, body) {
return data.inferenceProcessed === true && body.trim().length < 100;
}
/**
* MemoryLinter extra check (`memory-linter.ts:19-65`), READ-ONLY: the
* `orphaned-stub` DELETE fix is dropped (validate MUST NOT write) — emitted as
* a non-fixable Diagnostic. The `<name>.derived.md` sibling probe routes
* through `ctx.readFile` (overlay), not `fs.existsSync`.
*/
async function memoryDiagnostics(relPath, data, body, ctx) {
if (!memoryOrphanStubApplies(data, body))
return [];
const derivedPath = `${relPath.replace(/\.md$/, "")}.derived.md`;
const sibling = await ctx.readFile(derivedPath);
if (sibling !== null)
return [];
return [{ file: relPath, issue: "orphaned-stub", detail: ORPHANED_STUB_DETAIL, fixed: false }];
}