akm-cli 0.7.4 → 0.8.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli/parse-args.js +86 -0
  4. package/dist/cli.js +1223 -650
  5. package/dist/commands/agent-dispatch.js +107 -0
  6. package/dist/commands/agent-support.js +62 -0
  7. package/dist/commands/config-cli.js +68 -84
  8. package/dist/commands/consolidate.js +812 -0
  9. package/dist/commands/curate.js +1 -0
  10. package/dist/commands/distill-promotion-policy.js +658 -0
  11. package/dist/commands/distill.js +224 -39
  12. package/dist/commands/eval-cases.js +40 -0
  13. package/dist/commands/events.js +12 -24
  14. package/dist/commands/graph.js +222 -0
  15. package/dist/commands/health.js +376 -0
  16. package/dist/commands/help/help-accept.md +9 -0
  17. package/dist/commands/help/help-improve.md +53 -0
  18. package/dist/commands/help/help-proposals.md +15 -0
  19. package/dist/commands/help/help-propose.md +17 -0
  20. package/dist/commands/help/help-reject.md +8 -0
  21. package/dist/commands/history.js +3 -30
  22. package/dist/commands/improve.js +1161 -0
  23. package/dist/commands/info.js +2 -2
  24. package/dist/commands/init.js +2 -2
  25. package/dist/commands/install-audit.js +5 -1
  26. package/dist/commands/installed-stashes.js +118 -138
  27. package/dist/commands/knowledge.js +133 -0
  28. package/dist/commands/lint/agent-linter.js +46 -0
  29. package/dist/commands/lint/base-linter.js +291 -0
  30. package/dist/commands/lint/command-linter.js +46 -0
  31. package/dist/commands/lint/default-linter.js +13 -0
  32. package/dist/commands/lint/index.js +145 -0
  33. package/dist/commands/lint/knowledge-linter.js +13 -0
  34. package/dist/commands/lint/memory-linter.js +58 -0
  35. package/dist/commands/lint/registry.js +33 -0
  36. package/dist/commands/lint/skill-linter.js +42 -0
  37. package/dist/commands/lint/task-linter.js +47 -0
  38. package/dist/commands/lint/types.js +1 -0
  39. package/dist/commands/lint/vault-key-rules.js +67 -0
  40. package/dist/commands/lint/workflow-linter.js +53 -0
  41. package/dist/commands/lint.js +1 -0
  42. package/dist/commands/migration-help.js +2 -2
  43. package/dist/commands/proposal.js +8 -7
  44. package/dist/commands/propose.js +106 -43
  45. package/dist/commands/reflect.js +167 -41
  46. package/dist/commands/registry-search.js +2 -2
  47. package/dist/commands/remember.js +55 -1
  48. package/dist/commands/schema-repair.js +130 -0
  49. package/dist/commands/search.js +21 -5
  50. package/dist/commands/show.js +135 -55
  51. package/dist/commands/source-add.js +10 -10
  52. package/dist/commands/source-manage.js +11 -19
  53. package/dist/commands/tasks.js +385 -0
  54. package/dist/commands/url-checker.js +39 -0
  55. package/dist/commands/vault.js +173 -87
  56. package/dist/core/action-contributors.js +25 -0
  57. package/dist/core/asset-ref.js +4 -0
  58. package/dist/core/asset-registry.js +5 -17
  59. package/dist/core/asset-spec.js +11 -1
  60. package/dist/core/common.js +100 -0
  61. package/dist/core/concurrent.js +22 -0
  62. package/dist/core/config.js +240 -127
  63. package/dist/core/events.js +87 -123
  64. package/dist/core/frontmatter.js +0 -6
  65. package/dist/core/markdown.js +17 -0
  66. package/dist/core/memory-improve.js +678 -0
  67. package/dist/core/parse.js +155 -0
  68. package/dist/core/paths.js +101 -3
  69. package/dist/core/proposal-validators.js +61 -0
  70. package/dist/core/proposals.js +49 -38
  71. package/dist/core/state-db.js +731 -0
  72. package/dist/core/time.js +51 -0
  73. package/dist/core/warn.js +59 -1
  74. package/dist/indexer/db-search.js +86 -472
  75. package/dist/indexer/db.js +418 -59
  76. package/dist/indexer/ensure-index.js +133 -0
  77. package/dist/indexer/graph-boost.js +247 -94
  78. package/dist/indexer/graph-db.js +201 -0
  79. package/dist/indexer/graph-dedup.js +99 -0
  80. package/dist/indexer/graph-extraction.js +417 -74
  81. package/dist/indexer/index-context.js +10 -0
  82. package/dist/indexer/indexer.js +480 -298
  83. package/dist/indexer/llm-cache.js +47 -0
  84. package/dist/indexer/matchers.js +124 -160
  85. package/dist/indexer/memory-inference.js +63 -29
  86. package/dist/indexer/metadata-contributors.js +26 -0
  87. package/dist/indexer/metadata.js +196 -197
  88. package/dist/indexer/path-resolver.js +89 -0
  89. package/dist/indexer/ranking-contributors.js +204 -0
  90. package/dist/indexer/ranking.js +74 -0
  91. package/dist/indexer/search-hit-enrichers.js +22 -0
  92. package/dist/indexer/search-source.js +24 -9
  93. package/dist/indexer/semantic-status.js +2 -16
  94. package/dist/indexer/walker.js +25 -0
  95. package/dist/integrations/agent/builders.js +109 -0
  96. package/dist/integrations/agent/config.js +203 -3
  97. package/dist/integrations/agent/index.js +5 -2
  98. package/dist/integrations/agent/model-aliases.js +63 -0
  99. package/dist/integrations/agent/profiles.js +67 -5
  100. package/dist/integrations/agent/prompts.js +114 -29
  101. package/dist/integrations/agent/sdk-runner.js +120 -0
  102. package/dist/integrations/agent/spawn.js +158 -34
  103. package/dist/integrations/lockfile.js +10 -18
  104. package/dist/integrations/session-logs/index.js +65 -0
  105. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  106. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  107. package/dist/integrations/session-logs/types.js +1 -0
  108. package/dist/llm/call-ai.js +74 -0
  109. package/dist/llm/client.js +63 -86
  110. package/dist/llm/feature-gate.js +27 -16
  111. package/dist/llm/graph-extract.js +297 -64
  112. package/dist/llm/memory-infer.js +52 -71
  113. package/dist/llm/metadata-enhance.js +39 -22
  114. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  115. package/dist/output/cli-hints-full.md +277 -0
  116. package/dist/output/cli-hints-short.md +65 -0
  117. package/dist/output/cli-hints.js +2 -309
  118. package/dist/output/renderers.js +226 -257
  119. package/dist/output/shapes.js +109 -96
  120. package/dist/output/text.js +274 -36
  121. package/dist/registry/providers/skills-sh.js +61 -49
  122. package/dist/registry/providers/static-index.js +44 -48
  123. package/dist/registry/resolve.js +8 -16
  124. package/dist/setup/setup.js +510 -11
  125. package/dist/sources/provider-factory.js +2 -1
  126. package/dist/sources/providers/filesystem.js +16 -23
  127. package/dist/sources/providers/git.js +45 -4
  128. package/dist/sources/providers/website.js +15 -22
  129. package/dist/sources/website-ingest.js +4 -0
  130. package/dist/tasks/backends/cron.js +200 -0
  131. package/dist/tasks/backends/exec-utils.js +25 -0
  132. package/dist/tasks/backends/index.js +32 -0
  133. package/dist/tasks/backends/launchd-template.xml +19 -0
  134. package/dist/tasks/backends/launchd.js +184 -0
  135. package/dist/tasks/backends/schtasks-template.xml +29 -0
  136. package/dist/tasks/backends/schtasks.js +212 -0
  137. package/dist/tasks/parser.js +198 -0
  138. package/dist/tasks/resolveAkmBin.js +84 -0
  139. package/dist/tasks/runner.js +432 -0
  140. package/dist/tasks/schedule.js +208 -0
  141. package/dist/tasks/schema.js +13 -0
  142. package/dist/tasks/validator.js +59 -0
  143. package/dist/wiki/index-template.md +12 -0
  144. package/dist/wiki/ingest-workflow-template.md +54 -0
  145. package/dist/wiki/log-template.md +8 -0
  146. package/dist/wiki/schema-template.md +61 -0
  147. package/dist/wiki/wiki-templates.js +12 -0
  148. package/dist/wiki/wiki.js +10 -61
  149. package/dist/workflows/authoring.js +5 -25
  150. package/dist/workflows/db.js +9 -0
  151. package/dist/workflows/renderer.js +8 -3
  152. package/dist/workflows/runs.js +73 -88
  153. package/dist/workflows/scope-key.js +76 -0
  154. package/dist/workflows/validator.js +1 -1
  155. package/dist/workflows/workflow-template.md +24 -0
  156. package/docs/README.md +5 -2
  157. package/docs/migration/release-notes/0.7.0.md +1 -1
  158. package/docs/migration/release-notes/0.7.4.md +1 -1
  159. package/docs/migration/release-notes/0.7.5.md +20 -0
  160. package/docs/migration/release-notes/0.8.0.md +43 -0
  161. package/package.json +4 -3
  162. package/dist/templates/wiki-templates.js +0 -100
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { TYPE_DIRS } from "../../core/asset-spec";
6
6
  import { resolveStashDir } from "../../core/common";
