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

481 lines (480 loc) • 21.7 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/. // crontab backend for `akm task` (Linux default). // // Each akm-owned entry is wrapped in markers so a hand-edited crontab keeps // its other lines untouched: // // # akm:task <id> BEGIN // [SCHED] AKM_BUNDLE_DIR=<working stash> /abs/akm task run <id> ... > <log> 2>&1 // # akm:task <id> END // // The row sets its own environment inline, as a `VAR=value` prefix (see // `src/tasks/scheduler-invocation.ts`); PATH is the `# akm:env` block. A // command over the portable line limit runs `sh <wrapper script>` instead, // and the script holds the same environment and argv. // // The backend reads/writes the user's crontab via `crontab -l` and // `crontab -`. Every mutation is one read → modify the akm blocks (and the // `# akm:env` PATH header) → one write, with the prior crontab restored when // the write fails. Disabling a task comments the entry with `# akm:disabled ` // rather than removing it, so re-enabling preserves the original schedule. // // Platform notes: // • Operates on the *per-user* crontab — system-wide /etc/cron.d entries // are out of scope. // • Cron runs jobs with a stripped environment (`SHELL`, `PATH`, `HOME`, // `LOGNAME`/`USER` only). The cron line uses an absolute akm path // resolved at install time so it doesn't rely on the inherited PATH. // • BSD `crontab -l` returns exit 1 with "no crontab for <user>" on a // fresh user; a supercronic-managed PATH shim (#910, e.g. OpenPalm's // `/tmp/openpalm-bin/crontab` before any spool exists) does the same // with empty stdout instead. Both mean "empty crontab", not "broken // install" — only a genuinely missing binary (ENOENT from the spawn // itself) gets the "install crontab" remedy. // // Tests inject a fake exec so unit tests don't touch the real crontab. import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { ConfigError } from "../../core/errors.js"; import { getTaskLogDir } from "../../core/paths.js"; import { resolveAkmInvocation } from "../resolve-akm-bin.js"; import { parseSchedule, translateToCron } from "../schedule.js"; import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js"; import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js"; import { nodeFs, throwIfNotOk } from "./exec-utils.js"; const BEGIN = (id) => `# akm:task ${assertCronValue(id)} BEGIN`; const END = (id) => `# akm:task ${assertCronValue(id)} END`; const DISABLED_PREFIX = "# akm:disabled "; const BLOCK_RE = /^# akm:task ([\w.@:_-]+) BEGIN$/; const BLOCK_END_RE = /^# akm:task ([\w.@:_-]+) END$/; const ENV_BEGIN = "# akm:env BEGIN"; const ENV_END = "# akm:env END"; export const PORTABLE_CRON_LINE_LIMIT = 1000; const CRON_WRAPPER_PREFIX = ".akm-cron-wrapper-"; export function CRON_BACKEND(options = {}) { const exec = options.exec ?? defaultCronExec(); const fsLike = options.fs ?? nodeFs(); const logDir = options.logDir ?? getTaskLogDir(); const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv; const envPath = options.envPath === false ? undefined : (options.envPath ?? process.env.PATH); const readFile = options.fs?.readFile ?? ((file) => fs.readFileSync(file, "utf8")); const lineFor = (task, opts) => buildCronLineParts(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment); return { name: "cron", install(task, opts) { const parts = lineFor(task, opts); assertPortableCronLine(parts.line); // The redirect target's parent directory must exist or the surrounding // shell fails the entire entry. fsLike.ensureDir(logDir); if (parts.wrapper) { if (!fsLike.writeFile) { throw new ConfigError("Cron backend needs to write a wrapper script for this task's long invocation, but the configured filesystem cannot write files.", "INVALID_CONFIG_FILE"); } fsLike.writeFile(parts.wrapper.path, parts.wrapper.content); } const existing = readCrontab(exec); const nativeId = schedulerBindingNativeId(task); const block = renderBlock(nativeId, parts.line, task.enabled); replaceCrontab(exec, existing, upsertEnvBlock(upsertBlock(existing, nativeId, block), envPath)); }, uninstall(nativeId) { const existing = readCrontab(exec); if (!listBlocks(existing).some((block) => block.id === nativeId)) return; replaceCrontab(exec, existing, upsertEnvBlock(removeBlock(existing, nativeId), envPath)); }, setEnabled(nativeId, enabled) { const existing = readCrontab(exec); const next = toggleBlock(existing, nativeId, enabled); if (next !== existing) replaceCrontab(exec, existing, next); }, list() { const rows = []; for (const { id, body } of listBlocks(readCrontab(exec))) { const parsed = extractCronInvocation(body) ?? extractCronWrapperInvocation(body, readFile); if (!parsed) continue; rows.push({ id: schedulerLogicalBindingId(id, parsed.invocation), nativeId: id, enabled: !cronIsDisabled(body), signature: normalizeSignature(body), ...(parsed.target !== undefined ? { target: parsed.target } : {}), binding: parsed.binding, ...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}), ...(parsed.environment !== undefined ? { environment: parsed.environment } : {}), invocation: parsed.invocation, }); } return rows; }, expectedSignature(task, opts) { const line = lineFor(task, opts).line; assertPortableCronLine(line); return normalizeSignature(cronBlockBody(line, task.enabled)); }, }; } function buildCronLineParts(task, akmArgv, logDir, environment) { const cronExpr = translateToCron(parseSchedule(task.cron, "cron")); const nativeId = schedulerBindingNativeId(task); const logPath = path.join(logDir, `${nativeId}.log`); const argv = buildScheduledInvocation(akmArgv, task.invocation); const variables = scheduledRowEnvironmentEntries(environment); // An assignment prefix: the shell cron hands the line to sets it for this // command only. Only the value is quoted, or the word is not an assignment. const assignments = variables.map(([name, value]) => `${name}=${quoteForCron(value)} `).join(""); const cmd = argv.map((part) => quoteForCron(part)).join(" "); // #951: truncate (not append) so this bootstrap safety-net file always // holds exactly the latest run's raw output. akm's own per-run log // (src/tasks/run/task-log.ts) keeps history. const directLine = `${cronExpr} ${assignments}${cmd} > ${quoteForCron(logPath)} 2>&1`; if (Buffer.byteLength(directLine, "utf8") <= PORTABLE_CRON_LINE_LIMIT) return { line: directLine }; // A command over vixie-cron's MAX_COMMAND is spilled into a short wrapper // script under logDir instead of being truncated or refused. const exports = variables.map(([name, value]) => `export ${name}=${quoteForShellScript(value)}\n`).join(""); const content = `#!/bin/sh\n${exports}exec ${argv.map(quoteForShellScript).join(" ")}\n`; const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16); const wrapperPath = path.join(logDir, `${CRON_WRAPPER_PREFIX}${nativeId}-${contentHash}.sh`); const line = `${cronExpr} sh ${quoteForCron(wrapperPath)} > ${quoteForCron(logPath)} 2>&1`; return { line, wrapper: { path: wrapperPath, content } }; } function quoteForShellScript(part) { if (/^[A-Za-z0-9_\-./@:=+,]+$/.test(part)) return part; return `'${part.replace(/'/g, `'\\''`)}'`; } export function buildCronLine(task, akmArgv, logDir, environment) { return buildCronLineParts(task, akmArgv, logDir, environment).line; } /** The crontab line as it appears inside a block — commented when disabled. */ export function cronBlockBody(cronLine, enabled) { return enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`; } export function renderBlock(id, cronLine, enabled) { return [BEGIN(id), cronBlockBody(cronLine, enabled), END(id)].join("\n"); } /** The akm-owned blocks of a crontab: each id with the raw body between its BEGIN/END markers. */ export function listBlocks(existing) { return parseBlocks(existing).map(({ id, body }) => ({ id, body })); } function parseBlocks(existing) { const out = []; const lines = existing.split(/\r?\n/); let currentId = null; let start = -1; let body = []; for (const [index, line] of lines.entries()) { const begin = line.match(BLOCK_RE); if (begin) { if (currentId !== null) throw malformedBlockError(currentId); currentId = begin[1]; start = index; body = []; continue; } const end = line.match(BLOCK_END_RE); if (end) { if (currentId === null || end[1] !== currentId) throw malformedBlockError(currentId ?? end[1]); out.push({ id: currentId, body: body.join("\n"), start, end: index }); currentId = null; start = -1; body = []; continue; } if (currentId !== null) body.push(line); } if (currentId !== null) throw malformedBlockError(currentId); return out; } function malformedBlockError(id) { return new ConfigError(`Crontab contains a malformed akm task block for "${id}"; refusing to modify it.`, "INVALID_CONFIG_FILE"); } /** The `--bundle <bundle>` token of an installed cron body; undefined for the primary/default form. */ export function extractInstalledTarget(body) { return extractCronInvocation(body)?.target; } /** * Parse an installed cron body: its inline `VAR=value` environment, its * launcher, and its public tail — a current row, a row naming a * `--scheduler-context` descriptor (0.9.0 – 0.9.17-alpha.6), or an older * row with neither (#881). This only ever runs on a body already isolated * between akm's own BEGIN/END markers, so no trust extends to unmarked lines. */ export function extractCronInvocation(body) { const line = body.startsWith(DISABLED_PREFIX) ? body.slice(DISABLED_PREFIX.length) : body; const fields = splitCronShellWords(line); if (fields.length < 6) return undefined; let commandStart = 5; const variables = {}; for (let field = fields[commandStart]; field !== undefined; field = fields[commandStart]) { const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(field); if (!assignment) break; variables[assignment[1]] = assignment[2]; commandStart += 1; } // #951: new rows redirect with `>`; rows written before that use `>>`. const redirectIndex = fields.findIndex((field, index) => index >= commandStart && (field === ">" || field === ">>")); if (redirectIndex === -1) return undefined; const parsed = parseScheduledInvocationArgv(fields.slice(commandStart, redirectIndex)); if (!parsed) return undefined; const environment = scheduledRowEnvironmentFrom(variables); return environment ? { ...parsed, environment } : parsed; } /** * Parse a row spilled into a wrapper script (`sh <script> > <log> 2>&1`): * the script's `export NAME=value` lines and its `exec` argv. Only akm's own * wrapper scripts are read; one that is gone or does not parse leaves the * row unlisted, as any unparsable row is. */ export function extractCronWrapperInvocation(body, readFile) { const line = body.startsWith(DISABLED_PREFIX) ? body.slice(DISABLED_PREFIX.length) : body; const fields = splitCronShellWords(line); const script = fields[6]; if (fields[5] !== "sh" || !script || !path.basename(script).startsWith(CRON_WRAPPER_PREFIX)) return undefined; if (fields[7] !== ">" && fields[7] !== ">>") return undefined; let content; try { content = readFile(script); } catch { return undefined; } const variables = {}; let parsed; for (const scriptLine of content.split("\n")) { const words = splitCronShellWords(scriptLine); const assignment = words[0] === "export" ? /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(words[1] ?? "") : null; if (assignment) variables[assignment[1]] = assignment[2]; else if (words[0] === "exec") parsed = parseScheduledInvocationArgv(words.slice(1)); } if (!parsed) return undefined; const environment = scheduledRowEnvironmentFrom(variables); return environment ? { ...parsed, environment } : parsed; } /** Reverse {@link quoteForCron}: sh word splitting, where a backslash is literal inside single quotes. */ function splitCronShellWords(value) { const words = []; let current = ""; let quoted = false; for (let index = 0; index < value.length; index += 1) { const char = value[index]; if (char === "'") { quoted = !quoted; continue; } if (!quoted && char === "\\" && index + 1 < value.length) { current += value[index + 1]; index += 1; continue; } if (!quoted && /\s/.test(char)) { if (current) words.push(current); current = ""; continue; } current += char; } if (current) words.push(current); return words; } /** Collapse incidental whitespace so signature comparison ignores it. */ function normalizeSignature(body) { return body .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0) .join("\n"); } function cronIsDisabled(body) { return body.trimStart().startsWith(DISABLED_PREFIX); } export function upsertBlock(existing, id, block) { const trimmed = existing.replace(/\s+$/g, ""); const removed = removeBlock(trimmed, id); const sep = removed.length === 0 ? "" : "\n"; return `${removed}${sep}${block}\n`; } export function removeBlock(existing, id) { const lines = existing.split(/\r?\n/); const blocks = parseBlocks(existing).filter((block) => block.id === id); if (blocks.length === 0) return existing; const out = lines.filter((_, index) => !blocks.some((block) => index >= block.start && index <= block.end)); while (out.length > 0 && out[out.length - 1] === "") out.pop(); return out.join("\n"); } /** * akm's managed environment section: one `PATH=` line directly above the * first akm task block. Cron applies an environment assignment to every * command line after it, which is why the line sits immediately above akm's * own rows rather than at the top of the crontab; it is plain text a person * can edit with `crontab -e`, rewritten on every akm crontab write and * removed together with the last akm task block. */ export function renderEnvBlock(envPath) { return [ENV_BEGIN, `PATH=${assertCronValue(dedupeSearchPath(envPath))}`, ENV_END].join("\n"); } export function upsertEnvBlock(existing, envPath) { const lines = removeEnvBlock(existing).split(/\r?\n/); const firstBlock = lines.findIndex((line) => BLOCK_RE.test(line)); if (!envPath || firstBlock === -1) return lines.join("\n"); return [...lines.slice(0, firstBlock), renderEnvBlock(envPath), ...lines.slice(firstBlock)].join("\n"); } export function removeEnvBlock(existing) { const lines = existing.split(/\r?\n/); const start = lines.indexOf(ENV_BEGIN); if (start === -1) return existing; const end = lines.indexOf(ENV_END, start); if (end === -1) { throw new ConfigError("Crontab contains a malformed akm environment section; refusing to modify it.", "INVALID_CONFIG_FILE"); } return [...lines.slice(0, start), ...lines.slice(end + 1)].join("\n"); } function dedupeSearchPath(value) { const seen = new Set(); const entries = []; for (const entry of value.split(path.delimiter)) { if (entry.length === 0 || seen.has(entry)) continue; seen.add(entry); entries.push(entry); } return entries.join(path.delimiter); } export function toggleBlock(existing, id, enabled) { parseBlocks(existing); const out = []; let inBlock = false; for (const line of existing.split(/\r?\n/)) { if (!inBlock && line === BEGIN(id)) { inBlock = true; out.push(line); continue; } if (inBlock && line === END(id)) { inBlock = false; out.push(line); continue; } if (inBlock) { const isComment = line.startsWith(DISABLED_PREFIX); if (enabled && isComment) out.push(line.slice(DISABLED_PREFIX.length)); else if (!enabled && !isComment) out.push(`${DISABLED_PREFIX}${line}`); else out.push(line); continue; } out.push(line); } return out.join("\n"); } function quoteForCron(part) { assertCronValue(part); // crontab passes the rest of the line to /bin/sh -c, so quote anything that // isn't a plain shell-safe token. Single-quote and escape embedded single // quotes via the standard shell idiom: `'foo'\''bar'`. Cron interprets `%` // before the shell, even inside quotes, so close the quote around its escape. if (/^[A-Za-z0-9_\-./@:%=+,]+$/.test(part)) return part.replaceAll("%", "\\%"); return `'${part.replace(/'/g, `'\\''`).replace(/%/g, `'\\%'`)}'`; } function assertCronValue(value) { for (const char of value) { const code = char.codePointAt(0) ?? 0; if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) { throw new ConfigError("Cron values must not contain control characters.", "INVALID_CONFIG_FILE"); } } return value; } function assertPortableCronLine(line) { const bytes = Buffer.byteLength(line, "utf8"); if (bytes > PORTABLE_CRON_LINE_LIMIT) { throw new ConfigError(`Generated cron definition is ${bytes} bytes; portable cron command lines are limited to ${PORTABLE_CRON_LINE_LIMIT} bytes.`, "INVALID_CONFIG_FILE"); } } function readCrontab(exec) { const result = exec.read(); if (result.status === 0) return result.stdout ?? ""; // The spawn itself failed to find the binary (ENOENT) — the only case // where "install/PATH the crontab binary" is the correct remedy. if (result.enoent) { throw new ConfigError("crontab -l failed: the `crontab` binary was not found on PATH.", "INVALID_CONFIG_FILE", "Install the `crontab` binary (e.g. cron/cronie/vixie-cron) or add one to PATH."); } // #910: a nonzero exit that says nothing at all, or whose stderr says // "no crontab" (BSD's "no crontab for <user>"; a supercronic-managed PATH // shim like OpenPalm's before any spool exists), is cron's own contract // for "empty crontab". A nonzero exit that DID say something else (a // permission refusal) is reported as what it said. const stderr = (result.stderr ?? "").trim(); if (((result.stdout ?? "").trim() === "" && stderr === "") || /no crontab/i.test(stderr)) return ""; throw new ConfigError(`crontab -l failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "The `crontab` binary ran but did not report success; check its output above."); } function writeCrontab(exec, content) { const normalised = content.endsWith("\n") || content.length === 0 ? content : `${content}\n`; throwIfNotOk(exec.write(normalised), { message: (r) => `crontab - failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, hint: "Ensure the `crontab` binary is on PATH and your shell can write the user crontab.", }); } /** Write the next crontab; when that fails, put the prior one back before rethrowing. */ function replaceCrontab(exec, existing, next) { try { writeCrontab(exec, next); } catch (err) { try { writeCrontab(exec, existing); } catch (rollbackError) { const message = err instanceof Error ? err.message : String(err); throw new AggregateError([err, rollbackError], `${message}; restoring the prior crontab also failed.`); } throw err; } } function defaultCronExec() { const run = (args, input) => { const r = spawnSync("crontab", args, { encoding: "utf8", ...(input !== undefined ? { input } : {}) }); return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "", enoent: r.error?.code === "ENOENT", }; }; return { read: () => run(["-l"]), write: (content) => run(["-"], content), }; }