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
289 lines (288 loc) • 12.2 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/.
/**
* How a native scheduler row invokes akm.
*
* A row is `<launcher…> task run <id> --bundle <bundle> --scheduled
* [--<input> <value>…]`, or `<launcher…> workflow run <qualified-ref>`, and it
* carries its own context: the environment the scheduled process needs,
* set inline by the row itself (a cron `VAR=value` prefix, a launchd
* `EnvironmentVariables` entry, a PowerShell `$env:` assignment).
*
* - `AKM_BUNDLE_DIR`: the working stash of the process that ran
* `akm task sync`, on every row. The scheduled run uses the same working
* stash, `--bundle <name>` finds a stash no config names, and sync
* attributes the row to the installation that wrote it (#846).
* - `AKM_CONFIG_DIR`, `AKM_DATA_DIR`, `AKM_CACHE_DIR` and `AKM_STATE_DIR`
* only when the process that ran `akm task sync` set them explicitly. A
* default resolves at fire time exactly as it does for an interactive
* command.
*
* That is exactly what the `--scheduler-context` descriptor held.
*
* `PATH` is the scheduler's own: the crontab's `# akm:env` block, the plist's
* `EnvironmentVariables`.
*
* Rows written by 0.9.0 through 0.9.17-alpha.6 name a
* `--scheduler-context <descriptor>` file instead. The CLI still accepts that
* argument and applies the file's environment, so such a row keeps firing
* until the next `akm task sync` rewrites it.
*/
import fs from "node:fs";
import path from "node:path";
import { bundleRefToString, parseBundleRef } from "../core/asset/asset-ref.js";
import { resolveStashDir } from "../core/common.js";
import { ConfigError } from "../core/errors.js";
import { INPUT_NAME_PATTERN } from "../execution/input-contract.js";
import { normaliseTaskConceptId } from "./task-id.js";
export const SCHEDULED_TASK_CONTEXT_KEYS = [
"AKM_BUNDLE_DIR",
"AKM_CONFIG_DIR",
"AKM_DATA_DIR",
"AKM_CACHE_DIR",
"AKM_STATE_DIR",
];
/**
* Directories a row carries ONLY when the process that ran `task sync` had
* them set explicitly in its environment. They are never resolved from
* defaults: a sync run from inside an app whose environment pointed `$STATE`
* somewhere private (an OpenCode desktop session, 2026-08 → 2026-09) froze
* that directory into eight cron rows, and the scheduled improve runs then
* held their locks in a `locks/` directory no interactive command could see.
*/
const EXPLICIT_CONTEXT_KEYS = ["AKM_CONFIG_DIR", "AKM_DATA_DIR", "AKM_CACHE_DIR", "AKM_STATE_DIR"];
/**
* The AKM directory context of the current process. Child environments that
* are built from an allowlist must forward this closed set explicitly so
* nested AKM commands stay in the same installation.
*/
export function scheduledTaskContextEnv(env = process.env) {
const out = {};
for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
const value = env[key];
if (value)
out[key] = value;
}
return out;
}
/**
* The environment every row a sync writes sets inline: `AKM_BUNDLE_DIR`,
* the syncing process's working stash, plus whichever `AKM_*_DIR` overrides
* it set explicitly. With no working stash to resolve (no default bundle, no
* `AKM_BUNDLE_DIR`), rows name their bundle by `--bundle` alone.
*/
export function scheduledRowEnvironment(env = process.env) {
const out = {};
try {
out.AKM_BUNDLE_DIR = path.resolve(resolveStashDir(env));
}
catch {
// No working stash: there is no primary bundle to attribute rows to either.
}
for (const key of EXPLICIT_CONTEXT_KEYS) {
const value = env[key]?.trim();
if (value)
out[key] = path.resolve(value);
}
return out;
}
/** A row environment's entries, in {@link SCHEDULED_TASK_CONTEXT_KEYS} order. */
export function scheduledRowEnvironmentEntries(environment) {
const entries = [];
for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
const value = environment?.[key];
if (value)
entries.push([key, value]);
}
return entries;
}
/** Keep the {@link SCHEDULED_TASK_CONTEXT_KEYS} a parsed row set; `undefined` when there are none. */
export function scheduledRowEnvironmentFrom(variables) {
const out = {};
for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
const value = variables[key];
if (value)
out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
const SCHEDULER_CONTEXT_ARG = "--scheduler-context";
/** The installed argv: the launcher, then one already-validated public scheduler tail. */
export function buildScheduledInvocation(akmArgv, invocation) {
const parsed = parsePublicSchedulerInvocation(invocation);
if (!parsed)
throw invalidSchedulerInvocation();
return [...akmArgv, ...parsed.invocation];
}
/**
* Parse an installed row's argv (after any inline environment): the current
* shape, the `--scheduler-context <descriptor>` shape 0.9.0 – 0.9.17-alpha.6
* wrote, or the bare shape before that. A row that carries
* `--scheduler-context` but does not parse around it is never reread as
* another shape.
*/
export function parseScheduledInvocationArgv(argv) {
const contextIndex = argv.indexOf(SCHEDULER_CONTEXT_ARG);
if (contextIndex !== -1) {
if (contextIndex < 1 || argv.indexOf(SCHEDULER_CONTEXT_ARG, contextIndex + 1) !== -1)
return undefined;
const contextPath = argv[contextIndex + 1];
if (!contextPath)
return undefined;
const publicInvocation = parsePublicSchedulerInvocation(argv.slice(contextIndex + 2));
if (!publicInvocation)
return undefined;
return { binding: argv.slice(0, contextIndex), contextPath, ...publicInvocation };
}
for (let index = 1; index < argv.length - 1; index += 1) {
if ((argv[index] === "task" || argv[index] === "workflow") && argv[index + 1] === "run") {
const publicInvocation = parsePublicSchedulerInvocation(argv.slice(index));
return publicInvocation ? { binding: argv.slice(0, index), ...publicInvocation } : undefined;
}
}
return undefined;
}
/**
* The environment a `--scheduler-context` descriptor names: its `AKM_*_DIR`
* keys and, in one written before 0.9.17, `PATH`. It is read as plain JSON —
* the file is this user's own, next to the data it names.
*/
export function readLegacySchedulerContext(file) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
}
catch (error) {
throw new ConfigError(`Cannot read scheduler context "${file}": ${error instanceof Error ? error.message : String(error)}. ` +
"Run `akm task sync` to rewrite this scheduled row.", "INVALID_CONFIG_FILE");
}
const environment = parsed !== null && typeof parsed === "object" ? parsed.environment : undefined;
const out = {};
if (environment === null || typeof environment !== "object")
return out;
for (const key of [...SCHEDULED_TASK_CONTEXT_KEYS, "PATH"]) {
const value = environment[key];
if (typeof value === "string" && value.length > 0)
out[key] = value;
}
return out;
}
/**
* Apply and remove a row's `--scheduler-context <descriptor>` before citty
* parses argv. Only a row written by 0.9.0 – 0.9.17-alpha.6 passes it.
*/
export function consumeSchedulerContextArg(argv, env = process.env) {
const separator = argv.indexOf("--");
const index = argv.slice(0, separator === -1 ? argv.length : separator).indexOf(SCHEDULER_CONTEXT_ARG);
if (index === -1)
return argv;
const file = argv[index + 1];
if (!file) {
throw new ConfigError(`${SCHEDULER_CONTEXT_ARG} requires a descriptor path.`, "INVALID_CONFIG_FILE");
}
Object.assign(env, readLegacySchedulerContext(file));
return [...argv.slice(0, index), ...argv.slice(index + 2)];
}
/**
* Parse just the public `task run …` / `workflow run …` tail, the part of a
* row that names what it runs.
*/
function parsePublicSchedulerInvocation(invocation) {
if (invocation[0] === "task" && invocation[1] === "run" && invocation[2]) {
try {
if (normaliseTaskConceptId(invocation[2]) !== invocation[2])
return undefined;
}
catch {
return undefined;
}
let index = 3;
let target;
if (invocation[index] === "--bundle") {
target = invocation[index + 1];
if (!target)
return undefined;
index += 2;
}
if (invocation[index] !== "--scheduled")
return undefined;
// P2b Lane B (spec §4.4, §1.7 B-N3): zero or more `--<name> <value>`
// schedule-supplied input flags may follow `--scheduled` — the same
// trailing tail `compileTaskSchedulerBindings` compiles from
// `schedule[i].inputs`. Absent/empty is the pre-P2b shape, byte-identical
// (B-03).
if (!isValidSchedulerInputFlagTail(invocation.slice(index + 1)))
return undefined;
return { invocation: [...invocation], ...(target !== undefined ? { target } : {}) };
}
if (invocation[0] !== "workflow" || invocation[1] !== "run" || invocation.length !== 3) {
return undefined;
}
const ref = invocation[2];
if (!ref)
return undefined;
try {
const parsed = parseBundleRef(ref);
if (!parsed.bundle || parsed.fragment !== undefined || bundleRefToString(parsed) !== ref)
return undefined;
return { invocation: [...invocation], target: parsed.bundle };
}
catch {
return undefined;
}
}
/**
* Validate an OPTIONAL trailing schedule-input flag tail (spec §1.7 B-N3):
* empty is valid (the pre-P2b shape). Otherwise the tail is a sequence of
* entries, each EITHER:
*
* - a single inline `--<name>=<value>` token (code-review finding,
* scheduler-binding.ts:536 — the ONLY encoding a dash-leading value's
* exact text can round-trip through, since `<value>` here is everything
* after the first `=`, whatever its leading character); or
* - a `(--<name>, <value>)` pair, where `<value>` is a single non-flag
* token (does not start with `-`) — the pre-P2b shape.
*
* In both forms `<name>` matches {@link INPUT_NAME_PATTERN} and no name
* repeats. A bare flag, a repeated name, a flag-shaped value in the pair
* form, or a malformed token are all refused. This validates SHAPE only;
* the real materialization against the task's declared contract happens
* through the same `parseTaskInputFlags` + `materializeInputFlags` path
* `akm task run --<name>` already uses (B-48) — `parseTaskInputFlags`
* accepts both forms natively (its inline-`=` branch never inspects the
* value's leading character).
*/
function isValidSchedulerInputFlagTail(tail) {
if (tail.length === 0)
return true;
const seen = new Set();
let index = 0;
while (index < tail.length) {
const token = tail[index];
if (!token || !token.startsWith("--"))
return false;
const body = token.slice(2);
const equalsAt = body.indexOf("=");
if (equalsAt !== -1) {
const name = body.slice(0, equalsAt);
if (!INPUT_NAME_PATTERN.test(name) || seen.has(name))
return false;
seen.add(name);
index += 1;
continue;
}
if (!INPUT_NAME_PATTERN.test(body) || seen.has(body))
return false;
const value = tail[index + 1];
if (value === undefined || value.startsWith("-"))
return false;
seen.add(body);
index += 2;
}
return true;
}
function invalidSchedulerInvocation() {
return new ConfigError("Invalid scheduler invocation; expected public " +
"`task run <id> [--bundle <bundle>] --scheduled [--<input> <value>…]` or `workflow run <qualified-ref>` argv.", "INVALID_CONFIG_FILE");
}