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

458 lines (457 loc) • 22.9 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/. /** * Cross-platform schedule parsing and translation. * * Users always type cron-style expressions: * * • `m h dom mon dow` — five-field cron (UNIX minute/hour/dom/mon/dow) * • `@hourly` `@daily` `@weekly` `@monthly` * * Each {@link ScheduleSpec} can then be translated to: * * • a verbatim cron line (Linux backend), * • a launchd plist `<StartCalendarInterval>` / `<StartInterval>` (macOS), * • Task Scheduler XML triggers (Windows). * * The shared subset is `*`, single integers, `*\/N`, `A-B`, `A-B/N`, comma * lists, three-letter day/month names (`MON`, `JAN`) and name ranges * (`MON-FRI`), plus the `@hourly / @daily / @weekly / @monthly / @yearly / * @annually / @reboot` aliases. Day-of-month AND day-of-week combined in the * same expression are rejected with a {@link UsageError} — cron gives that * combination OR semantics no other backend can express portably, and it is * genuinely ambiguous to a person reading the schedule back. * * Cron is the most permissive of the three backends; some patterns it * accepts (e.g. `@hourly` = `0 * * * *`) have no clean schtasks primitive. * Validation runs against the *active* backend, so a task authored on * Linux may fail to translate when copied to macOS/Windows. `tasks sync` * re-validates against the local backend and surfaces any incompatibility. */ import { UsageError } from "../core/errors.js"; const ALIAS_TO_CRON = { "@hourly": "0 * * * *", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@weekly": "0 0 * * 0", "@monthly": "0 0 1 * *", "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", }; /** * vixie-cron's one true nickname with no 5-field equivalent — it fires once at * daemon startup, not on a recurring calendar boundary. Passed straight * through, unexpanded, on the cron backend (a real crontab line is just * `@reboot <command>`); launchd and schtasks have no "run at boot" trigger in * the vocabulary this module targets, so those backends reject it. */ const REBOOT_ALIAS = "@reboot"; /** Placeholder fields for the `@reboot` `ScheduleSpec` — never read: cron's translator emits `spec.cron` verbatim, and launchd/schtasks refuse `@reboot` before touching `fields`. */ const REBOOT_FIELDS = { minute: { kind: "star" }, hour: { kind: "star" }, dom: { kind: "star" }, month: { kind: "star" }, dow: { kind: "star" }, }; const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; /** * Substitute three-letter day-of-week / month names (and name ranges, e.g. * `MON-FRI`) with their numeric cron equivalents, case-insensitively, so the * existing numeric grammar below (value / range / list) parses the result * unchanged. Tokens that are not recognized names pass through untouched — * they are numbers already, or genuinely invalid and left for the numeric * parsers to reject with their own message. */ function substituteNamedTokens(raw, fieldName) { const names = fieldName === "month" ? MONTH_NAMES : DOW_NAMES; const offset = fieldName === "month" ? 1 : 0; const indexOf = (token) => { const idx = names.indexOf(token.toLowerCase()); return idx === -1 ? undefined : idx + offset; }; return raw .split(",") .map((part) => { const rangeMatch = part.match(/^([A-Za-z]{3})-([A-Za-z]{3})$/); if (rangeMatch) { const start = indexOf(rangeMatch[1]); const end = indexOf(rangeMatch[2]); if (start !== undefined && end !== undefined) return `${start}-${end}`; return part; } const single = /^[A-Za-z]{3}$/.test(part) ? indexOf(part) : undefined; return single !== undefined ? String(single) : part; }) .join(","); } const FIELD_LIMITS = { minute: { min: 0, max: 59 }, hour: { min: 0, max: 23 }, dom: { min: 1, max: 31 }, month: { min: 1, max: 12 }, dow: { min: 0, max: 6 }, }; const SUPPORTED_HINT = "Supported subset: `*`, single integers (`5`), ranges (`A-B`), steps (`*/N`, `A-B/N`), and comma lists " + "(`7,37`). Day-of-week and month fields also accept three-letter names and name ranges (`MON`, `MON-FRI`, " + "`JAN`). Aliases: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`/`@annually`, `@reboot` (cron only)."; export function parseSchedule(input, backend) { const trimmed = input.trim(); if (trimmed.toLowerCase() === REBOOT_ALIAS) { if (backend !== "cron") { throw new UsageError(`Schedule "${input}" (@reboot, run once at startup) has no ${backend === "launchd" ? "macOS launchd" : "Windows Task Scheduler"} equivalent this release expresses. Use a cron-backed install, or rewrite the task with a recurring schedule.`, "INVALID_FLAG_VALUE"); } return { raw: input, cron: REBOOT_ALIAS, fields: REBOOT_FIELDS }; } const cron = expandAlias(input); const fields = parseCronFields(cron, input); const spec = { raw: input, cron, fields }; // Validate translatability for the active backend so the caller does not // silently accept expressions that backend cannot run. Note: cron is the // most permissive of the three (e.g. `@hourly` is `0 * * * *` which has // no clean schtasks primitive), so a task authored on Linux may not be // portable to macOS/Windows. `tasks sync` on the destination platform // re-runs this with the local backend and will surface any incompatibility. if (backend === "launchd") translateToLaunchd(spec); if (backend === "schtasks") translateToSchtasks(spec); return spec; } function expandAlias(raw) { const trimmed = raw.trim(); if (!trimmed) { throw new UsageError("Schedule is empty.", "MISSING_REQUIRED_ARGUMENT"); } const lower = trimmed.toLowerCase(); if (lower in ALIAS_TO_CRON) return ALIAS_TO_CRON[lower]; return trimmed; } function parseCronFields(cron, original) { const parts = cron.split(/\s+/); if (parts.length !== 5) { throw new UsageError(`Invalid schedule "${original}": expected 5 fields, got ${parts.length}. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE"); } const [m, h, dom, mon, dow] = parts; return { minute: parseField(m, "minute", FIELD_LIMITS.minute, original), hour: parseField(h, "hour", FIELD_LIMITS.hour, original), dom: parseField(dom, "day-of-month", FIELD_LIMITS.dom, original), month: parseField(mon, "month", FIELD_LIMITS.month, original), dow: parseField(dow, "day-of-week", FIELD_LIMITS.dow, original), }; } function parseField(rawInput, name, limit, original) { const raw = name === "month" || name === "day-of-week" ? substituteNamedTokens(rawInput, name) : rawInput; if (raw === "*") return { kind: "star" }; const stepMatch = raw.match(/^\*\/(\d+)$/); if (stepMatch) { const step = Number(stepMatch[1]); // Step must be ≥1 and ≤ the field's range size, not just `max`. For // 1-based fields like day-of-month (1-31) and month (1-12) the previous // `max + 1` bound let invalid steps like `*/32` or `*/13` slip through. const range = limit.max - limit.min + 1; if (!Number.isInteger(step) || step <= 0 || step > range) { throw new UsageError(`Invalid ${name} step "${raw}" in schedule "${original}". ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE"); } return { kind: "step", step }; } const rangeStepMatch = raw.match(/^(\d+)-(\d+)\/(\d+)$/); if (rangeStepMatch) { const start = Number(rangeStepMatch[1]); const end = Number(rangeStepMatch[2]); const step = Number(rangeStepMatch[3]); if (start < limit.min || end > limit.max || start > end || step <= 0) { throw new UsageError(`Invalid ${name} range-step "${raw}" in schedule "${original}" (allowed ${limit.min}-${limit.max}).`, "INVALID_FLAG_VALUE"); } return { kind: "rangeStep", start, end, step }; } // Plain range, no step: `A-B` (e.g. `1-5` for Mon-Fri, `1-5` for the first // five days of the month). Represented as a `rangeStep` with `step: 1` so // every downstream consumer (verbatim cron passthrough, launchd/schtasks // expansion) reuses the exact same handling a stepped range already gets. const rangeMatch = raw.match(/^(\d+)-(\d+)$/); if (rangeMatch) { const start = Number(rangeMatch[1]); const end = Number(rangeMatch[2]); if (start < limit.min || end > limit.max || start > end) { throw new UsageError(`Invalid ${name} range "${raw}" in schedule "${original}" (allowed ${limit.min}-${limit.max}).`, "INVALID_FLAG_VALUE"); } return { kind: "rangeStep", start, end, step: 1 }; } if (/^\d+$/.test(raw)) { const value = Number(raw); if (value < limit.min || value > limit.max) { throw new UsageError(`Invalid ${name} value "${raw}" in schedule "${original}" (allowed ${limit.min}-${limit.max}).`, "INVALID_FLAG_VALUE"); } return { kind: "value", value }; } // Comma-separated list: `7,37` or `0,15,30,45`. Each element must be an // integer within [limit.min, limit.max]. Duplicates and unsorted input // are accepted but the parsed form is deduped + ascending so downstream // consumers can rely on a canonical shape. if (/^\d+(,\d+)+$/.test(raw)) { const values = [...new Set(raw.split(",").map((s) => Number(s)))].sort((a, b) => a - b); for (const v of values) { if (!Number.isInteger(v) || v < limit.min || v > limit.max) { throw new UsageError(`Invalid ${name} list value "${v}" in schedule "${original}" (allowed ${limit.min}-${limit.max}).`, "INVALID_FLAG_VALUE"); } } return { kind: "list", values }; } throw new UsageError(`Unsupported ${name} expression "${raw}" in schedule "${original}". ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE"); } // ── Backend translators ───────────────────────────────────────────────────── /** Verbatim cron line, alias-expanded. */ export function translateToCron(spec) { return spec.cron; } export function translateToLaunchd(spec) { const f = spec.fields; rejectDomDowCombination(spec, "macOS launchd"); // Expand minute steps into clock-minute anchors. StartInterval would drift // from cron whenever the agent is loaded away from a matching boundary. if ((f.minute.kind === "step" || f.minute.kind === "rangeStep") && f.hour.kind === "star" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { return { calendars: expandFieldValues(f.minute, FIELD_LIMITS.minute).map((Minute) => ({ Minute })) }; } // Likewise, anchor hour steps to the same hours cron selects each day. if (f.minute.kind === "value" && (f.hour.kind === "step" || f.hour.kind === "rangeStep") && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { return { calendars: expandFieldValues(f.hour, FIELD_LIMITS.hour).map((Hour) => ({ Minute: f.minute.kind === "value" ? f.minute.value : 0, Hour, })), }; } // A day-of-week or month range/list (`1-5` for Mon-Fri, `1,3,5`) has no // single-dict launchd primitive either, but — unlike a step — it names a // small, closed set of concrete values, so it genuinely IS expressible: one // calendar dict per (month, weekday) combination, same trick as the // minute/hour step expansion above. Only attempted when the remaining // fields are already single values or `*`; anything else (e.g. a minute // step combined with a weekday range) falls through to the generic path // below, where `rejectStepInsideCalendar` reports the field it actually // cannot express. const dowValues = expandListLikeField(f.dow, FIELD_LIMITS.dow); const monthValues = expandListLikeField(f.month, FIELD_LIMITS.month); const remainingFieldsAreSimple = (f.minute.kind === "value" || f.minute.kind === "star") && (f.hour.kind === "value" || f.hour.kind === "star") && (f.dom.kind === "value" || f.dom.kind === "star"); if ((dowValues || monthValues) && remainingFieldsAreSimple) { const base = {}; if (f.minute.kind === "value") base.Minute = f.minute.value; if (f.hour.kind === "value") base.Hour = f.hour.value; if (f.dom.kind === "value") base.Day = f.dom.value; const months = monthValues ?? (f.month.kind === "value" ? [f.month.value] : [undefined]); const weekdays = dowValues ?? (f.dow.kind === "value" ? [f.dow.value] : [undefined]); const calendars = []; for (const month of months) { for (const weekday of weekdays) { calendars.push({ ...base, ...(month !== undefined ? { Month: month } : {}), ...(weekday !== undefined ? { Weekday: weekday } : {}), }); } } return { calendars }; } // Otherwise build a calendar dict from concrete values. launchd treats any // omitted key as "every value", so a `*` field translates to "no key". // Exception: launchd does not support arbitrary step values inside a // calendar dict — reject those. const calendar = {}; rejectStepInsideCalendar(f.minute, "minute", spec); rejectStepInsideCalendar(f.hour, "hour", spec); rejectStepInsideCalendar(f.dom, "day-of-month", spec); rejectStepInsideCalendar(f.month, "month", spec); rejectStepInsideCalendar(f.dow, "day-of-week", spec); if (f.minute.kind === "value") calendar.Minute = f.minute.value; if (f.hour.kind === "value") calendar.Hour = f.hour.value; if (f.dom.kind === "value") calendar.Day = f.dom.value; if (f.month.kind === "value") calendar.Month = f.month.value; if (f.dow.kind === "value") calendar.Weekday = f.dow.value; // launchd's CalendarInterval requires at least one specific key. If every // Expand every-minute to explicit minute boundaries rather than a // load-relative 60-second interval. if (Object.keys(calendar).length === 0) { return { calendars: expandFieldValues({ kind: "step", step: 1 }, FIELD_LIMITS.minute).map((Minute) => ({ Minute })), }; } return { calendar }; } function expandFieldValues(field, limit) { const start = field.kind === "rangeStep" ? field.start : limit.min; const end = field.kind === "rangeStep" ? field.end : limit.max; const values = []; for (let value = start; value <= end; value += field.step) values.push(value); return values; } /** Discrete values named by a `list` or (stepless) `rangeStep` field; `null` for anything else (`*`, `value`, `step`). */ function expandListLikeField(field, limit) { if (field.kind === "list") return field.values; if (field.kind === "rangeStep") return expandFieldValues(field, limit); return null; } function rejectStepInsideCalendar(field, name, spec) { if (field.kind === "step") { throw new UsageError(`Schedule "${spec.raw}" uses step (${name} = */N) in a position macOS launchd cannot express. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE", "Either restrict the step to the minute or hour field only, or rewrite the schedule with concrete values."); } if (field.kind === "rangeStep") { const shape = field.step === 1 ? "A-B" : "A-B/N"; throw new UsageError(`Schedule "${spec.raw}" uses a range (${name} = ${shape}) in a position macOS launchd cannot express. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE", "Restrict the range to the minute, hour, day-of-week, or month field, or rewrite the schedule with a concrete value."); } if (field.kind === "list") { throw new UsageError(`Schedule "${spec.raw}" uses comma list (${name} = a,b,...) which macOS launchd cannot express as a single trigger. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE", "Either install one task per list element, or rewrite the schedule with a step (`*/N`) or single value."); } } const MAX_SCHTASKS_TRIGGERS = 48; export function translateToSchtasks(spec) { const f = spec.fields; rejectDomDowCombination(spec, "Windows Task Scheduler"); // A repetition interval is cron-equivalent indefinitely only when it divides // the field's wall-clock cycle. Other steps must reset at every hour. if (f.minute.kind === "step" && f.hour.kind === "star" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { if (60 % f.minute.step !== 0) { return minuteValuesTrigger(expandFieldValues(f.minute, FIELD_LIMITS.minute), spec); } return { kind: "minute", everyMinutes: f.minute.step }; } if (f.minute.kind === "rangeStep" && f.hour.kind === "star" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { if (f.minute.start === FIELD_LIMITS.minute.min && f.minute.end === FIELD_LIMITS.minute.max) { if (60 % f.minute.step === 0) return { kind: "minute", everyMinutes: f.minute.step }; } return minuteValuesTrigger(expandFieldValues(f.minute, FIELD_LIMITS.minute), spec); } // Fixed-minute hour schedules can use one daily-reset repetition only when // the interval divides 24 hours. Non-divisors become explicit daily times. if (f.minute.kind === "value" && f.hour.kind === "step" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { if (24 % f.hour.step === 0 && f.hour.step < 24) { return { kind: "hour", everyHours: f.hour.step, atMinute: f.minute.value }; } return { kind: "hourValues", hours: expandFieldValues(f.hour, FIELD_LIMITS.hour), atMinute: f.minute.value, }; } if (f.minute.kind === "value" && f.hour.kind === "rangeStep" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { if (f.hour.start === FIELD_LIMITS.hour.min && f.hour.end === FIELD_LIMITS.hour.max) { if (24 % f.hour.step === 0 && f.hour.step < 24) { return { kind: "hour", everyHours: f.hour.step, atMinute: f.minute.value }; } } return { kind: "hourValues", hours: expandFieldValues(f.hour, FIELD_LIMITS.hour), atMinute: f.minute.value, }; } // `M * * * *` includes the shipped top-of-hour task and arbitrary fixed // minutes. A daily calendar trigger resets the hourly repetition each day. if (f.minute.kind === "value" && f.hour.kind === "star" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { return { kind: "hour", everyHours: 1, atMinute: f.minute.value }; } // `M H * * *` → DAILY at H:M. if (f.minute.kind === "value" && f.hour.kind === "value" && f.dom.kind === "star" && f.month.kind === "star" && f.dow.kind === "star") { return { kind: "daily", atHour: f.hour.value, atMinute: f.minute.value }; } // `M H * * D` → WEEKLY at H:M on day(s) D — a single day, a plain range // (`1-5`, e.g. from `MON-FRI`), or a comma list all name a small closed set // of weekdays that Task Scheduler's native `<DaysOfWeek>` already takes as // a set, so no expansion into multiple triggers is needed. if (f.minute.kind === "value" && f.hour.kind === "value" && f.dom.kind === "star" && f.month.kind === "star" && (f.dow.kind === "value" || f.dow.kind === "list" || f.dow.kind === "rangeStep")) { const daysOfWeek = f.dow.kind === "value" ? [f.dow.value] : expandListLikeField(f.dow, FIELD_LIMITS.dow); return { kind: "weekly", atHour: f.hour.value, atMinute: f.minute.value, daysOfWeek, }; } // `M H D * *` / `M H D m *` → MONTHLY at H:M on day(s)-of-month D, in the // given month(s) m (or every month when `m` is `*`). Task Scheduler's // `ScheduleByMonth` trigger takes both as native sets, same as `ScheduleByWeek` // above. if (f.minute.kind === "value" && f.hour.kind === "value" && (f.dom.kind === "value" || f.dom.kind === "list") && (f.month.kind === "star" || f.month.kind === "value" || f.month.kind === "list") && f.dow.kind === "star") { const daysOfMonth = f.dom.kind === "value" ? [f.dom.value] : f.dom.values; const months = f.month.kind === "star" ? Array.from({ length: 12 }, (_, i) => i + 1) : f.month.kind === "value" ? [f.month.value] : f.month.values; return { kind: "monthly", atHour: f.hour.value, atMinute: f.minute.value, daysOfMonth, months }; } throw new UsageError(`Schedule "${spec.raw}" cannot be expressed as a Windows Task Scheduler trigger. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE", "Use one of: minute steps/range-steps, fixed-minute hour steps/range-steps, hourly, daily, weekly on one or more weekdays, or monthly on one or more days-of-month."); } function minuteValuesTrigger(minutes, spec) { if (minutes.length > MAX_SCHTASKS_TRIGGERS) { throw new UsageError(`Schedule "${spec.raw}" requires ${minutes.length} native triggers; Windows Task Scheduler allows at most ${MAX_SCHTASKS_TRIGGERS}.`, "INVALID_FLAG_VALUE", "Use a full-field step whose interval divides 60, or reduce the minute range."); } return { kind: "minuteValues", minutes }; } function rejectDomDowCombination(spec, backend) { if (spec.fields.dom.kind !== "star" && spec.fields.dow.kind !== "star") { throw new UsageError(`Schedule "${spec.raw}": day-of-month and day-of-week use OR semantics in cron, which ${backend} cannot express portably.`, "INVALID_FLAG_VALUE", "Use either day-of-month or day-of-week, but not both."); } } /** Human-readable summary used by `tasks doctor`. */ export const SCHEDULE_SUPPORTED_SUBSET_HINT = SUPPORTED_HINT;