@cairnkeep/cli 2.8.0 → 2.11.0

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 (89) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +82 -27
  3. package/bin/cairn +6 -153
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +41 -4
  6. package/docs/containers.md +10 -0
  7. package/docs/context-packs.md +94 -0
  8. package/docs/harness-compatibility.md +37 -3
  9. package/docs/learning/CURRICULUM-MAP.md +16 -5
  10. package/docs/learning/FEATURE-GUIDE.md +15 -4
  11. package/docs/learning/README.md +8 -2
  12. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  13. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  14. package/docs/learning/lessons/L02-installation.md +11 -2
  15. package/docs/learning/lessons/L03-first-project.md +1 -1
  16. package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
  17. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  18. package/docs/learning/lessons/L21-context-packs.md +79 -0
  19. package/docs/learning/lessons/L22-native-windows.md +83 -0
  20. package/docs/learning/lessons/L23-guided-setup.md +138 -0
  21. package/docs/learning/tracks/operator.md +10 -3
  22. package/docs/learning/tracks/quickstart.md +7 -3
  23. package/docs/mcp-tool-profiles.md +43 -0
  24. package/docs/native-windows.md +122 -0
  25. package/docs/operating.md +157 -18
  26. package/docs/privacy-and-data-flow.md +51 -1
  27. package/docs/storage.md +29 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  30. package/mcp-memory-server/dist/artifact-store.js +196 -55
  31. package/mcp-memory-server/dist/capability-cli.js +2 -1
  32. package/mcp-memory-server/dist/capability-config.js +4 -3
  33. package/mcp-memory-server/dist/capability-harness.js +34 -7
  34. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  35. package/mcp-memory-server/dist/context-pack.js +889 -0
  36. package/mcp-memory-server/dist/eval-cli.js +6 -5
  37. package/mcp-memory-server/dist/eval-plan.js +35 -13
  38. package/mcp-memory-server/dist/eval-process.js +11 -4
  39. package/mcp-memory-server/dist/eval-report.js +8 -5
  40. package/mcp-memory-server/dist/eval-runner.js +2 -0
  41. package/mcp-memory-server/dist/eval-schema.js +1 -0
  42. package/mcp-memory-server/dist/graph-cli.js +7 -1
  43. package/mcp-memory-server/dist/index.js +87 -37
  44. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  45. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  46. package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
  47. package/mcp-memory-server/dist/note-store.js +12 -3
  48. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  49. package/mcp-memory-server/dist/platform-security.js +102 -0
  50. package/mcp-memory-server/dist/skill-store.js +27 -12
  51. package/mcp-memory-server/package.json +4 -1
  52. package/package.json +5 -4
  53. package/pi/extensions/cairnkeep-memory.ts +61 -0
  54. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  55. package/schemas/cairnkeep-setup.schema.json +36 -0
  56. package/schemas/context-pack.schema.json +33 -0
  57. package/schemas/eval-report.schema.json +1 -0
  58. package/scripts/cairn-cli.mjs +119 -0
  59. package/scripts/cairn-container-cli.mjs +121 -0
  60. package/scripts/cairn-hook.mjs +199 -0
  61. package/scripts/completion.sh +33 -5
  62. package/scripts/doctor.sh +96 -0
  63. package/scripts/fix-permissions.mjs +12 -0
  64. package/scripts/memory-wiki-audit.mjs +95 -0
  65. package/scripts/phase26-test-manifest.mjs +26 -0
  66. package/scripts/run-repository-tests.mjs +76 -0
  67. package/scripts/setup-core.mjs +310 -0
  68. package/scripts/setup-reconcile.mjs +331 -0
  69. package/scripts/setup.mjs +306 -0
  70. package/scripts/sync-pi-assets.sh +19 -4
  71. package/scripts/test-cli-dispatch.sh +6 -0
  72. package/scripts/test-completion.sh +44 -0
  73. package/scripts/test-doctor.sh +66 -0
  74. package/scripts/test-learning-docs.sh +53 -3
  75. package/scripts/test-package-install.sh +81 -0
  76. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  77. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  78. package/scripts/test-pi-lifecycle.mjs +190 -0
  79. package/scripts/test-setup-compatibility.mjs +186 -0
  80. package/scripts/test-setup-output.sh +131 -0
  81. package/scripts/test-setup-overlay.sh +113 -0
  82. package/scripts/test-setup-preflight.mjs +281 -0
  83. package/scripts/test-setup-reconcile.mjs +257 -0
  84. package/scripts/test-uninstall.sh +71 -0
  85. package/scripts/test-windows-native.mjs +136 -0
  86. package/scripts/uninstall.sh +95 -7
  87. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  88. package/scripts/windows-platform.mjs +725 -0
  89. package/templates/env.example.template +11 -0
