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

318 lines (317 loc) • 15.5 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/. /** 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; }