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
318 lines (317 loc) • 15.5 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/.
/** Setup wizard step for reviewing task definitions and activating schedules. */
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import * as p from "../../cli/clack.js";
import { akmTasksSync } from "../../commands/tasks/tasks.js";
import { makeBundleRef } from "../../core/asset/asset-ref.js";
import { loadConfig, mutateConfig, resetConfigCache } from "../../core/config/config.js";
import { UsageError } from "../../core/errors.js";
import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
import { enabledRefsFromInstalled, isSchedulerBundleActive, schedulerEnabledRefs } from "../../tasks/activation-config.js";
import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
import { listEmbeddedTasks } from "../../tasks/embedded.js";
import { parseSchedule } from "../../tasks/schedule.js";
import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
import { prompt } from "../prompt.js";
/**
* A scheduled server-only nightly full sweep exists among the embedded
* improve-schedule templates (folded in from the retired
* `registerDefaultTasks`/`akm tasks init` path in 0.9, S6). This id is
* preselected in the review multiselect on a detected server install — the
* "battery heuristic" {@link detectServerDefault} preserved from that path.
*/
const SERVER_SUGGESTED_TASK_ID = "akm-improve-nightly";
let scheduledTasksEnvOverrides;
/** TEST-ONLY. Swap the CI/server-detection probes; pass undefined to restore. */
export function _setScheduledTasksEnvForTests(fakes) {
scheduledTasksEnvOverrides = fakes;
}
/**
* Decide whether `akm setup` is running in a CI environment, where it must
* register NO scheduled tasks. Mirrors the common `CI=true` convention used by
* GitHub Actions, GitLab CI, CircleCI, etc.
*/
export function isCiEnvironment(env = process.env) {
if (scheduledTasksEnvOverrides?.isCiEnvironment)
return scheduledTasksEnvOverrides.isCiEnvironment(env);
const ci = env.CI;
if (ci === undefined || ci === null)
return false;
const v = String(ci).trim().toLowerCase();
return v !== "" && v !== "0" && v !== "false";
}
/**
* Platform-appropriate default for "Is this a server install?":
* - Linux without a battery → `true` (server).
* - macOS / any host with a battery (laptop) → `false`.
* Used to preselect {@link SERVER_SUGGESTED_TASK_ID} in the task-review
* multiselect.
*/
export function detectServerDefault() {
if (scheduledTasksEnvOverrides?.detectServerDefault)
return scheduledTasksEnvOverrides.detectServerDefault();
if (os.platform() !== "linux")
return false;
// A laptop exposes a battery under /sys/class/power_supply/BAT*. Absence of
// any battery is our heuristic for "server / desktop".
try {
const entries = fs.readdirSync("/sys/class/power_supply");
const hasBattery = entries.some((e) => /^BAT/i.test(e));
return !hasBattery;
}
catch {
// If we cannot read power-supply info, prefer the safe server default on
// Linux (the nightly sweep is low-impact and re-runnable).
return true;
}
}
function normaliseTaskIdForMatch(raw) {
return raw.trim().replace(/\.(yml|md)$/, "");
}
export function listSetupTaskDefinitions() {
const config = loadConfig();
const target = resolveWriteTarget(config, config.defaultBundle, { requireWritable: false });
const taskDir = path.join(target.source.path, "tasks");
const enabledRefs = new Set(schedulerEnabledRefs(config) ?? []);
if (!fs.existsSync(taskDir))
return [];
const tasks = [];
for (const file of fs.readdirSync(taskDir)) {
if (!file.endsWith(".yml"))
continue;
const id = file.slice(0, -4);
const filePath = path.join(taskDir, file);
try {
const parsed = parseTaskSource({
yaml: fs.readFileSync(filePath, "utf8"),
filePath,
workspaceRoot: target.source.path,
});
const document = parsed.v4;
if (document.schedule.length === 0)
continue;
const schedules = document.schedule.map((entry) => entry.cron);
tasks.push({
id,
schedule: schedules[0],
schedules: Object.freeze(schedules),
enabled: enabledRefs.has(makeBundleRef(target.source.name, `tasks/${id}`)),
...(document.description !== undefined ? { description: document.description } : {}),
});
}
catch (error) {
throw new UsageError(`Cannot review task definition ${filePath}: ${error instanceof Error ? error.message : String(error)} ` +
"Fix or remove the invalid task, then rerun `akm setup`. No task files or scheduler state were changed.", "INVALID_FLAG_VALUE", "Fix or remove the invalid task definition, then rerun `akm setup`.");
}
}
return tasks.sort((a, b) => a.id.localeCompare(b.id));
}
export async function prepareSetupTaskDefinitions(tasks, deps = {}) {
const config = loadConfig();
const target = prepareWriteTargetForMutation(resolveWriteTarget(config, config.defaultBundle, { requireWritable: true }));
const taskDir = path.join(target.source.path, "tasks");
const writeAsset = deps.writeAsset ?? writeAssetToSource;
const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
const prepared = tasks.map((plan) => {
const filePath = path.join(taskDir, `${plan.task.id}.yml`);
const original = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined;
let yaml;
if (original !== undefined) {
yaml = original;
}
else {
const document = yamlParse(plan.task.yaml);
document.schedule = plan.schedule;
yaml = yamlStringify(document);
}
const parsed = parseTaskSource({ yaml, filePath, workspaceRoot: target.source.path });
for (const schedule of parsed.v4.schedule) {
parseSchedule(schedule.cron, backendNameForPlatform());
}
return { filePath, original, yaml, ref: { type: "task", name: plan.task.id } };
});
const changed = prepared.filter((entry) => entry.original !== entry.yaml);
const attempted = [];
try {
for (const entry of changed) {
attempted.push(entry);
await writeAsset(target.source, target.config, entry.ref, entry.yaml);
}
commitBoundary(target, "Prepare scheduled tasks");
}
catch (error) {
const rollbackErrors = [];
for (const entry of [...attempted].reverse()) {
try {
if (entry.original === undefined) {
if (fs.existsSync(entry.filePath))
await deleteAsset(target.source, target.config, entry.ref);
}
else {
await writeAsset(target.source, target.config, entry.ref, entry.original);
fs.writeFileSync(entry.filePath, entry.original, "utf8");
}
}
catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
try {
commitBoundary(target, "Restore scheduled tasks after failed setup");
}
catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
if (rollbackErrors.length > 0) {
throw new AggregateError([error, ...rollbackErrors], `Task definition preparation failed and rollback was incomplete: ${error instanceof Error ? error.message : String(error)}`);
}
throw error;
}
const selected = new Set(tasks.filter((plan) => plan.enabled).map((plan) => makeBundleRef(target.source.name, `tasks/${plan.task.id}`)));
const managed = new Set(tasks.map((plan) => makeBundleRef(target.source.name, `tasks/${plan.task.id}`)));
mutateConfig((current) => {
const existing = schedulerEnabledRefs(current) ?? [];
const next = existing.filter((ref) => !managed.has(ref));
if (selected.size > 0 && !isSchedulerBundleActive(current, target.source.name)) {
throw new UsageError(`Cannot activate setup tasks from disabled bundle ${JSON.stringify(target.source.name)}.`);
}
next.push(...selected);
next.sort((left, right) => left.localeCompare(right));
if (current.scheduler?.enabled !== undefined && JSON.stringify([...existing]) === JSON.stringify(next))
return current;
return { ...current, scheduler: { ...current.scheduler, enabled: next } };
});
return changed.length;
}
const DEFAULT_SCHEDULED_TASKS_DEPS = {
list: listSetupTaskDefinitions,
prepare: prepareSetupTaskDefinitions,
sync: akmTasksSync,
inspectInstalled: async () => ({ installed: await selectBackend().list() }),
};
export async function stepScheduledTasks(deps = DEFAULT_SCHEDULED_TASKS_DEPS, options = {}) {
if (options.nonInteractive || isCiEnvironment()) {
p.log.info("Non-interactive setup leaves task files and scheduler state unchanged. Run `akm setup` interactively to review tasks.");
return;
}
// ALL templates are offered, including ships-disabled ones (e.g. the
// manual-recovery catchup task): an unselected template is still PREPARED,
// so its YAML exists for `akm task run <id>` while its ref remains absent
// from local scheduler activation. Filtering on `task.enabled`
// here would make ships-disabled templates invisible and unpreparable.
const embedded = listEmbeddedTasks();
if (embedded.length === 0)
return;
// A config that predates `scheduler.enabled` means "keep what is installed":
// make that explicit before the review below reads and edits the list.
if (schedulerEnabledRefs(loadConfig()) === undefined) {
try {
const inspection = await deps.inspectInstalled();
const refs = enabledRefsFromInstalled(inspection.installed, loadConfig());
mutateConfig((current) => ({ ...current, scheduler: { ...current.scheduler, enabled: [...refs] } }));
resetConfigCache();
}
catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
p.log.warn(`Native scheduler bindings could not be inspected: ${message}`);
}
}
const installed = await deps.list();
const byId = new Map();
for (const task of installed)
byId.set(normaliseTaskIdForMatch(task.id), task);
const preChecked = embedded.filter((task) => byId.get(task.id)?.enabled === true).map((task) => task.id);
// Battery heuristic preserved from the retired `registerDefaultTasks` path
// (S6): suggest the nightly full sweep on a detected server install, same
// as every other embedded template, still gated behind the confirmation
// below.
if (!byId.has(SERVER_SUGGESTED_TASK_ID) &&
!preChecked.includes(SERVER_SUGGESTED_TASK_ID) &&
embedded.some((task) => task.id === SERVER_SUGGESTED_TASK_ID) &&
detectServerDefault()) {
preChecked.push(SERVER_SUGGESTED_TASK_ID);
}
const selected = await prompt(() => p.multiselect({
message: "Which task definitions should be enabled? (scheduler activation is confirmed separately)",
required: false,
initialValues: preChecked,
options: embedded.map((task) => {
const current = byId.get(task.id);
const schedule = current ? displayTaskSchedules(current) : task.schedule;
const state = current ? (current.enabled ? "enabled" : "disabled") : "not prepared";
return {
value: task.id,
label: task.label,
hint: `${task.description} - ${schedule} [${state}]`,
};
}),
}));
const selectedSet = new Set(selected);
const scheduleFor = new Map();
for (const task of embedded) {
if (!selectedSet.has(task.id) || byId.has(task.id))
continue;
const edited = await prompt(() => p.text({
message: `Schedule for ${task.label}?`,
initialValue: task.schedule,
validate(value) {
const candidate = (value ?? "").trim() || task.schedule;
try {
parseSchedule(candidate, backendNameForPlatform());
}
catch (error) {
return error instanceof Error ? error.message : "Invalid schedule.";
}
return undefined;
},
}));
scheduleFor.set(task.id, (edited ?? "").trim() || task.schedule);
}
const plans = embedded.map((task) => {
const current = byId.get(task.id);
return {
task,
schedule: current?.schedule ?? scheduleFor.get(task.id) ?? task.schedule,
enabled: selectedSet.has(task.id),
installed: current !== undefined,
};
});
const embeddedIds = new Set(embedded.map((task) => task.id));
const custom = installed.filter((task) => !embeddedIds.has(normaliseTaskIdForMatch(task.id)));
p.note([
...plans.map((plan) => `${plan.task.label}: ${plan.enabled ? "enabled" : "disabled"} | ${plan.schedule} | ${plan.task.description}`),
...custom.map((task) => `${task.id}: ${task.enabled ? "enabled" : "disabled"} | ${displayTaskSchedules(task)}${task.description ? ` | ${task.description}` : ""}`),
].join("\n"), "Task Schedule Review");
const activate = await prompt(() => p.confirm({
message: `Activate these schedules now? This will update task files and sync them to the ${backendNameForPlatform()} scheduler.`,
initialValue: false,
}));
if (!activate) {
p.log.info("Task definitions and scheduler state were not changed.");
return;
}
const changed = await deps.prepare(plans);
if (changed > 0)
p.log.success(`Prepared ${changed} task definition${changed === 1 ? "" : "s"}.`);
const syncResult = await deps.sync();
if (syncResult.skipped.length > 0) {
for (const skipped of syncResult.skipped) {
p.log.warn(`Task "${skipped.id}" was not activated: ${skipped.reason}`);
}
const activeCount = syncResult.installed.length + syncResult.updated.length + syncResult.unchanged.length;
p.log.warn(`${activeCount === 0 ? "No task schedules were activated." : "Task schedule activation was incomplete."} ` +
"If you are running AKM from source, run the installed `akm setup`. " +
"To migrate or repair existing scheduler bindings explicitly, run `akm task sync --rebind`.");
return;
}
p.log.success("Task schedules activated. Verify them with `akm task doctor`.");
}
function displayTaskSchedules(task) {
return task.schedules && task.schedules.length > 0 ? task.schedules.join(", ") : task.schedule;
}