@@ -0,0 +1,725 @@
1
+ import {
2
+ accessSync,
3
+ chmodSync,
4
+ copyFileSync,
5
+ cpSync,
6
+ existsSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ constants as fsConstants,
18
+ } from "node:fs";
19
+ import { gzipSync, gunzipSync } from "node:zlib";
20
+ import { spawnSync } from "node:child_process";
21
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
22
+ import { homedir, tmpdir } from "node:os";
23
+
24
+ const BOOTSTRAP_FILES = [
25
+ ["env.example.template", ".ai/env.example"],
26
+ ["trajectory-redaction.json.template", ".ai/trajectory-redaction.json"],
27
+ ["capabilities.json.template", ".ai/capabilities.json"],
28
+ ["agentfs-gitignore.template", ".agentfs/.gitignore"],
29
+ ["planning-config.json.template", ".planning/config.json"],
30
+ ["project-brief.md.template", ".planning/PROJECT-BRIEF.md"],
31
+ ["wiki-index.md.template", ".planning/wiki/index.md"],
32
+ ["wiki-policy.md.template", ".planning/wiki/policy.md"],
33
+ ["wiki-contradictions.md.template", ".planning/wiki/CONTRADICTIONS.md"],
34
+ ["wiki-log.md.template", ".planning/wiki/LOG.md"],
35
+ ["alignment-policy.md.template", ".planning/alignment/policy.md"],
36
+ ["alignment-gap-register.yaml.template", ".planning/alignment/gap-register.yaml"],
37
+ ["graph-policy.md.template", ".planning/graphs/policy.md"],
38
+ ["graphs-gitignore.template", ".planning/graphs/.gitignore"],
39
+ ["security-policy.md.template", ".planning/security/policy.md"],
40
+ ];
41
+
42
+ const HARNESSES = ["claude", "opencode", "pi", "kimi", "qwen"];
43
+ const HOOK_EVENTS = new Map([
44
+ ["memory-wakeup", ["SessionStart", ""]],
45
+ ["memory-capture", ["SessionEnd", ""]],
46
+ ["compaction-capture", ["PostCompact", ""]],
47
+ ["memory-recall", ["PreToolUse", "Edit|Write|MultiEdit"]],
48
+ ["context-explore-pretask", ["UserPromptSubmit", ""]],
49
+ ]);
50
+
51
+ function bool(value) {
52
+ return /^(1|true|yes|on)$/i.test(String(value ?? "").trim());
53
+ }
54
+
55
+ function expandHome(value) {
56
+ if (!value) return value;
57
+ return value === "~" ? homedir() : value.startsWith(`~${sep}`) || value.startsWith("~/")
58
+ ? join(homedir(), value.slice(2)) : value;
59
+ }
60
+
61
+ function run(executable, args, options = {}) {
62
+ const result = spawnSync(executable, args, { encoding: "utf8", ...options });
63
+ if (result.error) throw result.error;
64
+ return result;
65
+ }
66
+
67
+ function hardenWindowsAcl(path) {
68
+ if (process.platform !== "win32") return;
69
+ const identity = run("whoami.exe", ["/user", "/fo", "csv", "/nh"]);
70
+ const sid = identity.stdout.match(/"(S-1-[0-9-]+)"/i)?.[1];
71
+ if (identity.status !== 0 || !sid) throw new Error("Unable to resolve the current Windows security identity.");
72
+ const result = run("icacls.exe", [path, "/inheritance:r", "/grant:r", `*${sid}:(F)`, "/grant:r", "*S-1-5-18:(F)", "/grant:r", "*S-1-5-32-544:(F)"]);
73
+ if (result.status !== 0) throw new Error(`Unable to restrict Windows ACLs: ${path}`);
74
+ }
75
+
76
+ function atomicWrite(path, content, mode = 0o600) {
77
+ mkdirSync(dirname(path), { recursive: true });
78
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
79
+ writeFileSync(temporary, content, { mode });
80
+ try { chmodSync(temporary, mode); } catch {}
81
+ if (mode === 0o600) hardenWindowsAcl(temporary);
82
+ for (let attempt = 0; attempt < 5; attempt += 1) {
83
+ try {
84
+ renameSync(temporary, path);
85
+ if (mode === 0o600) hardenWindowsAcl(path);
86
+ return;
87
+ } catch (error) {
88
+ if (attempt === 4) throw error;
89
+ if (existsSync(path)) unlinkSync(path);
90
+ }
91
+ }
92
+ }
93
+
94
+ function installFile(source, destination, mode = 0o644) {
95
+ if (existsSync(destination)) {
96
+ console.log(`skip (exists): ${destination}`);
97
+ return false;
98
+ }
99
+ mkdirSync(dirname(destination), { recursive: true });
100
+ copyFileSync(source, destination);
101
+ try { chmodSync(destination, mode); } catch {}
102
+ if (mode === 0o600) hardenWindowsAcl(destination);
103
+ console.log(`created: ${destination}`);
104
+ return true;
105
+ }
106
+
107
+ function parseArgs(args, valued = new Set()) {
108
+ const options = new Map();
109
+ const positional = [];
110
+ for (let index = 0; index < args.length; index += 1) {
111
+ const arg = args[index];
112
+ if (!arg.startsWith("--")) {
113
+ positional.push(arg);
114
+ continue;
115
+ }
116
+ if (valued.has(arg)) {
117
+ if (index + 1 >= args.length) throw new Error(`${arg} requires a value`);
118
+ options.set(arg, args[++index]);
119
+ } else options.set(arg, true);
120
+ }
121
+ return { options, positional };
122
+ }
123
+
124
+ function allowOnly(options, allowed) {
125
+ for (const key of options.keys()) if (!allowed.has(key)) throw new Error(`Unknown option: ${key}`);
126
+ }
127
+
128
+ function git(args, cwd) {
129
+ const result = run("git.exe", args, { cwd });
130
+ if (result.status !== 0) throw new Error((result.stderr || result.stdout || "git failed").trim());
131
+ return result.stdout.trim();
132
+ }
133
+
134
+ function windowsLauncher(harness) {
135
+ return `@echo off\r\npowershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-harness.ps1" ${harness} %*\r\n`;
136
+ }
137
+
138
+ function windowsSetupAssets(harnesses) {
139
+ return Object.freeze([
140
+ ...harnesses.map((harness) => Object.freeze({
141
+ path: `.ai/start-${harness}.cmd`,
142
+ bytes: Buffer.from(windowsLauncher(harness), "utf8"),
143
+ mode: 0o755,
144
+ template: `start-${harness}.cmd.generated`,
145
+ harness,
146
+ })),
147
+ Object.freeze({
148
+ path: ".ai/start-harness.mjs",
149
+ bytes: Buffer.from(launcherModule(), "utf8"),
150
+ mode: 0o755,
151
+ template: "start-harness.mjs.generated",
152
+ }),
153
+ Object.freeze({
154
+ path: ".ai/start-harness.ps1",
155
+ bytes: Buffer.from(launcherPowerShell(), "utf8"),
156
+ mode: 0o600,
157
+ template: "start-harness.ps1.generated",
158
+ }),
159
+ ]);
160
+ }
161
+
162
+ export function launcherPowerShell() {
163
+ return `param(
164
+ [Parameter(Mandatory=$true, Position=0)][string]$Harness,
165
+ [Parameter(ValueFromRemainingArguments=$true)][string[]]$HarnessArgs
166
+ )
167
+ $ErrorActionPreference = 'Stop'
168
+ $AiRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
169
+ $ProjectRoot = Split-Path -Parent $AiRoot
170
+ $EnvFile = Join-Path $AiRoot '.env'
171
+ if (Test-Path -LiteralPath $EnvFile) {
172
+ foreach ($RawLine in Get-Content -LiteralPath $EnvFile) {
173
+ $Line = $RawLine.Trim()
174
+ if (-not $Line -or $Line.StartsWith('#')) { continue }
175
+ if ($Line -match '^(?:export\\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') {
176
+ $Name = $Matches[1]
177
+ if (Test-Path "Env:$Name") { continue }
178
+ $Value = $Matches[2].Trim().Trim('"').Trim("'")
179
+ if ($Value.StartsWith('~/') -or $Value.StartsWith('~\\')) { $Value = Join-Path $HOME $Value.Substring(2) }
180
+ Set-Item -Path "Env:$Name" -Value $Value
181
+ }
182
+ }
183
+ }
184
+ $PreLaunch = Join-Path $AiRoot 'pre-launch.ps1'
185
+ if (Test-Path -LiteralPath $PreLaunch) { . $PreLaunch }
186
+ if ($env:CAIRN_EXTRA_SETTINGS -and $Harness -eq 'claude') {
187
+ $HarnessArgs = @('--settings', $env:CAIRN_EXTRA_SETTINGS) + $HarnessArgs
188
+ }
189
+ if ($env:CAIRN_EXTRA_SETTINGS -and $Harness -eq 'opencode') { $env:OPENCODE_CONFIG = $env:CAIRN_EXTRA_SETTINGS }
190
+ Push-Location $ProjectRoot
191
+ try {
192
+ & $Harness @HarnessArgs
193
+ $Status = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
194
+ } finally {
195
+ Pop-Location
196
+ }
197
+ $env:CAIRN_EXIT_STATUS = [string]$Status
198
+ $PostExit = Join-Path $AiRoot 'post-exit.ps1'
199
+ if (Test-Path -LiteralPath $PostExit) { . $PostExit }
200
+ exit $Status
201
+ `;
202
+ }
203
+
204
+ export function launcherModule() {
205
+ return `#!/usr/bin/env node
206
+ import { existsSync, readFileSync } from "node:fs";
207
+ import { dirname, resolve } from "node:path";
208
+ import { spawnSync } from "node:child_process";
209
+ import { fileURLToPath } from "node:url";
210
+ const ai = dirname(fileURLToPath(import.meta.url));
211
+ const root = resolve(ai, "..");
212
+ const [command, ...args] = process.argv.slice(2);
213
+ const env = { ...process.env };
214
+ const envFile = resolve(ai, ".env");
215
+ if (existsSync(envFile)) for (const raw of readFileSync(envFile, "utf8").split(/\\r?\\n/)) {
216
+ const line = raw.trim(); if (!line || line.startsWith("#")) continue;
217
+ const match = line.match(/^(?:export\\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue;
218
+ if (env[match[1]] !== undefined) continue;
219
+ let value = match[2].trim();
220
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
221
+ env[match[1]] = value.replace(/^~(?=[\\\\/]|$)/, env.USERPROFILE || env.HOME || "~");
222
+ }
223
+ if (!command) { console.error("missing harness command"); process.exit(2); }
224
+ if (env.CAIRN_EXTRA_SETTINGS && command === "claude") args.unshift("--settings", env.CAIRN_EXTRA_SETTINGS);
225
+ if (env.CAIRN_EXTRA_SETTINGS && command === "opencode") env.OPENCODE_CONFIG = env.CAIRN_EXTRA_SETTINGS;
226
+ const result = spawnSync(command, args, { cwd: root, env, stdio: "inherit", shell: false });
227
+ if (result.error) { console.error(result.error.message); process.exit(1); }
228
+ process.exit(result.status ?? 1);
229
+ `;
230
+ }
231
+
232
+ export function bootstrapWindows(root, args) {
233
+ const { options, positional } = parseArgs(args);
234
+ for (const key of options.keys()) if (key !== "--untracked") throw new Error(`Unknown option: ${key}`);
235
+ if (positional.length > 1) throw new Error("Usage: cairn bootstrap [--untracked] [path]");
236
+ const target = resolve(positional[0] ?? ".");
237
+ if (!existsSync(target) || !statSync(target).isDirectory()) throw new Error(`Bootstrap target is not a directory: ${target}`);
238
+
239
+ let excludeFile;
240
+ let prefix = "";
241
+ if (options.has("--untracked")) {
242
+ excludeFile = git(["rev-parse", "--git-path", "info/exclude"], target);
243
+ if (!isAbsolute(excludeFile)) excludeFile = resolve(target, excludeFile);
244
+ prefix = git(["rev-parse", "--show-prefix"], target).replaceAll("\\", "/");
245
+ }
246
+
247
+ const templates = join(root, "templates");
248
+ for (const [source, destination] of BOOTSTRAP_FILES) {
249
+ installFile(join(templates, source), join(target, ...destination.split("/")), destination.endsWith("capabilities.json") ? 0o600 : 0o644);
250
+ }
251
+ for (const harness of HARNESSES) {
252
+ installFile(join(templates, `start-${harness}.sh.template`), join(target, ".ai", `start-${harness}.sh`), 0o755);
253
+ const cmdPath = join(target, ".ai", `start-${harness}.cmd`);
254
+ if (!existsSync(cmdPath)) atomicWrite(cmdPath, windowsLauncher(harness), 0o755);
255
+ }
256
+ const modulePath = join(target, ".ai", "start-harness.mjs");
257
+ if (!existsSync(modulePath)) atomicWrite(modulePath, launcherModule(), 0o755);
258
+ const powershellPath = join(target, ".ai", "start-harness.ps1");
259
+ if (!existsSync(powershellPath)) atomicWrite(powershellPath, launcherPowerShell(), 0o600);
260
+
261
+ if (excludeFile) {
262
+ mkdirSync(dirname(excludeFile), { recursive: true });
263
+ const existing = existsSync(excludeFile) ? readFileSync(excludeFile, "utf8").split(/\r?\n/) : [];
264
+ const additions = [`.ai/`, `.planning/`, `.agentfs/`].map((entry) => `/${prefix}${entry}`);
265
+ for (const entry of additions) if (!existing.includes(entry)) existing.push(entry);
266
+ atomicWrite(excludeFile, `${existing.filter(Boolean).join("\n")}\n`, 0o644);
267
+ }
268
+
269
+ console.log(`\nCairnkeep bootstrapped into ${target}`);
270
+ console.log(`Launch from PowerShell or Command Prompt with ${join(target, ".ai", "start-claude.cmd")}`);
271
+ }
272
+
273
+ function walkFiles(root, predicate = () => true, prefix = "") {
274
+ if (!existsSync(root)) return [];
275
+ const result = [];
276
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
277
+ const rel = prefix ? join(prefix, entry.name) : entry.name;
278
+ if (entry.isDirectory()) result.push(...walkFiles(join(root, entry.name), predicate, rel));
279
+ else if (entry.isFile() && predicate(rel)) result.push(rel);
280
+ }
281
+ return result;
282
+ }
283
+
284
+ function syncFile(source, destination, apply, render = (value) => value) {
285
+ const expected = render(readFileSync(source, "utf8"));
286
+ const matches = existsSync(destination) && readFileSync(destination, "utf8") === expected;
287
+ if (matches) {
288
+ console.log(`ok: ${destination}`);
289
+ return true;
290
+ }
291
+ if (!apply) {
292
+ console.error(`DRIFT: ${destination}`);
293
+ return false;
294
+ }
295
+ atomicWrite(destination, expected, 0o644);
296
+ console.log(`installed: ${destination}`);
297
+ return true;
298
+ }
299
+
300
+ function registerClaudeHook(settingsPath, name, command, event, matcher) {
301
+ let settings = {};
302
+ if (existsSync(settingsPath)) settings = JSON.parse(readFileSync(settingsPath, "utf8"));
303
+ settings.hooks ??= {};
304
+ settings.hooks[event] = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
305
+ const already = settings.hooks[event].some((entry) => entry?.hooks?.some((hook) => hook?.command?.includes(name)));
306
+ if (!already) {
307
+ if (existsSync(settingsPath)) copyFileSync(settingsPath, `${settingsPath}.bak`);
308
+ const hook = { type: "command", command };
309
+ if (name === "context-explore-pretask") hook.timeout = 25;
310
+ settings.hooks[event].push(matcher ? { matcher, hooks: [hook] } : { hooks: [hook] });
311
+ atomicWrite(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
312
+ }
313
+ }
314
+
315
+ function claudeHookRegistered(settingsPath, name, event) {
316
+ if (!existsSync(settingsPath)) return false;
317
+ try {
318
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
319
+ return Array.isArray(settings.hooks?.[event])
320
+ && settings.hooks[event].some((entry) => entry?.hooks?.some((hook) => hook?.command?.includes(name)));
321
+ } catch { return false; }
322
+ }
323
+
324
+ function syncClaude(root, args) {
325
+ const { options, positional } = parseArgs(args, new Set(["--live-root"]));
326
+ allowOnly(options, new Set(["--check", "--apply", "--capability-overlay", "--live-root"]));
327
+ if (positional.length) throw new Error("Usage: cairn sync [--check|--apply] [--live-root DIR]");
328
+ const apply = options.has("--apply");
329
+ const capabilityOverlay = options.has("--capability-overlay") && bool(process.env.CAIRN_CAPABILITY_CONTRACT);
330
+ const live = resolve(expandHome(options.get("--live-root") || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")));
331
+ const source = join(root, "claude");
332
+ let ok = true;
333
+ for (const rel of walkFiles(source, (path) => path.endsWith(".md") && !path.startsWith(`capability-contract${sep}`))) {
334
+ ok = syncFile(join(source, rel), join(live, rel), apply) && ok;
335
+ }
336
+ for (const rel of walkFiles(join(root, "templates"), (path) => /^(security|wiki)-.*\.template$/.test(path))) {
337
+ ok = syncFile(join(root, "templates", rel), join(live, "templates", rel), apply) && ok;
338
+ }
339
+ for (const [name, [event, matcher]] of HOOK_EVENTS) {
340
+ const wrapper = join(live, "hooks", `${name}.cmd`);
341
+ const content = `@echo off\r\nnode "${join(root, "scripts", "cairn-hook.mjs")}" ${name} %*\r\n`;
342
+ const matches = existsSync(wrapper) && readFileSync(wrapper, "utf8") === content;
343
+ if (!matches && apply) atomicWrite(wrapper, content, 0o755);
344
+ else if (!matches) ok = false;
345
+ if (apply) registerClaudeHook(join(live, "settings.json"), name, `"${wrapper}"`, event, matcher);
346
+ else if (!claudeHookRegistered(join(live, "settings.json"), name, event)) {
347
+ console.error(`DRIFT: ${event} hook registration for ${name}`);
348
+ ok = false;
349
+ }
350
+ }
351
+ if (capabilityOverlay) {
352
+ const capabilityHooks = [
353
+ ["capability-command-start", "UserPromptExpansion", "wiki-ingest|wiki-query|wiki-lint|graphify|security-audit"],
354
+ ["capability-command-finish", "Stop", ""],
355
+ ["capability-command-finish", "StopFailure", ""],
356
+ ["capability-command-finish", "CwdChanged", ""],
357
+ ["capability-command-finish", "SessionEnd", ""],
358
+ ];
359
+ for (const [name, event, matcher] of capabilityHooks) {
360
+ const wrapper = join(live, "hooks", `${name}.cmd`);
361
+ const content = `@echo off\r\nnode "${join(root, "scripts", "cairn-hook.mjs")}" ${name} %*\r\n`;
362
+ const matches = existsSync(wrapper) && readFileSync(wrapper, "utf8") === content;
363
+ if (!matches && apply) atomicWrite(wrapper, content, 0o755);
364
+ else if (!matches) ok = false;
365
+ if (apply) registerClaudeHook(join(live, "settings.json"), name, `"${wrapper}"`, event, matcher);
366
+ else if (!claudeHookRegistered(join(live, "settings.json"), name, event)) {
367
+ console.error(`DRIFT: ${event} hook registration for ${name}`);
368
+ ok = false;
369
+ }
370
+ }
371
+ }
372
+ if (!ok) process.exitCode = 1;
373
+ else console.log(`Claude assets are in sync under ${live}`);
374
+ }
375
+
376
+ function syncSimple(root, args, kind) {
377
+ const { options, positional } = parseArgs(args, new Set(["--live-root"]));
378
+ allowOnly(options, new Set(["--check", "--apply", "--live-root"]));
379
+ if (positional.length) throw new Error(`Usage: cairn sync-${kind} [--check|--apply] [--live-root DIR]`);
380
+ const apply = options.has("--apply");
381
+ const config = kind === "pi"
382
+ ? { source: "pi", live: process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), files: ["extensions/cairnkeep-memory.ts", "extensions/cairnkeep-trajectory.ts", "prompts/graphify.md"] }
383
+ : { source: "kimi", live: process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"), files: ["skills/graphify/SKILL.md"] };
384
+ const live = resolve(expandHome(options.get("--live-root") || config.live));
385
+ let ok = true;
386
+ for (const rel of config.files) {
387
+ const render = kind === "pi" && rel.endsWith(".ts") ? (value) => value.replaceAll("@@INFRA_ROOT@@", root.replaceAll("\\", "/")) : undefined;
388
+ ok = syncFile(join(root, config.source, ...rel.split("/")), join(live, ...rel.split("/")), apply, render) && ok;
389
+ }
390
+ if (!ok) process.exitCode = 1;
391
+ else console.log(`${kind === "pi" ? "Pi" : "Kimi"} assets are in sync under ${live}`);
392
+ }
393
+
394
+ function loadProjectEnv(cwd) {
395
+ const path = join(cwd, ".ai", ".env");
396
+ if (!existsSync(path)) return;
397
+ for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
398
+ const match = raw.trim().match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
399
+ if (!match || process.env[match[1]] !== undefined) continue;
400
+ let value = match[2].trim();
401
+ if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
402
+ process.env[match[1]] = expandHome(value);
403
+ }
404
+ }
405
+
406
+ async function doctorWindows(root, args) {
407
+ const repair = args.includes("--repair");
408
+ if (args.some((arg) => !["--repair", "--help", "-h"].includes(arg))) throw new Error("Usage: cairn doctor [--repair]");
409
+ if (args.some((arg) => ["--help", "-h"].includes(arg))) {
410
+ console.log("Usage: cairn doctor [--repair]");
411
+ return;
412
+ }
413
+ loadProjectEnv(process.cwd());
414
+ let failures = 0;
415
+ const line = (state, text) => { console.log(` [${state}] ${text}`); if (state === "FAIL") failures += 1; };
416
+ console.log("cairn doctor");
417
+ const major = Number(process.versions.node.split(".")[0]);
418
+ if (major < 22) line("FAIL", `Node.js ${process.version} is unsupported (22 or newer required)`);
419
+ else line("PASS", `Node.js ${process.version} on native Windows ${process.arch}`);
420
+ const server = join(root, "mcp-memory-server", "dist", "index.js");
421
+ const probe = join(root, "scripts", "probe-memory-server.mjs");
422
+ const probeResult = run(process.execPath, [probe, server], { stdio: "ignore" });
423
+ line(probeResult.status === 0 ? "PASS" : "FAIL", probeResult.status === 0
424
+ ? "bundled local memory server responds over MCP stdio"
425
+ : "memory server failed its MCP stdio probe");
426
+ const store = resolve(expandHome(process.env.CAIRN_AGENTFS_BASE_DIR || join(homedir(), ".cairnkeep")));
427
+ try {
428
+ mkdirSync(store, { recursive: true });
429
+ accessSync(store, fsConstants.W_OK);
430
+ line("PASS", `memory store is writable: ${store}`);
431
+ } catch { line("FAIL", `memory store is not writable: ${store}`); }
432
+ const sqlite = run("where.exe", ["sqlite3.exe"]);
433
+ line(sqlite.status === 0 ? "PASS" : "WARN", sqlite.status === 0
434
+ ? "sqlite3 is available for WAL-safe memory export"
435
+ : "sqlite3 is not installed; runtime works, but memory export is unavailable");
436
+ for (const [label, file, doctorArgs] of [
437
+ ["trajectory", "trajectory-cli.js", ["doctor", "--json", ...(repair ? ["--repair"] : [])]],
438
+ ["artifact", "artifact-cli.js", ["doctor", "--json", ...(repair ? ["--repair"] : [])]],
439
+ ["typed memory", "node-cli.js", ["doctor", "--project-root", process.cwd(), ...(repair ? ["--repair"] : [])]],
440
+ ["capability", "capability-cli.js", ["doctor", "--json"]],
441
+ ["context pack", "context-pack-cli.js", ["doctor", "--json"]],
442
+ ]) {
443
+ const entry = join(root, "mcp-memory-server", "dist", file);
444
+ if (!existsSync(entry)) { line("FAIL", `${label} diagnostics are unavailable`); continue; }
445
+ const result = run(process.execPath, [entry, ...doctorArgs]);
446
+ line(result.status === 0 ? "PASS" : "FAIL", `${label} diagnostics ${result.status === 0 ? "passed" : "failed"}`);
447
+ }
448
+ if (process.env.CAIRN_LLM_API_URL) {
449
+ try {
450
+ await fetch(process.env.CAIRN_LLM_API_URL, { signal: AbortSignal.timeout(5000) });
451
+ line("PASS", `LLM endpoint reachable (${process.env.CAIRN_LLM_API_URL})`);
452
+ } catch { line("FAIL", `LLM endpoint unreachable (${process.env.CAIRN_LLM_API_URL})`); }
453
+ } else line("SKIP", "LLM endpoint unset; search uses deterministic substring fallback");
454
+ if (process.env.CAIRN_MEMORY_EMBEDDING_URL) {
455
+ if (!process.env.CAIRN_LLM_API_KEY || !process.env.CAIRN_MEMORY_EMBEDDING_MODEL) {
456
+ line("FAIL", "embedding configuration is incomplete (API key and model are required)");
457
+ } else {
458
+ try {
459
+ const endpoint = `${process.env.CAIRN_MEMORY_EMBEDDING_URL.replace(/\/$/, "")}/embeddings`;
460
+ const response = await fetch(endpoint, {
461
+ method: "POST",
462
+ headers: { Authorization: `Bearer ${process.env.CAIRN_LLM_API_KEY}`, "Content-Type": "application/json" },
463
+ body: JSON.stringify({ model: process.env.CAIRN_MEMORY_EMBEDDING_MODEL, input: ["cairnkeep health check"] }),
464
+ signal: AbortSignal.timeout(10_000),
465
+ });
466
+ line(response.ok ? "PASS" : "FAIL", `embedding endpoint ${response.ok ? "accepted" : "rejected"} model ${process.env.CAIRN_MEMORY_EMBEDDING_MODEL}`);
467
+ } catch { line("FAIL", `embedding endpoint request failed (${process.env.CAIRN_MEMORY_EMBEDDING_URL})`); }
468
+ }
469
+ } else line("SKIP", "embedding endpoint unset");
470
+ const provider = process.env.CAIRN_GIT_PROVIDER?.trim();
471
+ if (!provider || provider === "none") line("SKIP", "git provider unset/none; collaboration commands are off");
472
+ else if (["github", "gitlab", "codeberg", "forgejo"].includes(provider)) line("PASS", `git provider configured: ${provider}`);
473
+ else line("WARN", `git provider is not recognized: ${provider}`);
474
+ if (failures) process.exitCode = 1;
475
+ }
476
+
477
+ function tarOctal(value, width) {
478
+ return `${value.toString(8).padStart(width - 1, "0")}\0`;
479
+ }
480
+
481
+ export function createTar(files) {
482
+ const blocks = [];
483
+ for (const [name, content] of files) {
484
+ if (!/^[A-Za-z0-9._-]+\.db$/.test(name)) throw new Error(`unsafe archive name: ${name}`);
485
+ const header = Buffer.alloc(512);
486
+ header.write(name, 0, 100, "utf8");
487
+ header.write(tarOctal(0o600, 8), 100, 8, "ascii");
488
+ header.write(tarOctal(0, 8), 108, 8, "ascii");
489
+ header.write(tarOctal(0, 8), 116, 8, "ascii");
490
+ header.write(tarOctal(content.length, 12), 124, 12, "ascii");
491
+ header.write(tarOctal(Math.floor(Date.now() / 1000), 12), 136, 12, "ascii");
492
+ header.fill(0x20, 148, 156);
493
+ header[156] = "0".charCodeAt(0);
494
+ header.write("ustar\0", 257, 6, "ascii");
495
+ header.write("00", 263, 2, "ascii");
496
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
497
+ header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii");
498
+ blocks.push(header, content, Buffer.alloc((512 - (content.length % 512)) % 512));
499
+ }
500
+ blocks.push(Buffer.alloc(1024));
501
+ return Buffer.concat(blocks);
502
+ }
503
+
504
+ export function extractTar(buffer) {
505
+ const files = [];
506
+ const names = new Set();
507
+ for (let offset = 0; offset + 512 <= buffer.length;) {
508
+ const header = buffer.subarray(offset, offset + 512);
509
+ if (header.every((byte) => byte === 0)) break;
510
+ const storedChecksum = Number.parseInt(header.subarray(148, 156).toString("ascii").replace(/\0.*$/, "").trim() || "0", 8);
511
+ const checksumHeader = Buffer.from(header);
512
+ checksumHeader.fill(0x20, 148, 156);
513
+ const calculatedChecksum = checksumHeader.reduce((sum, byte) => sum + byte, 0);
514
+ if (storedChecksum !== calculatedChecksum) throw new Error("archive header checksum is invalid");
515
+ const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
516
+ const size = Number.parseInt(header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim() || "0", 8);
517
+ const type = String.fromCharCode(header[156] || 48);
518
+ if (!/^[A-Za-z0-9._-]+\.db$/.test(name) || basename(name) !== name || type !== "0" || !Number.isSafeInteger(size)) {
519
+ throw new Error("archive contains an unsafe or unsupported entry");
520
+ }
521
+ if (names.has(name)) throw new Error("archive contains duplicate database entries");
522
+ names.add(name);
523
+ offset += 512;
524
+ if (offset + size > buffer.length) throw new Error("archive is truncated");
525
+ files.push([name, Buffer.from(buffer.subarray(offset, offset + size))]);
526
+ offset += Math.ceil(size / 512) * 512;
527
+ }
528
+ return files;
529
+ }
530
+
531
+ function memoryWindows(args) {
532
+ const [sub = "help", archive] = args;
533
+ if (args.length > (sub === "path" || ["help", "-h", "--help"].includes(sub) ? 1 : 2)) throw new Error("Usage: cairn memory path | export <file.tgz> | import <file.tgz>");
534
+ const base = resolve(expandHome(process.env.CAIRN_AGENTFS_BASE_DIR || join(homedir(), ".cairnkeep")));
535
+ if (sub === "path") { console.log(base); return; }
536
+ if (["help", "-h", "--help"].includes(sub)) {
537
+ console.log("Usage: cairn memory path | export <file.tgz> | import <file.tgz>");
538
+ return;
539
+ }
540
+ if (!archive) throw new Error(`cairn memory ${sub} requires an archive path`);
541
+ if (sub === "export") {
542
+ if (!existsSync(base)) throw new Error(`no memory store directory at ${base}`);
543
+ const databases = readdirSync(base).filter((name) => /^[A-Za-z0-9._-]+\.db$/.test(name));
544
+ if (!databases.length) throw new Error(`no scope .db files under ${base}`);
545
+ const scratch = mkdtempSync(join(tmpdir(), "cairn-export-"));
546
+ try {
547
+ const files = [];
548
+ for (const name of databases) {
549
+ const output = join(scratch, name);
550
+ const result = run("sqlite3.exe", [join(base, name), `.backup '${output.replaceAll("'", "''")}'`]);
551
+ if (result.status !== 0) throw new Error("sqlite3 is required for a WAL-safe export");
552
+ files.push([name, readFileSync(output)]);
553
+ }
554
+ writeFileSync(resolve(archive), gzipSync(createTar(files), { level: 9 }));
555
+ console.log(`exported ${files.length} scope db file(s) from ${base} -> ${resolve(archive)}`);
556
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
557
+ return;
558
+ }
559
+ if (sub === "import") {
560
+ const source = resolve(archive);
561
+ if (!existsSync(source)) throw new Error(`no such archive: ${source}`);
562
+ if (statSync(source).size > 512 * 1024 * 1024) throw new Error("memory archive exceeds the 512 MiB compressed limit");
563
+ const files = extractTar(gunzipSync(readFileSync(source), { maxOutputLength: 512 * 1024 * 1024 }));
564
+ if (!files.length) throw new Error("archive contained no scope .db files");
565
+ mkdirSync(base, { recursive: true });
566
+ for (const [name, content] of files) {
567
+ const destination = join(base, name);
568
+ if (existsSync(destination)) copyFileSync(destination, `${destination}.bak-pre-import`);
569
+ atomicWrite(destination, content, 0o600);
570
+ }
571
+ console.log(`imported ${files.length} scope db file(s) -> ${base}`);
572
+ return;
573
+ }
574
+ throw new Error(`Unknown: cairn memory ${sub}`);
575
+ }
576
+
577
+ function auditTimerWindows(root, args) {
578
+ const { options, positional } = parseArgs(args, new Set(["--on-calendar", "--para-root", "--render-only"]));
579
+ allowOnly(options, new Set(["--on-calendar", "--para-root", "--render-only"]));
580
+ if (positional.length) throw new Error("Usage: cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]");
581
+ const interval = options.get("--on-calendar") || "DAILY";
582
+ const para = resolve(expandHome(options.get("--para-root") || join(homedir(), "PARA")));
583
+ const command = `node "${join(root, "scripts", "memory-wiki-audit.mjs")}" --para-root "${para}"`;
584
+ if (options.get("--render-only")) {
585
+ const destination = resolve(options.get("--render-only"));
586
+ mkdirSync(destination, { recursive: true });
587
+ atomicWrite(join(destination, "CairnkeepMemoryAudit.ps1"), `& ${command}\r\n`, 0o600);
588
+ console.log(`rendered Windows audit task command under ${destination}`);
589
+ return;
590
+ }
591
+ const schedule = /^(DAILY|WEEKLY|MONTHLY|ONLOGON)$/i.test(interval) ? interval.toUpperCase() : "DAILY";
592
+ const result = run("schtasks.exe", ["/Create", "/F", "/TN", "Cairnkeep Memory Audit", "/SC", schedule, "/TR", command]);
593
+ if (result.status !== 0) throw new Error((result.stderr || "Windows Task Scheduler registration failed").trim());
594
+ console.log("installed Windows Task Scheduler task: Cairnkeep Memory Audit");
595
+ }
596
+
597
+ function uninstallWindows(root, args) {
598
+ const { options, positional: projects } = parseArgs(args, new Set(["--live-root", "--pi-live-root", "--kimi-live-root"]));
599
+ allowOnly(options, new Set(["--dry-run", "--yes", "--purge-memory", "--purge-packs", "--live-root", "--pi-live-root", "--kimi-live-root"]));
600
+ const dryRun = options.has("--dry-run");
601
+ if (!dryRun && !options.has("--yes")) throw new Error("native Windows uninstall requires --yes (or use --dry-run)");
602
+ const targets = [];
603
+ const live = resolve(expandHome(options.get("--live-root") || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")));
604
+ for (const rel of walkFiles(join(root, "claude"), (path) => path.endsWith(".md") && !path.startsWith(`capability-contract${sep}`))) targets.push(join(live, rel));
605
+ for (const rel of walkFiles(join(root, "templates"), (path) => /^(security|wiki)-.*\.template$/.test(path))) targets.push(join(live, "templates", rel));
606
+ for (const name of HOOK_EVENTS.keys()) targets.push(join(live, "hooks", `${name}.cmd`));
607
+ targets.push(join(live, "hooks", "capability-command-start.cmd"), join(live, "hooks", "capability-command-finish.cmd"));
608
+ const piLive = resolve(expandHome(options.get("--pi-live-root") || process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent")));
609
+ targets.push(
610
+ join(piLive, "extensions", "cairnkeep-memory.ts"),
611
+ join(piLive, "extensions", "cairnkeep-trajectory.ts"),
612
+ join(piLive, "prompts", "graphify.md"),
613
+ );
614
+ const kimiLive = resolve(expandHome(options.get("--kimi-live-root") || process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code")));
615
+ targets.push(join(kimiLive, "skills", "graphify", "SKILL.md"));
616
+ for (const project of projects) {
617
+ const base = resolve(project);
618
+ for (const [, rel] of BOOTSTRAP_FILES) targets.push(join(base, ...rel.split("/")));
619
+ for (const harness of HARNESSES) targets.push(join(base, ".ai", `start-${harness}.cmd`));
620
+ targets.push(join(base, ".ai", "start-harness.mjs"));
621
+ targets.push(join(base, ".ai", "start-harness.ps1"));
622
+ if (options.has("--purge-memory")) targets.push(join(base, ".agentfs"));
623
+ }
624
+ if (options.has("--purge-memory")) targets.push(resolve(expandHome(process.env.CAIRN_AGENTFS_BASE_DIR || join(homedir(), ".cairnkeep"))));
625
+ if (options.has("--purge-packs")) targets.push(resolve(expandHome(process.env.CAIRN_PACK_BASE_DIR || join(homedir(), ".cairnkeep", "packs"))));
626
+ const candidates = [...new Set(targets)].filter(existsSync);
627
+ const existing = candidates.filter((target) => !candidates.some((parent) => parent !== target && target.toLowerCase().startsWith(`${parent.toLowerCase()}${sep}`)));
628
+ if (dryRun) { for (const target of existing) console.log(`would remove: ${target}`); return; }
629
+ const backup = join(homedir(), "cairnkeep-uninstall-backups", new Date().toISOString().replace(/[:.]/g, "-"));
630
+ mkdirSync(backup, { recursive: true });
631
+ hardenWindowsAcl(backup);
632
+ const manifest = [];
633
+ const backupTarget = (target) => {
634
+ const destination = join(backup, "items", String(manifest.length));
635
+ mkdirSync(dirname(destination), { recursive: true });
636
+ if (lstatSync(target).isDirectory()) cpSync(target, destination, { recursive: true, errorOnExist: false });
637
+ else copyFileSync(target, destination);
638
+ manifest.push({ source: join("items", String(manifest.length)), destination: target, directory: lstatSync(target).isDirectory() });
639
+ };
640
+ const settingsPath = join(live, "settings.json");
641
+ if (existsSync(settingsPath)) {
642
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
643
+ let changed = false;
644
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
645
+ if (!Array.isArray(entries)) continue;
646
+ const filtered = entries.map((entry) => {
647
+ if (!Array.isArray(entry?.hooks)) return entry;
648
+ const hooks = entry.hooks.filter((hook) => !/memory-wakeup|memory-capture|compaction-capture|memory-recall|context-explore-pretask|capability-command-(?:start|finish)/.test(hook?.command ?? ""));
649
+ if (hooks.length !== entry.hooks.length) changed = true;
650
+ return { ...entry, hooks };
651
+ }).filter((entry) => entry.hooks?.length !== 0);
652
+ settings.hooks[event] = filtered;
653
+ }
654
+ if (changed) {
655
+ backupTarget(settingsPath);
656
+ atomicWrite(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
657
+ console.log(`updated: ${settingsPath}`);
658
+ }
659
+ }
660
+ for (const target of existing) {
661
+ backupTarget(target);
662
+ rmSync(target, { recursive: true, force: true });
663
+ console.log(`removed: ${target}`);
664
+ }
665
+ atomicWrite(join(backup, "manifest.json"), `${JSON.stringify({ schema_version: 1, items: manifest }, null, 2)}\n`, 0o600);
666
+ atomicWrite(join(backup, "revert.ps1"), `$ErrorActionPreference = 'Stop'
667
+ $Manifest = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'manifest.json') -Raw | ConvertFrom-Json
668
+ foreach ($Item in $Manifest.items) {
669
+ $Source = Join-Path $PSScriptRoot $Item.source
670
+ $Parent = Split-Path -Parent $Item.destination
671
+ New-Item -ItemType Directory -Force -Path $Parent | Out-Null
672
+ if ($Item.directory) { Copy-Item -LiteralPath $Source -Destination $Item.destination -Recurse -Force }
673
+ else { Copy-Item -LiteralPath $Source -Destination $Item.destination -Force }
674
+ Set-Acl -LiteralPath $Item.destination -AclObject (Get-Acl -LiteralPath $Source)
675
+ if ($Item.directory) {
676
+ Get-ChildItem -LiteralPath $Source -Recurse -Force | ForEach-Object {
677
+ $Relative = $_.FullName.Substring($Source.Length).TrimStart('\\')
678
+ $Restored = Join-Path $Item.destination $Relative
679
+ Set-Acl -LiteralPath $Restored -AclObject (Get-Acl -LiteralPath $_.FullName)
680
+ }
681
+ }
682
+ }
683
+ `, 0o600);
684
+ console.log(`backup retained at ${backup}`);
685
+ }
686
+
687
+ export function powershellCompletion() {
688
+ return `Register-ArgumentCompleter -Native -CommandName cairn -ScriptBlock {
689
+ param($wordToComplete, $commandAst, $cursorPosition)
690
+ $commands = 'bootstrap','setup','memory-server','sync','sync-pi','sync-kimi','doctor','trajectory','artifact','capabilities','mcp-tools','pack','notes','eval','skill','graph','memory','audit-timer','uninstall','completion','version','help'
691
+ $setup = '--git','--harness','--memory','--policy','--yes','--json','init','existing','none','claude','opencode','pi','kimi','qwen','local'
692
+ $candidates = if ($commandAst.ToString() -match '^\\s*cairn\\s+setup(?:\\s|$)') { $setup } else { $commands }
693
+ $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
694
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
695
+ }
696
+ }\n`;
697
+ }
698
+
699
+ export async function runWindowsCommand({ command, args, root }) {
700
+ switch (command) {
701
+ case "bootstrap": bootstrapWindows(root, args); return true;
702
+ case "setup": {
703
+ const { runSetup } = await import("./setup.mjs");
704
+ process.exitCode = await runSetup(args, {
705
+ platform: "win32",
706
+ augmentPlan: (plan) => Object.freeze({
707
+ ...plan,
708
+ assets: Object.freeze([...plan.assets, ...windowsSetupAssets(plan.harnesses)]),
709
+ }),
710
+ });
711
+ return true;
712
+ }
713
+ case "sync": syncClaude(root, args); return true;
714
+ case "sync-pi": syncSimple(root, args, "pi"); return true;
715
+ case "sync-kimi": syncSimple(root, args, "kimi"); return true;
716
+ case "doctor": await doctorWindows(root, args); return true;
717
+ case "memory": memoryWindows(args); return true;
718
+ case "audit-timer": auditTimerWindows(root, args); return true;
719
+ case "uninstall": uninstallWindows(root, args); return true;
720
+ case "completion":
721
+ if (args[0] !== "powershell") throw new Error("native Windows completion supports PowerShell: cairn completion powershell");
722
+ process.stdout.write(powershellCompletion()); return true;
723
+ default: return false;
724
+ }
725
+ }