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
502 lines (501 loc) • 22.6 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/.
/**
* Built-in asset renderers.
*
* Each renderer implements the show/search/metadata behavior for its asset
* type via the AssetRenderer interface from ./file-context. Renderers are
* registered at module-load time so that importing this module is sufficient
* to make them available.
*/
import fs from "node:fs";
import path from "node:path";
import { listKeys as listVaultKeys } from "../commands/env/env.js";
import { parseFrontmatter } from "../core/asset/frontmatter.js";
import { asNonEmptyString, hasErrnoCode } from "../core/common.js";
import { extractCommentMetadata } from "../indexer/passes/metadata.js";
import { registerRenderer } from "../indexer/walk/file-context.js";
import { buildWorkflowAction, workflowMdRenderer } from "../workflows/renderer.js";
// ── Interpreter auto-detection map ───────────────────────────────────────────
const INTERPRETER_MAP = {
".sh": "bash",
".ts": "bun",
".js": "bun",
".py": "python",
".rb": "ruby",
".go": "go run",
".ps1": "powershell -File",
".cmd": "cmd /c",
".bat": "cmd /c",
".pl": "perl",
".php": "php",
".lua": "lua",
".r": "Rscript",
".swift": "swift",
".kt": "kotlin",
".kts": "kotlin",
};
// ── Setup signal map ─────────────────────────────────────────────────────────
const SETUP_SIGNALS = {
"package.json": "bun install",
"requirements.txt": "pip install -r requirements.txt",
Gemfile: "bundle install",
"go.mod": "go mod download",
};
// ── Comment tag extraction ───────────────────────────────────────────────────
/**
* Extract `@run`, `@setup`, `@cwd` tags from script file header comments.
*
* Scans the first 50 lines of the file for comment lines containing
* `@run <value>`, `@setup <value>`, or `@cwd <value>`.
*/
export function extractCommentTags(filePath) {
const metadata = extractCommentMetadata(filePath);
return {
run: metadata?.run,
setup: metadata?.setup,
cwd: metadata?.cwd,
};
}
// ── Auto-detection ───────────────────────────────────────────────────────────
/**
* Auto-detect execution hints from the file extension and nearby files.
*
* 1. Maps the file extension to an interpreter via INTERPRETER_MAP.
* 2. Scans the file's directory for dependency signal files (package.json,
* requirements.txt, etc.) to suggest a setup command.
*/
export function detectExecHints(filePath) {
const ext = path.extname(filePath).toLowerCase();
const hints = {};
// Interpreter from extension — use basename so the run command is portable
// relative to the stash root (callers set cwd to the file's directory).
const interpreter = INTERPRETER_MAP[ext];
if (interpreter) {
hints.run = `${interpreter} ${path.basename(filePath)}`;
}
// Setup from nearby dependency files
const dir = path.dirname(filePath);
try {
for (const [file, cmd] of Object.entries(SETUP_SIGNALS)) {
if (fs.existsSync(path.join(dir, file))) {
hints.setup = cmd;
hints.cwd = dir;
break;
}
}
}
catch {
// Non-fatal: skip setup detection on FS errors
}
return hints;
}
// ── Resolution ───────────────────────────────────────────────────────────────
/**
* Resolve execution hints for a script asset.
*
* Resolution order (first non-empty value wins for each field):
* 1. Indexed entry metadata (`run`/`setup`/`cwd`) when supplied by the caller
* 2. Script file header comments (`@run`/`@setup`/`@cwd`)
* 3. Auto-detection from extension + dependency files
*/
export function resolveExecHints(stashEntry, filePath) {
const stashHints = {
run: stashEntry?.run,
setup: stashEntry?.setup,
cwd: stashEntry?.cwd,
};
const commentHints = extractCommentTags(filePath);
const autoHints = detectExecHints(filePath);
return {
run: stashHints.run || commentHints.run || autoHints.run,
setup: stashHints.setup || commentHints.setup || autoHints.setup,
cwd: stashHints.cwd || commentHints.cwd || autoHints.cwd,
};
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Derive a display name from the RenderContext.
*
* Prefers `matchResult.meta.name` when present; otherwise falls back to the
* POSIX-style relative path stripped of its extension.
*/
function deriveName(ctx) {
const metaName = ctx.matchResult.meta?.name;
if (typeof metaName === "string" && metaName)
return metaName;
// Strip the extension from the relPath for a reasonable fallback.
const ext = path.extname(ctx.relPath);
return ext ? ctx.relPath.slice(0, -ext.length) : ctx.relPath;
}
export { buildWorkflowAction };
function extractParameters(template) {
const parameters = [];
if (/\$ARGUMENTS\b/i.test(template)) {
parameters.push("ARGUMENTS");
}
for (const match of template.matchAll(/\$([1-9])/g)) {
const parameter = `$${match[1]}`;
if (!parameters.includes(parameter)) {
parameters.push(parameter);
}
}
for (const match of template.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g)) {
const parameter = match[1];
if (!parameters.includes(parameter)) {
parameters.push(parameter);
}
}
return parameters.length > 0 ? parameters : undefined;
}
function readFrontmatterTags(value) {
if (!Array.isArray(value))
return undefined;
const tags = value.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
return tags.length > 0 ? tags : undefined;
}
// ── 1. skill-md ──────────────────────────────────────────────────────────────
const skillMdRenderer = {
name: "skill-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const parsed = parseFrontmatter(ctx.content());
const tags = readFrontmatterTags(parsed.data.tags);
return {
type: "skill",
name,
path: ctx.absPath,
action: "Read and follow the instructions below",
description: asNonEmptyString(parsed.data.description),
...(tags ? { tags } : {}),
content: parsed.content,
};
},
};
// ── 2. command-md ────────────────────────────────────────────────────────────
const commandMdRenderer = {
name: "command-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const parsedMd = parseFrontmatter(ctx.content());
const template = parsedMd.content;
const tags = readFrontmatterTags(parsedMd.data.tags);
const modelHint = typeof parsedMd.data.model === "string" ? parsedMd.data.model : undefined;
const agent = asNonEmptyString(parsedMd.data.agent);
const parameters = extractParameters(template);
return {
type: "command",
name,
path: ctx.absPath,
action: "Fill $ARGUMENTS placeholders in the template, then dispatch",
description: asNonEmptyString(parsedMd.data.description),
...(tags ? { tags } : {}),
template,
...(modelHint !== undefined ? { modelHint } : {}),
...(agent !== undefined ? { agent } : {}),
...(parameters !== undefined ? { parameters } : {}),
};
},
};
// ── 3. agent-md ──────────────────────────────────────────────────────────────
const agentMdRenderer = {
name: "agent-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const parsedMd = parseFrontmatter(ctx.content());
const toolPolicy = parsedMd.data.tools;
const modelHint = typeof parsedMd.data.model === "string" ? parsedMd.data.model : undefined;
return {
type: "agent",
name,
path: ctx.absPath,
action: "Dispatch using the prompt below verbatim. Use modelHint and toolPolicy if present.",
description: asNonEmptyString(parsedMd.data.description),
prompt: parsedMd.content,
// `tools` is self-declared frontmatter. The provenance CEILING that decides
// whether this self-declared policy is honoured is applied at the show
// layer (`akmShowUnified`), which knows whether the source is the operator's
// own writable stash vs a read-only third-party source (07 P1-D).
...(toolPolicy !== undefined ? { toolPolicy } : {}),
...(modelHint !== undefined ? { modelHint } : {}),
};
},
};
// ── 4. knowledge-md ──────────────────────────────────────────────────────────
const KNOWLEDGE_ACTION = "Reference material - read the content below. For a large document, read one section with `akm show <ref>#fragment` " +
"(an unmatched #fragment lists the available slugs).";
const knowledgeMdRenderer = {
name: "knowledge-md",
buildShowResponse(ctx) {
const instruction = ctx.matchResult.type === "instruction";
return {
type: instruction ? "instruction" : "knowledge",
name: deriveName(ctx),
path: ctx.absPath,
action: instruction ? "Project instructions — read and follow the content below." : KNOWLEDGE_ACTION,
content: ctx.content(),
};
},
};
// ── 4c. lesson-md ────────────────────────────────────────────────────────────
/**
* Renderer for the `lesson` asset type (v1 spec §13).
*
* Lessons are markdown files with required `description` and `when_to_use`
* frontmatter. The renderer projects both fields explicitly so consumers can
* decide whether to apply a lesson without reading the full body. Lint
* (see `src/core/lesson-lint.ts`) is the contract enforcer; the renderer is
* intentionally tolerant — a lesson missing required fields will still render
* its body so the user has something to work with while they fix the file.
*/
const lessonMdRenderer = {
name: "lesson-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const parsed = parseFrontmatter(ctx.content());
const description = asNonEmptyString(parsed.data.description);
const whenToUse = asNonEmptyString(parsed.data.when_to_use);
const action = whenToUse
? `Apply this lesson when: ${whenToUse}`
: "Apply this lesson when its `when_to_use` trigger matches the current task.";
return {
type: "lesson",
name,
path: ctx.absPath,
action,
description,
content: parsed.content,
};
},
};
// ── 5. memory-md ─────────────────────────────────────────────────────────────
const memoryMdRenderer = {
name: "memory-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
return {
type: "memory",
name,
path: ctx.absPath,
action: "Recall context — read the content below",
content: ctx.content(),
};
},
};
// ── 6. workflow-md ───────────────────────────────────────────────────────────
// Defined in src/workflows/renderer.ts and imported above.
// ── 7. script-source ─────────────────────────────────────────────────────────
const scriptSourceRenderer = {
name: "script-source",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const ext = path.extname(ctx.absPath).toLowerCase();
// For extensions with a known interpreter, show exec hints
if (INTERPRETER_MAP[ext]) {
const hints = resolveExecHints(undefined, ctx.absPath);
if (hints.run) {
return {
type: "script",
name,
path: ctx.absPath,
action: "Execute the run command below",
run: hints.run,
...(hints.setup !== undefined ? { setup: hints.setup } : {}),
...(hints.cwd !== undefined ? { cwd: hints.cwd } : {}),
};
}
}
// For other extensions or when no hints are available, show file content
return {
type: "script",
name,
path: ctx.absPath,
action: "Review the script source below",
content: ctx.content(),
};
},
enrichSearchHit(hit, _stashDir) {
const ext = path.extname(hit.path).toLowerCase();
if (!INTERPRETER_MAP[ext])
return;
try {
const hints = resolveExecHints(undefined, hit.path);
hit.run = hints.run;
}
catch (error) {
if (!hasErrnoCode(error, "ENOENT"))
throw error;
}
},
};
// ── 8. env-file ───────────────────────────────────────────────────────────────
/**
* Env renderer. Returns ONLY key names — never values, and never comment
* text (comments routinely contain commented-out credentials). Deliberately
* omits content/template/prompt so env values cannot leak through `akm show`.
*/
const envFileRenderer = {
name: "env-file",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const { keys } = listVaultKeys(ctx.absPath);
return {
type: "env",
name,
path: ctx.absPath,
action: "Environment — key names only. Use `akm env run <ref> -- <command>` to run with the whole .env injected; prefer `--clean` to minimize inherited parent env. AKM itself does not print values, but child stdout/stderr is not redacted. `akm env export <ref> --out <file>` writes a sourceable script to a file. Never `source` the raw file. Values stay on disk and are never written to akm's stdout.",
keys,
};
},
enrichSearchHit(hit, _stashDir) {
const { keys } = listVaultKeys(hit.path);
if (keys.length > 0)
hit.keys = keys;
},
};
// ── 9. secret-file ─────────────────────────────────────────────────────────────
/**
* Secret renderer. The ENTIRE file is the secret value, so this surfaces ONLY
* the name + path + a usage hint — never content/template/prompt/keys. There
* is no `enrichSearchHit`: secrets are discoverable by name alone.
*/
const secretFileRenderer = {
name: "secret-file",
buildShowResponse(ctx) {
const name = deriveName(ctx);
return {
type: "secret",
name,
path: ctx.absPath,
action: "Secret — name only; the file contents are the value and are never written to akm's stdout. Use `akm secret run <ref> <VAR> -- <command>` to run with the value injected into $VAR.",
};
},
};
// ── 7. task-md ───────────────────────────────────────────────────────────────
const TASK_PAGE_ACTION = "Scheduled task — `akm show tasks/<id>` for parsed details, `akm task run <id>` to invoke now.";
const taskMdRenderer = {
name: "task-yaml",
buildShowResponse(ctx) {
const name = deriveName(ctx);
return {
type: "task",
name,
path: ctx.absPath,
action: TASK_PAGE_ACTION,
content: ctx.content(),
};
},
};
// ── 8. session-md (#561) ─────────────────────────────────────────────────────
/**
* Renderer for the `session` asset type (#561). A session asset is generated by
* the `extract` pass and carries `harness`, `started_at`/`ended_at`, `project`,
* `log_path`, and `access` frontmatter plus an LLM `## Summary` / `## Key topics`
* body. The renderer surfaces a human-readable one-liner (harness + date +
* project) and concrete `access` instructions rather than dumping raw
* frontmatter, so an agent can decide whether to open the raw log.
*/
const sessionMdRenderer = {
name: "session-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const parsed = parseFrontmatter(ctx.content());
const fm = parsed.data;
const harness = asNonEmptyString(fm.harness);
const project = asNonEmptyString(fm.project);
const startedAt = asNonEmptyString(fm.started_at);
const endedAt = asNonEmptyString(fm.ended_at);
const logPath = asNonEmptyString(fm.log_path);
const access = asNonEmptyString(fm.access);
const description = asNonEmptyString(fm.description);
const dateRange = startedAt ? (endedAt ? `${startedAt} – ${endedAt}` : startedAt) : undefined;
const headerParts = [
harness ? `harness: ${harness}` : undefined,
project ? `project: ${project}` : undefined,
dateRange,
].filter((p) => !!p);
const accessLine = [logPath ? `log: ${logPath}` : undefined, access].filter((p) => !!p).join("\n");
const action = [
"Prior agent session — read the summary below.",
headerParts.length > 0 ? headerParts.join(" ") : undefined,
accessLine ? `Open the raw log:\n${accessLine}` : undefined,
]
.filter((p) => !!p)
.join("\n");
return {
type: "session",
name,
path: ctx.absPath,
action,
description,
content: parsed.content,
};
},
};
// ── 9. fact-md ───────────────────────────────────────────────────────────────
/**
* Renderer for the `fact` asset type. A fact is durable stash-level semantic
* knowledge (personal/team/project details, coding conventions, stash-meta).
* It carries `category` (personal|team|project|convention|meta) and an
* optional `pinned` flag marking it as part of the always-injected core. The
* renderer surfaces a one-liner (category + pinned marker) so an agent can tell
* at a glance what kind of fact it is and whether it is core context.
*/
const factMdRenderer = {
name: "fact-md",
buildShowResponse(ctx) {
const name = deriveName(ctx);
const parsed = parseFrontmatter(ctx.content());
const fm = parsed.data;
const category = asNonEmptyString(fm.category);
const description = asNonEmptyString(fm.description);
const pinned = fm.pinned === true;
const headerParts = [
category ? `category: ${category}` : undefined,
pinned ? "pinned (core context)" : undefined,
].filter((p) => !!p);
const action = [
"Durable stash fact — apply it as background context.",
headerParts.length > 0 ? headerParts.join(" ") : undefined,
]
.filter((p) => !!p)
.join("\n");
return {
type: "fact",
name,
path: ctx.absPath,
action,
description,
content: parsed.content,
};
},
};
// ── Registration ─────────────────────────────────────────────────────────────
/** All built-in renderers. */
const builtinRenderers = [
skillMdRenderer,
commandMdRenderer,
agentMdRenderer,
knowledgeMdRenderer,
lessonMdRenderer,
memoryMdRenderer,
workflowMdRenderer,
scriptSourceRenderer,
envFileRenderer,
secretFileRenderer,
taskMdRenderer,
sessionMdRenderer,
factMdRenderer,
];
/**
* Register all built-in renderers with the file-context registry.
* Called once from the CLI entry point (or ensureBuiltinsRegistered).
*/
export function registerBuiltinRenderers() {
for (const renderer of builtinRenderers) {
registerRenderer(renderer);
}
}
// ── Named exports for testing ────────────────────────────────────────────────
export { agentMdRenderer, commandMdRenderer, envFileRenderer, factMdRenderer, INTERPRETER_MAP, knowledgeMdRenderer, lessonMdRenderer, memoryMdRenderer, SETUP_SIGNALS, scriptSourceRenderer, secretFileRenderer, skillMdRenderer, workflowMdRenderer, };