7
- import { loadConfig } from "../../core/config";
7
+ import { getSources, loadConfig } from "../../core/config";
8
8
  import { ConfigError, UsageError } from "../../core/errors";
9
9
  import { getRegistryCacheDir, getRegistryIndexCacheDir } from "../../core/paths";
10
10
  import { sanitizeCommitMessage } from "../../core/write-source";
@@ -15,7 +15,6 @@ import { applyAkmIncludeConfig, buildInstallCacheDir, copyDirectoryContents, det
15
15
  const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
16
16
  /** Maximum stale age allowed when refresh fails (7 days). */
17
17
  const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
18
- const GIT_STASH_TYPES = new Set(["git"]);
19
18
  /**
20
19
  * Git source provider — clones (and re-pulls) a remote repo into a local
21
20
  * cache directory. Implements the v1 {@link SourceProvider} interface (spec
@@ -407,10 +406,10 @@ export function saveGitStash(name, message, writableOverride) {
407
406
  let writable = false;
408
407
  if (name) {
409
408
  const config = loadConfig();
410
- const stash = (config.sources ?? config.stashes ?? []).find((s) => s.name === name || s.url === name);
409
+ const stash = findGitStashByTarget(getSources(config), name);
411
410
  if (!stash)
412
411
  throw new UsageError(`No git stash found with name "${name}"`);
413
- if (!GIT_STASH_TYPES.has(stash.type)) {
412
+ if (stash.type !== "git") {
414
413
  throw new UsageError(`Stash "${name}" is not a git stash (type: ${stash.type})`);
415
414
  }
416
415
  if (!stash.url)
@@ -468,5 +467,47 @@ export function saveGitStash(name, message, writableOverride) {
468
467
  output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
469
468
  };
470
469
  }
470
+ function findGitStashByTarget(stashes, target) {
471
+ return stashes.find((stash) => matchesGitStashTarget(stash, target));
472
+ }
473
+ function matchesGitStashTarget(stash, target) {
474
+ if (stash.type !== "git")
475
+ return false;
476
+ if (stash.name === target || stash.url === target)
477
+ return true;
478
+ if (!stash.url)
479
+ return false;
480
+ try {
481
+ const repo = parseGitRepoUrl(stash.url);
482
+ if (repo.canonicalUrl === target)
483
+ return true;
484
+ return buildGithubTargetAliases(repo.canonicalUrl).has(target);
485
+ }
486
+ catch {
487
+ return false;
488
+ }
489
+ }
490
+ function buildGithubTargetAliases(canonicalUrl) {
491
+ try {
492
+ const parsed = new URL(canonicalUrl);
493
+ if (parsed.hostname !== "github.com")
494
+ return new Set();
495
+ const segments = parsed.pathname.split("/").filter(Boolean);
496
+ if (segments.length < 2)
497
+ return new Set();
498
+ const owner = segments[0];
499
+ const repo = segments[1];
500
+ const aliases = new Set([`${owner}/${repo}`, `github:${owner}/${repo}`]);
501
+ if (segments[2] === "tree" && segments.length >= 4) {
502
+ const ref = segments.slice(3).join("/");
503
+ aliases.add(`${owner}/${repo}#${ref}`);
504
+ aliases.add(`github:${owner}/${repo}#${ref}`);
505
+ }
506
+ return aliases;
507
+ }
508
+ catch {
509
+ return new Set();
510
+ }
511
+ }
471
512
  // ── Exports ─────────────────────────────────────────────────────────────────
472
513
  export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
@@ -3,25 +3,18 @@ import { ensureWebsiteMirror, getWebsiteCachePaths, validateWebsiteUrl } from ".
3
3
  /**
4
4
  * Website source provider — thin adapter over the shared website ingest module.
5
5
  */
6
- class WebsiteSourceProvider {
7
- kind = "website";
8
- name;
9
- #config;
10
- #url;
11
- constructor(config) {
12
- this.#config = config;
13
- this.name = config.name ?? "website";
14
- this.#url = validateWebsiteUrl(config.url ?? "");
15
- }
16
- async init(_ctx) {
17
- // URL validation already happens in the constructor; nothing else to do.
18
- }
19
- path() {
20
- return getWebsiteCachePaths(this.#url).stashDir;
21
- }
22
- async sync() {
23
- await ensureWebsiteMirror(this.#config, { requireStashDir: true });
24
- }
25
- }
26
- registerSourceProvider("website", (config) => new WebsiteSourceProvider(config));
27
- export { WebsiteSourceProvider };
6
+ registerSourceProvider("website", (config) => {
7
+ const url = validateWebsiteUrl(config.url ?? "");
8
+ const name = config.name ?? "website";
9
+ return {
10
+ kind: "website",
11
+ name,
12
+ async init(_ctx) { },
13
+ path() {
14
+ return getWebsiteCachePaths(url).stashDir;
15
+ },
16
+ async sync() {
17
+ await ensureWebsiteMirror(config, { requireStashDir: true });
18
+ },
19
+ };
20
+ });
@@ -167,6 +167,10 @@ async function crawlWebsite(startUrl, options) {
167
167
  return pages;
168
168
  }
169
169
  async function fetchWebsitePage(pageUrl) {
170
+ const parsedUrl = new URL(pageUrl);
171
+ if (parsedUrl.hostname.endsWith(".invalid")) {
172
+ throw new Error(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`);
173
+ }
170
174
  const response = await fetchWithRetry(pageUrl, {
171
175
  headers: {
172
176
  Accept: "text/html, text/markdown, text/plain;q=0.9, application/xhtml+xml;q=0.8",
@@ -0,0 +1,200 @@
1
+ // crontab backend for `akm tasks` (Linux default).
2
+ //
3
+ // Each akm-owned entry is wrapped in markers so a hand-edited crontab keeps
4
+ // its other lines untouched:
5
+ //
6
+ // # akm:task <id> BEGIN
7
+ // [SCHED] /abs/akm tasks run <id> >> /home/.../tasks/logs/<id>.log 2>&1
8
+ // # akm:task <id> END
9
+ //
10
+ // The backend reads/writes the user's crontab via `crontab -l` and
11
+ // `crontab -`. Disabling a task comments the entry with `# akm:disabled `
12
+ // rather than removing it, so re-enabling preserves the original schedule.
13
+ //
14
+ // Platform notes:
15
+ // • Operates on the *per-user* crontab — system-wide /etc/cron.d entries
16
+ // are out of scope.
17
+ // • Cron runs jobs with a stripped environment (`SHELL`, `PATH`, `HOME`,
18
+ // `LOGNAME`/`USER` only). The cron line uses an absolute akm path
19
+ // resolved at install time so it doesn't rely on the inherited PATH.
20
+ // • BSD `crontab -l` returns exit 1 with "no crontab for <user>" on a
21
+ // fresh user; we treat that as an empty crontab rather than an error.
22
+ //
23
+ // Tests inject a fake exec so unit tests don't touch the real crontab.
24
+ import { spawnSync } from "node:child_process";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { ConfigError } from "../../core/errors";
28
+ import { getTaskLogDir } from "../../core/paths";
29
+ import { resolveAkmInvocation } from "../resolveAkmBin";
30
+ import { parseSchedule, translateToCron } from "../schedule";
31
+ const BEGIN = (id) => `# akm:task ${id} BEGIN`;
32
+ const END = (id) => `# akm:task ${id} END`;
33
+ const DISABLED_PREFIX = "# akm:disabled ";
34
+ const BLOCK_RE = /^# akm:task ([\w.@:_-]+) BEGIN$/;
35
+ export function CRON_BACKEND(options = {}) {
36
+ const exec = options.exec ?? defaultCronExec();
37
+ const logDir = options.logDir ?? getTaskLogDir();
38
+ const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
39
+ return {
40
+ name: "cron",
41
+ install(task) {
42
+ // Create the log directory before writing the crontab line — cron
43
+ // appends with `>>` and the surrounding shell will fail the entire
44
+ // entry if the parent directory doesn't exist.
45
+ ensureDir(logDir);
46
+ const cronLine = buildCronLine(task, akmArgv, logDir);
47
+ const existing = readCrontab(exec);
48
+ const block = renderBlock(task.id, cronLine, task.enabled);
49
+ const next = upsertBlock(existing, task.id, block);
50
+ writeCrontab(exec, next);
51
+ },
52
+ uninstall(id) {
53
+ const existing = readCrontab(exec);
54
+ const next = removeBlock(existing, id);
55
+ writeCrontab(exec, next);
56
+ },
57
+ setEnabled(id, enabled) {
58
+ const existing = readCrontab(exec);
59
+ const next = toggleBlock(existing, id, enabled);
60
+ writeCrontab(exec, next);
61
+ },
62
+ list() {
63
+ const existing = readCrontab(exec);
64
+ const ids = [];
65
+ for (const line of existing.split(/\r?\n/)) {
66
+ const m = line.match(BLOCK_RE);
67
+ if (m)
68
+ ids.push(m[1]);
69
+ }
70
+ return ids.map((id) => ({ id }));
71
+ },
72
+ };
73
+ }
74
+ // ── helpers (exported for tests) ────────────────────────────────────────────
75
+ export function buildCronLine(task, akmArgv, logDir) {
76
+ const spec = parseSchedule(task.schedule, "cron");
77
+ const cronExpr = translateToCron(spec);
78
+ const logPath = path.join(logDir, `${task.id}.log`);
79
+ const cmd = [...akmArgv, "tasks", "run", task.id].map((part) => quoteForCron(part)).join(" ");
80
+ return `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
81
+ }
82
+ export function renderBlock(id, cronLine, enabled) {
83
+ const body = enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`;
84
+ return [BEGIN(id), body, END(id)].join("\n");
85
+ }
86
+ export function upsertBlock(existing, id, block) {
87
+ const trimmed = existing.replace(/\s+$/g, "");
88
+ const removed = removeBlock(trimmed, id);
89
+ const sep = removed.length === 0 ? "" : "\n";
90
+ return `${removed}${sep}${block}\n`;
91
+ }
92
+ export function removeBlock(existing, id) {
93
+ const lines = existing.split(/\r?\n/);
94
+ const out = [];
95
+ let inBlock = false;
96
+ for (const line of lines) {
97
+ if (!inBlock && line === BEGIN(id)) {
98
+ inBlock = true;
99
+ continue;
100
+ }
101
+ if (inBlock && line === END(id)) {
102
+ inBlock = false;
103
+ continue;
104
+ }
105
+ if (inBlock)
106
+ continue;
107
+ out.push(line);
108
+ }
109
+ // Collapse trailing blank lines.
110
+ while (out.length > 0 && out[out.length - 1] === "")
111
+ out.pop();
112
+ return out.join("\n");
113
+ }
114
+ export function toggleBlock(existing, id, enabled) {
115
+ const lines = existing.split(/\r?\n/);
116
+ const out = [];
117
+ let inBlock = false;
118
+ for (const line of lines) {
119
+ if (!inBlock && line === BEGIN(id)) {
120
+ inBlock = true;
121
+ out.push(line);
122
+ continue;
123
+ }
124
+ if (inBlock && line === END(id)) {
125
+ inBlock = false;
126
+ out.push(line);
127
+ continue;
128
+ }
129
+ if (inBlock) {
130
+ const isComment = line.startsWith(DISABLED_PREFIX);
131
+ if (enabled && isComment) {
132
+ out.push(line.slice(DISABLED_PREFIX.length));
133
+ }
134
+ else if (!enabled && !isComment) {
135
+ out.push(`${DISABLED_PREFIX}${line}`);
136
+ }
137
+ else {
138
+ out.push(line);
139
+ }
140
+ continue;
141
+ }
142
+ out.push(line);
143
+ }
144
+ return out.join("\n");
145
+ }
146
+ function quoteForCron(part) {
147
+ // crontab passes the rest of the line to /bin/sh -c, so quote anything that
148
+ // isn't a plain shell-safe token. Single-quote and escape embedded single
149
+ // quotes via the standard shell idiom: `'foo'\''bar'`.
150
+ if (/^[A-Za-z0-9_\-./@:%=+,]+$/.test(part))
151
+ return part;
152
+ return `'${part.replace(/'/g, `'\\''`)}'`;
153
+ }
154
+ function readCrontab(exec) {
155
+ const result = exec.read();
156
+ if (result.status === 0)
157
+ return result.stdout ?? "";
158
+ // BSD crontab returns 1 with "no crontab for <user>" on stderr — treat as empty.
159
+ if (/no crontab for/i.test(result.stderr ?? ""))
160
+ return "";
161
+ if (/no crontab/i.test(result.stdout ?? ""))
162
+ return "";
163
+ throw new ConfigError(`crontab -l failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure the `crontab` binary is on PATH and your shell can read the user crontab.");
164
+ }
165
+ function writeCrontab(exec, content) {
166
+ const normalised = content.endsWith("\n") || content.length === 0 ? content : `${content}\n`;
167
+ const result = exec.write(normalised);
168
+ if (result.status !== 0) {
169
+ throw new ConfigError(`crontab - failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure the `crontab` binary is on PATH and your shell can write the user crontab.");
170
+ }
171
+ }
172
+ function ensureDir(dir) {
173
+ try {
174
+ fs.mkdirSync(dir, { recursive: true });
175
+ }
176
+ catch {
177
+ // Best-effort: the install will surface a clearer error if the cron
178
+ // line later fails at runtime due to a missing redirection target.
179
+ }
180
+ }
181
+ function defaultCronExec() {
182
+ return {
183
+ read() {
184
+ const r = spawnSync("crontab", ["-l"], { encoding: "utf8" });
185
+ return {
186
+ status: r.status ?? 1,
187
+ stdout: r.stdout ?? "",
188
+ stderr: r.stderr ?? "",
189
+ };
190
+ },
191
+ write(content) {
192
+ const r = spawnSync("crontab", ["-"], { encoding: "utf8", input: content });
193
+ return {
194
+ status: r.status ?? 1,
195
+ stdout: r.stdout ?? "",
196
+ stderr: r.stderr ?? "",
197
+ };
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Run a command synchronously, normalizing null results to safe defaults.
4
+ * args[0] is the binary; args[1..] are its arguments.
5
+ */
6
+ export function spawnCommand(args) {
7
+ const [bin, ...rest] = args;
8
+ const r = spawnSync(bin, rest, { encoding: "utf8" });
9
+ return {
10
+ status: r.status ?? 1,
11
+ stdout: r.stdout ?? "",
12
+ stderr: r.stderr ?? "",
13
+ };
14
+ }
15
+ /**
16
+ * Escape a string for safe embedding in an XML attribute or text node.
17
+ */
18
+ export function escapeXml(s) {
19
+ return s
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&apos;");
25
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Backend selection for the OS-native scheduler.
3
+ *
4
+ * • Linux → crontab
5
+ * • macOS → launchd (per-user LaunchAgent)
6
+ * • Windows → schtasks.exe / Task Scheduler
7
+ *
8
+ * Each backend implements {@link TaskBackend}; selection is a one-line
9
+ * platform check. Tests inject a fake `platform` to exercise non-host
10
+ * code paths.
11
+ */
12
+ import { CRON_BACKEND } from "./cron";
13
+ import { LAUNCHD_BACKEND } from "./launchd";
14
+ import { SCHTASKS_BACKEND } from "./schtasks";
15
+ export function selectBackend(options = {}) {
16
+ const platform = options.platform ?? process.platform;
17
+ switch (platform) {
18
+ case "win32":
19
+ return SCHTASKS_BACKEND(options.schtasks);
20
+ case "darwin":
21
+ return LAUNCHD_BACKEND(options.launchd);
22
+ default:
23
+ return CRON_BACKEND(options.cron);
24
+ }
25
+ }
26
+ export function backendNameForPlatform(platform = process.platform) {
27
+ if (platform === "win32")
28
+ return "schtasks";
29
+ if (platform === "darwin")
30
+ return "launchd";
31
+ return "cron";
32
+ }
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>{{LABEL}}</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ {{PROGRAM_ARGS}}
10
+ </array>
11
+ <key>StandardOutPath</key>
12
+ <string>{{LOG_PATH}}</string>
13
+ <key>StandardErrorPath</key>
14
+ <string>{{LOG_PATH}}</string>
15
+ <key>RunAtLoad</key>
16
+ <false/>
17
+ {{ENV_VARS}}{{TRIGGER_XML}}
18
+ </dict>
19
+ </plist>
@@ -0,0 +1,184 @@
1
+ /**
2
+ * launchd backend for `akm tasks` (macOS default).
3
+ *
4
+ * Each task is written as a per-user LaunchAgent plist at
5
+ * `~/Library/LaunchAgents/com.akm.task.<id>.plist` and registered via
6
+ * `launchctl bootstrap gui/<uid> <plist>`. Disabling uses
7
+ * `launchctl disable gui/<uid>/<label>` and re-enabling uses `enable`.
8
+ *
9
+ * Platform notes:
10
+ * • The `bootstrap` / `bootout` / `enable` / `disable` subcommands require
11
+ * macOS 10.10 (Yosemite) or newer. On older systems the equivalents
12
+ * are `launchctl load -w` / `unload -w`. We only target modern macOS.
13
+ * • `gui/<uid>` is the per-user GUI launchd domain — agents in this
14
+ * domain only run while the user is logged in (no background runs at
15
+ * the loginwindow). Tasks that need to run when the user is logged
16
+ * out should be installed as system Daemons, which is out of scope.
17
+ *
18
+ * Tests inject a fake exec + filesystem so the backend can be unit-tested
19
+ * without touching the host launchctl.
20
+ */
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { ConfigError } from "../../core/errors";
25
+ import { getTaskLogDir } from "../../core/paths";
26
+ import { resolveAkmInvocation } from "../resolveAkmBin";
27
+ import { parseSchedule, translateToLaunchd } from "../schedule";
28
+ import { escapeXml, spawnCommand } from "./exec-utils";
29
+ import launchdTemplate from "./launchd-template.xml" with { type: "text" };
30
+ export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
31
+ export function LAUNCHD_BACKEND(options = {}) {
32
+ const exec = options.exec ?? defaultLaunchdExec();
33
+ const fsLike = options.fs ?? defaultLaunchdFs();
34
+ const agentsDir = options.agentsDir ?? defaultAgentsDir();
35
+ const logDir = options.logDir ?? getTaskLogDir();
36
+ const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
37
+ const plistPath = (id) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${id}.plist`);
38
+ const label = (id) => `${LAUNCHD_LABEL_PREFIX}${id}`;
39
+ const target = (id) => `gui/${exec.uid()}/${label(id)}`;
40
+ return {
41
+ name: "launchd",
42
+ install(task) {
43
+ // Capture PATH at install time so launchd (which strips the environment
44
+ // aggressively) can find the same binaries the user sees interactively.
45
+ let pathEnv;
46
+ if (options.envPath === false) {
47
+ pathEnv = undefined;
48
+ }
49
+ else if (typeof options.envPath === "string") {
50
+ pathEnv = options.envPath;
51
+ }
52
+ else {
53
+ pathEnv = process.env.PATH ?? "";
54
+ }
55
+ const xml = buildPlistXml(task, akmArgv, logDir, pathEnv);
56
+ fsLike.ensureDir(agentsDir);
57
+ // launchd refuses to start a job when StandardOutPath/StandardErrorPath
58
+ // points at a non-existent directory; create it before bootstrap.
59
+ fsLike.ensureDir(logDir);
60
+ fsLike.writeFile(plistPath(task.id), xml);
61
+ const bootout = exec.run(["launchctl", "bootout", target(task.id)]);
62
+ // bootout returning non-zero is fine — agent might not be loaded.
63
+ void bootout;
64
+ const bootstrap = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, plistPath(task.id)]);
65
+ if (bootstrap.status !== 0) {
66
+ throw new ConfigError(`launchctl bootstrap failed (exit ${bootstrap.status}): ${bootstrap.stderr || bootstrap.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure `launchctl` is available; on macOS it is part of the base system.");
67
+ }
68
+ if (!task.enabled) {
69
+ const disable = exec.run(["launchctl", "disable", target(task.id)]);
70
+ if (disable.status !== 0) {
71
+ throw new ConfigError(`launchctl disable failed: ${disable.stderr || disable.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
72
+ }
73
+ }
74
+ },
75
+ uninstall(id) {
76
+ // Bootout first (may fail if agent never loaded — that's fine).
77
+ exec.run(["launchctl", "bootout", target(id)]);
78
+ const file = plistPath(id);
79
+ if (fsLike.exists(file))
80
+ fsLike.removeFile(file);
81
+ },
82
+ setEnabled(id, enabled) {
83
+ const verb = enabled ? "enable" : "disable";
84
+ const r = exec.run(["launchctl", verb, target(id)]);
85
+ if (r.status !== 0) {
86
+ throw new ConfigError(`launchctl ${verb} failed: ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
87
+ }
88
+ },
89
+ list() {
90
+ if (!fsLike.exists(agentsDir))
91
+ return [];
92
+ const ids = [];
93
+ for (const file of fsLike.list(agentsDir)) {
94
+ if (file.startsWith(LAUNCHD_LABEL_PREFIX) && file.endsWith(".plist")) {
95
+ ids.push(file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length));
96
+ }
97
+ }
98
+ return ids.map((id) => ({ id }));
99
+ },
100
+ };
101
+ }
102
+ // ── XML builder (exported for tests) ────────────────────────────────────────
103
+ export function buildPlistXml(task, akmArgv, logDir, pathEnv) {
104
+ const spec = parseSchedule(task.schedule, "launchd");
105
+ const trigger = translateToLaunchd(spec);
106
+ const argv = [...akmArgv, "tasks", "run", task.id];
107
+ const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
108
+ const logPath = path.join(logDir, `${task.id}.log`);
109
+ const triggerXml = renderLaunchdTrigger(trigger);
110
+ const envVarsXml = pathEnv !== undefined
111
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${escapeXml(pathEnv)}</string>\n </dict>\n`
112
+ : "";
113
+ return launchdTemplate
114
+ .replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(task.id))
115
+ .replace("{{PROGRAM_ARGS}}", programArgs)
116
+ .replaceAll("{{LOG_PATH}}", escapeXml(logPath))
117
+ .replace("{{ENV_VARS}}", envVarsXml)
118
+ .replace("{{TRIGGER_XML}}", triggerXml);
119
+ }
120
+ function renderLaunchdTrigger(trigger) {
121
+ if (trigger.intervalSeconds !== undefined) {
122
+ return ` <key>StartInterval</key>
123
+ <integer>${trigger.intervalSeconds}</integer>`;
124
+ }
125
+ const cal = trigger.calendar ?? {};
126
+ const lines = [" <key>StartCalendarInterval</key>", " <dict>"];
127
+ if (cal.Minute !== undefined)
128
+ lines.push(` <key>Minute</key><integer>${cal.Minute}</integer>`);
129
+ if (cal.Hour !== undefined)
130
+ lines.push(` <key>Hour</key><integer>${cal.Hour}</integer>`);
131
+ if (cal.Day !== undefined)
132
+ lines.push(` <key>Day</key><integer>${cal.Day}</integer>`);
133
+ if (cal.Month !== undefined)
134
+ lines.push(` <key>Month</key><integer>${cal.Month}</integer>`);
135
+ if (cal.Weekday !== undefined)
136
+ lines.push(` <key>Weekday</key><integer>${cal.Weekday}</integer>`);
137
+ lines.push(" </dict>");
138
+ return lines.join("\n");
139
+ }
140
+ function defaultAgentsDir() {
141
+ // launchd's per-user LaunchAgents live under the user's home directory.
142
+ // If we can't determine HOME, refuse rather than silently producing a
143
+ // relative path that would write somewhere unexpected.
144
+ const home = os.homedir();
145
+ if (!home) {
146
+ throw new ConfigError("Cannot determine user home directory; launchd backend requires HOME to locate ~/Library/LaunchAgents.", "INVALID_CONFIG_FILE", "Set $HOME (POSIX) or the equivalent before running `akm tasks` on macOS.");
147
+ }
148
+ return path.join(home, "Library", "LaunchAgents");
149
+ }
150
+ function defaultLaunchdExec() {
151
+ return {
152
+ run(args) {
153
+ return spawnCommand(args);
154
+ },
155
+ uid() {
156
+ const fn = process.getuid;
157
+ return typeof fn === "function" ? fn.call(process) : 0;
158
+ },
159
+ };
160
+ }
161
+ function defaultLaunchdFs() {
162
+ return {
163
+ writeFile(file, content) {
164
+ fs.writeFileSync(file, content, { encoding: "utf8" });
165
+ },
166
+ removeFile(file) {
167
+ fs.rmSync(file, { force: true });
168
+ },
169
+ ensureDir(dir) {
170
+ fs.mkdirSync(dir, { recursive: true });
171
+ },
172
+ list(dir) {
173
+ try {
174
+ return fs.readdirSync(dir);
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ },
180
+ exists(file) {
181
+ return fs.existsSync(file);
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,29 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
3
+ <RegistrationInfo>
4
+ <Description>akm scheduled task: {{TASK_ID}}</Description>
5
+ <URI>{{FOLDER}}{{TASK_ID}}</URI>
6
+ </RegistrationInfo>
7
+ <Triggers>
8
+ {{TRIGGER_XML}}
9
+ </Triggers>
10
+ <Principals>
11
+ <Principal id="Author">
12
+ <LogonType>InteractiveToken</LogonType>
13
+ <RunLevel>LeastPrivilege</RunLevel>
14
+ </Principal>
15
+ </Principals>
16
+ <Settings>
17
+ <Enabled>{{ENABLED}}</Enabled>
18
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
19
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
20
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
21
+ </Settings>
22
+ <Actions Context="Author">
23
+ <Exec>
24
+ <Command>{{COMMAND}}</Command>
25
+ <Arguments>{{ARGS}}</Arguments>
26
+ </Exec>
27
+ </Actions>
28
+ <!-- Log target (informational only; schtasks doesn't redirect): {{LOG_PATH}} -->
29
+ </Task>