akm-cli 0.7.4 → 0.8.0-rc1

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 (158) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli/parse-args.js +43 -0
  4. package/dist/cli.js +1007 -593
  5. package/dist/commands/agent-dispatch.js +102 -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 +823 -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 +250 -48
  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 +1170 -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 +251 -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 +107 -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/workflow-linter.js +53 -0
  40. package/dist/commands/lint.js +1 -0
  41. package/dist/commands/migration-help.js +2 -2
  42. package/dist/commands/proposal.js +8 -7
  43. package/dist/commands/propose.js +113 -43
  44. package/dist/commands/reflect.js +175 -41
  45. package/dist/commands/registry-search.js +2 -2
  46. package/dist/commands/remember.js +55 -1
  47. package/dist/commands/schema-repair.js +130 -0
  48. package/dist/commands/search.js +21 -5
  49. package/dist/commands/show.js +131 -52
  50. package/dist/commands/source-add.js +10 -10
  51. package/dist/commands/source-manage.js +11 -19
  52. package/dist/commands/tasks.js +385 -0
  53. package/dist/commands/url-checker.js +39 -0
  54. package/dist/commands/vault.js +7 -33
  55. package/dist/core/action-contributors.js +25 -0
  56. package/dist/core/asset-registry.js +5 -17
  57. package/dist/core/asset-spec.js +11 -1
  58. package/dist/core/common.js +94 -0
  59. package/dist/core/concurrent.js +22 -0
  60. package/dist/core/config.js +229 -122
  61. package/dist/core/events.js +87 -123
  62. package/dist/core/frontmatter.js +3 -1
  63. package/dist/core/markdown.js +17 -0
  64. package/dist/core/memory-improve.js +678 -0
  65. package/dist/core/parse.js +155 -0
  66. package/dist/core/paths.js +101 -3
  67. package/dist/core/proposal-validators.js +61 -0
  68. package/dist/core/proposals.js +49 -38
  69. package/dist/core/state-db.js +775 -0
  70. package/dist/core/time.js +51 -0
  71. package/dist/core/warn.js +59 -1
  72. package/dist/indexer/db-search.js +86 -472
  73. package/dist/indexer/db.js +392 -6
  74. package/dist/indexer/ensure-index.js +133 -0
  75. package/dist/indexer/graph-boost.js +247 -94
  76. package/dist/indexer/graph-db.js +201 -0
  77. package/dist/indexer/graph-dedup.js +99 -0
  78. package/dist/indexer/graph-extraction.js +417 -74
  79. package/dist/indexer/index-context.js +10 -0
  80. package/dist/indexer/indexer.js +466 -298
  81. package/dist/indexer/llm-cache.js +47 -0
  82. package/dist/indexer/match-contributors.js +141 -0
  83. package/dist/indexer/matchers.js +24 -190
  84. package/dist/indexer/memory-inference.js +63 -29
  85. package/dist/indexer/metadata-contributors.js +26 -0
  86. package/dist/indexer/metadata.js +188 -175
  87. package/dist/indexer/path-resolver.js +89 -0
  88. package/dist/indexer/ranking-contributors.js +204 -0
  89. package/dist/indexer/ranking.js +74 -0
  90. package/dist/indexer/search-hit-enrichers.js +22 -0
  91. package/dist/indexer/search-source.js +24 -9
  92. package/dist/indexer/semantic-status.js +2 -16
  93. package/dist/indexer/walker.js +25 -0
  94. package/dist/integrations/agent/config.js +175 -3
  95. package/dist/integrations/agent/index.js +3 -1
  96. package/dist/integrations/agent/pipeline.js +39 -0
  97. package/dist/integrations/agent/profiles.js +67 -5
  98. package/dist/integrations/agent/prompts.js +114 -29
  99. package/dist/integrations/agent/runners.js +31 -0
  100. package/dist/integrations/agent/sdk-runner.js +120 -0
  101. package/dist/integrations/agent/spawn.js +136 -28
  102. package/dist/integrations/lockfile.js +10 -18
  103. package/dist/integrations/session-logs/index.js +65 -0
  104. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  105. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  106. package/dist/integrations/session-logs/types.js +1 -0
  107. package/dist/llm/call-ai.js +74 -0
  108. package/dist/llm/client.js +63 -86
  109. package/dist/llm/feature-gate.js +27 -16
  110. package/dist/llm/graph-extract.js +297 -64
  111. package/dist/llm/memory-infer.js +52 -71
  112. package/dist/llm/metadata-enhance.js +39 -22
  113. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  114. package/dist/output/cli-hints-full.md +277 -0
  115. package/dist/output/cli-hints-short.md +65 -0
  116. package/dist/output/cli-hints.js +2 -309
  117. package/dist/output/renderers.js +196 -124
  118. package/dist/output/shapes.js +41 -3
  119. package/dist/output/text.js +257 -21
  120. package/dist/registry/providers/skills-sh.js +61 -49
  121. package/dist/registry/providers/static-index.js +44 -48
  122. package/dist/setup/setup.js +510 -11
  123. package/dist/sources/provider-factory.js +2 -1
  124. package/dist/sources/providers/git.js +44 -2
  125. package/dist/sources/website-ingest.js +4 -0
  126. package/dist/tasks/backends/cron.js +200 -0
  127. package/dist/tasks/backends/exec-utils.js +25 -0
  128. package/dist/tasks/backends/index.js +32 -0
  129. package/dist/tasks/backends/launchd-template.xml +19 -0
  130. package/dist/tasks/backends/launchd.js +184 -0
  131. package/dist/tasks/backends/schtasks-template.xml +29 -0
  132. package/dist/tasks/backends/schtasks.js +212 -0
  133. package/dist/tasks/parser.js +198 -0
  134. package/dist/tasks/resolveAkmBin.js +84 -0
  135. package/dist/tasks/runner.js +432 -0
  136. package/dist/tasks/schedule.js +208 -0
  137. package/dist/tasks/schema.js +13 -0
  138. package/dist/tasks/validator.js +59 -0
  139. package/dist/wiki/index-template.md +12 -0
  140. package/dist/wiki/ingest-workflow-template.md +54 -0
  141. package/dist/wiki/log-template.md +8 -0
  142. package/dist/wiki/schema-template.md +61 -0
  143. package/dist/wiki/wiki-templates.js +12 -0
  144. package/dist/wiki/wiki.js +10 -61
  145. package/dist/workflows/authoring.js +5 -25
  146. package/dist/workflows/db.js +9 -0
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +73 -88
  149. package/dist/workflows/scope-key.js +76 -0
  150. package/dist/workflows/validator.js +1 -1
  151. package/dist/workflows/workflow-template.md +24 -0
  152. package/docs/README.md +3 -0
  153. package/docs/migration/release-notes/0.7.0.md +1 -1
  154. package/docs/migration/release-notes/0.7.4.md +1 -1
  155. package/docs/migration/release-notes/0.7.5.md +20 -0
  156. package/docs/migration/release-notes/0.8.0.md +43 -0
  157. package/package.json +4 -3
  158. package/dist/templates/wiki-templates.js +0 -100
@@ -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>