@cairnkeep/cli 2.11.0 → 2.12.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 (43) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +31 -6
  3. package/docs/building-an-overlay.md +1 -1
  4. package/docs/harness-compatibility.md +34 -1
  5. package/docs/learning/CURRICULUM-MAP.md +8 -7
  6. package/docs/learning/FEATURE-GUIDE.md +9 -4
  7. package/docs/learning/README.md +3 -2
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +16 -9
  11. package/docs/learning/lessons/L03-first-project.md +34 -15
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +9 -9
  16. package/docs/learning/lessons/L23-guided-setup.md +6 -2
  17. package/docs/learning/tracks/quickstart.md +5 -4
  18. package/docs/native-windows.md +14 -0
  19. package/docs/operating.md +26 -5
  20. package/docs/privacy-and-data-flow.md +8 -1
  21. package/docs/quickstart.md +86 -0
  22. package/examples/eval/bundled-fake.json +1 -1
  23. package/package.json +2 -1
  24. package/schemas/cairnkeep-setup-policy.schema.json +3 -3
  25. package/schemas/cairnkeep-setup.schema.json +2 -2
  26. package/scripts/bootstrap.sh +7 -15
  27. package/scripts/completion.sh +8 -6
  28. package/scripts/doctor.sh +5 -4
  29. package/scripts/harness-registry.mjs +126 -0
  30. package/scripts/setup-core.mjs +4 -2
  31. package/scripts/setup-reconcile.mjs +3 -2
  32. package/scripts/setup.mjs +41 -5
  33. package/scripts/test-bootstrap-untracked.sh +2 -1
  34. package/scripts/test-completion.sh +2 -0
  35. package/scripts/test-harness-registry.sh +94 -0
  36. package/scripts/test-package-install.sh +5 -0
  37. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  38. package/scripts/test-setup-compatibility.mjs +5 -4
  39. package/scripts/test-windows-native.mjs +24 -1
  40. package/scripts/uninstall.sh +7 -3
  41. package/scripts/windows-platform.mjs +13 -5
  42. package/templates/codex-config.toml.template +5 -0
  43. package/templates/start-codex.sh.template +30 -0
@@ -55,6 +55,7 @@ git -C "$tmp/repo" init -q
55
55
  [[ -f "$tmp/repo/.ai/start-claude.sh" ]] || fail "scaffold missing"
56
56
  [[ -x "$tmp/repo/.ai/start-kimi.sh" ]] || fail "Kimi launcher missing"
57
57
  [[ -x "$tmp/repo/.ai/start-qwen.sh" ]] || fail "Qwen launcher missing"
58
+ [[ -x "$tmp/repo/.ai/start-codex.sh" ]] || fail "Codex launcher missing"
58
59
  [[ -f "$tmp/repo/.planning/config.json" ]] || fail "planning layer missing"
59
60
  graph_policy="$tmp/repo/.planning/graphs/policy.md"
60
61
  grep -qF '`uv tool install graphifyy`' "$graph_policy" ||
@@ -89,7 +90,7 @@ grep -qxF "/.agentfs/" "$tmp/repo/.git/info/exclude" || fail "missing /.agentfs/
89
90
  printf '%s\n' '{"schema_version":1,"capabilities":{"wiki":false},"logging":{"callbacks":true}}' >"$tmp/repo/.ai/capabilities.json"
90
91
  chmod 600 "$tmp/repo/.ai/capabilities.json"
91
92
  printf '%s\n' '# operator-owned environment' >"$tmp/repo/.ai/env.example"
92
- asset_paths='.ai/start-claude.sh .ai/start-opencode.sh .ai/start-pi.sh .ai/start-kimi.sh .ai/start-qwen.sh .ai/env.example .ai/trajectory-redaction.json .ai/capabilities.json .agentfs/.gitignore .planning/config.json .planning/wiki/index.md .planning/wiki/policy.md .planning/wiki/CONTRADICTIONS.md .planning/wiki/LOG.md .planning/alignment/policy.md .planning/alignment/gap-register.yaml .planning/graphs/policy.md .planning/graphs/.gitignore .planning/security/policy.md'
93
+ asset_paths='.ai/start-claude.sh .ai/start-opencode.sh .ai/start-pi.sh .ai/start-kimi.sh .ai/start-qwen.sh .ai/start-codex.sh .ai/env.example .ai/trajectory-redaction.json .ai/capabilities.json .agentfs/.gitignore .planning/config.json .planning/wiki/index.md .planning/wiki/policy.md .planning/wiki/CONTRADICTIONS.md .planning/wiki/LOG.md .planning/alignment/policy.md .planning/alignment/gap-register.yaml .planning/graphs/policy.md .planning/graphs/.gitignore .planning/security/policy.md'
93
94
  mkdir -p "$tmp/assets-before"
94
95
  for path in $asset_paths; do
95
96
  cp "$tmp/repo/$path" "$tmp/assets-before/${path//\//__}"
@@ -87,10 +87,12 @@ grep -q '#compdef cairn' "$tmp/zsh"
87
87
  grep -q 'complete -c cairn' "$tmp/fish"
88
88
  for shell in bash zsh; do
89
89
  grep -q 'sync-pi' "$tmp/$shell"
90
+ grep -q 'codex' "$tmp/$shell"
90
91
  grep -q 'sync-kimi' "$tmp/$shell"
91
92
  grep -q -- '--live-root' "$tmp/$shell"
92
93
  done
93
94
  grep -q 'sync-pi' "$tmp/fish"
95
+ grep -q 'codex' "$tmp/fish"
94
96
  grep -q 'sync-kimi' "$tmp/fish"
95
97
  grep -q 'not __fish_seen_subcommand_from .*sync-pi sync-kimi' "$tmp/fish"
96
98
  grep -q -- '-l live-root' "$tmp/fish"
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
5
+ tmp=$(mktemp -d)
6
+ trap 'rm -rf "$tmp"' EXIT
7
+
8
+ node --input-type=module - "$ROOT" "$tmp" <<'NODE'
9
+ import assert from "node:assert/strict";
10
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { spawnSync } from "node:child_process";
13
+ import { pathToFileURL } from "node:url";
14
+
15
+ const [root, sandbox] = process.argv.slice(2);
16
+ const registry = await import(pathToFileURL(join(root, "scripts", "harness-registry.mjs")).href);
17
+ assert.deepEqual(registry.HARNESS_IDS, ["claude", "opencode", "pi", "kimi", "qwen", "codex"]);
18
+ assert.equal(new Set(registry.HARNESS_IDS).size, registry.HARNESS_IDS.length);
19
+ for (const id of registry.HARNESS_IDS) {
20
+ const definition = registry.harnessDefinition(id);
21
+ assert.equal(definition.id, id);
22
+ assert.ok(definition.title);
23
+ assert.match(definition.launcher.path, new RegExp(`^\\.ai/start-${id}\\.sh$`));
24
+ }
25
+ assert.deepEqual(registry.harnessProjectAssets("codex", "local").map(({ path }) => path), [
26
+ ".ai/start-codex.sh",
27
+ ".codex/config.toml",
28
+ ]);
29
+ assert.deepEqual(registry.requiredHarnessAssetPaths(["codex"], "local"), [".ai/start-codex.sh"]);
30
+ assert.deepEqual(registry.requiredHarnessAssetPaths(["codex"], "none"), [".ai/start-codex.sh"]);
31
+ assert.equal(registry.machineSyncCommand(["codex"]), null);
32
+
33
+ const schema = JSON.parse(readFileSync(join(root, "schemas", "cairnkeep-setup.schema.json"), "utf8"));
34
+ assert.deepEqual(schema.properties.harnesses.items.enum, registry.HARNESS_IDS, "setup schema drifted from harness registry");
35
+ const policySchema = JSON.parse(readFileSync(join(root, "schemas", "cairnkeep-setup-policy.schema.json"), "utf8"));
36
+ assert.deepEqual(policySchema.properties.defaults.properties.harnesses.items.enum, registry.HARNESS_IDS);
37
+ assert.deepEqual(policySchema.properties.constraints.properties.harnesses.items.enum, registry.HARNESS_IDS);
38
+ assert.deepEqual(policySchema.properties.constraints.properties.required_harnesses.items.enum, registry.HARNESS_IDS);
39
+
40
+ const target = join(sandbox, "codex-project");
41
+ const setup = spawnSync(join(root, "bin", "cairn"), [
42
+ "setup", target, "--git", "init", "--harness", "codex", "--memory", "local", "--yes", "--json",
43
+ ], { encoding: "utf8", shell: false });
44
+ assert.equal(setup.status, 0, setup.stderr);
45
+ const result = JSON.parse(setup.stdout);
46
+ assert.deepEqual(result.harnesses, ["codex"]);
47
+ assert.equal(result.machine_sync.command, null);
48
+ assert.deepEqual(result.launch_commands, [".ai/start-codex.sh"]);
49
+ assert.match(readFileSync(join(target, ".codex", "config.toml"), "utf8"), /\[mcp_servers\.cairn-memory\][\s\S]*command = "cairn"[\s\S]*args = \["memory-server"\]/);
50
+
51
+ const fakeBin = join(sandbox, "bin");
52
+ mkdirSync(fakeBin);
53
+ const fakeCodex = join(fakeBin, "codex");
54
+ writeFileSync(fakeCodex, "#!/bin/sh\nprintf '%s\\n' \"$PWD\" \"${CAIRN_REGISTRY_TEST:-}\" \"$*\"\n");
55
+ chmodSync(fakeCodex, 0o755);
56
+ writeFileSync(join(target, ".ai", ".env"), "CAIRN_REGISTRY_TEST=loaded\n", { mode: 0o600 });
57
+ const launch = spawnSync(join(target, ".ai", "start-codex.sh"), ["--fixture"], {
58
+ encoding: "utf8",
59
+ shell: false,
60
+ env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}` },
61
+ });
62
+ assert.equal(launch.status, 0, launch.stderr);
63
+ assert.deepEqual(launch.stdout.trim().split("\n"), [target, "loaded", "--fixture"]);
64
+
65
+ const noMemory = join(sandbox, "codex-no-memory");
66
+ const noMemorySetup = spawnSync(join(root, "bin", "cairn"), [
67
+ "setup", noMemory, "--git", "init", "--harness", "codex", "--memory", "none", "--yes",
68
+ ], { encoding: "utf8", shell: false });
69
+ assert.equal(noMemorySetup.status, 0, noMemorySetup.stderr);
70
+ assert.throws(() => readFileSync(join(noMemory, ".codex", "config.toml")), /ENOENT/);
71
+
72
+ const collision = join(sandbox, "codex-existing-config");
73
+ mkdirSync(join(collision, ".codex"), { recursive: true });
74
+ writeFileSync(join(collision, ".codex", "config.toml"), "[features]\nfixture = true\n");
75
+ const collisionSetup = spawnSync(join(root, "bin", "cairn"), [
76
+ "setup", collision, "--git", "init", "--harness", "codex", "--memory", "local", "--yes", "--json",
77
+ ], { encoding: "utf8", shell: false });
78
+ assert.equal(collisionSetup.status, 0, collisionSetup.stderr);
79
+ assert.equal(JSON.parse(collisionSetup.stdout).counts.skipped, 1);
80
+ assert.equal(readFileSync(join(collision, ".codex", "config.toml"), "utf8"), "[features]\nfixture = true\n");
81
+ const setupModule = await import(pathToFileURL(join(root, "scripts", "setup.mjs")).href);
82
+ assert.equal(setupModule.diagnoseSetup(collision).status, "incomplete");
83
+ writeFileSync(join(collision, ".codex", "config.toml"), [
84
+ "[features]", "fixture = true", "", "[mcp_servers.cairn-memory]", 'command = "cairn"', 'args = ["memory-server"]', "",
85
+ ].join("\n"));
86
+ assert.equal(setupModule.diagnoseSetup(collision).status, "complete");
87
+ const uninstall = spawnSync(join(root, "bin", "cairn"), ["uninstall", "--yes", collision], {
88
+ encoding: "utf8", shell: false, env: { ...process.env, HOME: join(sandbox, "home") },
89
+ });
90
+ assert.equal(uninstall.status, 0, uninstall.stderr);
91
+ assert.match(readFileSync(join(collision, ".codex", "config.toml"), "utf8"), /fixture = true/);
92
+ NODE
93
+
94
+ echo "PASS: declarative harness registry and first-class Codex setup contract"
@@ -46,6 +46,7 @@ bootstrap_output=$(cairn bootstrap "$tmp/project")
46
46
  [[ -x "$tmp/project/.ai/start-pi.sh" ]] || fail "bootstrap did not install an executable Pi launcher"
47
47
  [[ -x "$tmp/project/.ai/start-kimi.sh" ]] || fail "bootstrap did not install an executable Kimi launcher"
48
48
  [[ -x "$tmp/project/.ai/start-qwen.sh" ]] || fail "bootstrap did not install an executable Qwen launcher"
49
+ [[ -x "$tmp/project/.ai/start-codex.sh" ]] || fail "bootstrap did not install an executable Codex launcher"
49
50
  [[ -f "$tmp/project/.planning/config.json" ]] || fail "bootstrap did not install the planning scaffold"
50
51
 
51
52
  (cd "$tmp/project" && cairn doctor) >/dev/null || fail "installed package failed cairn doctor"
@@ -267,6 +268,10 @@ NODE
267
268
  fail "npm tarball omitted the Kimi launcher template"
268
269
  [[ -f "$installed_root/templates/start-qwen.sh.template" ]] || \
269
270
  fail "npm tarball omitted the Qwen launcher template"
271
+ [[ -f "$installed_root/templates/start-codex.sh.template" ]] || \
272
+ fail "npm tarball omitted the Codex launcher template"
273
+ [[ -f "$installed_root/templates/codex-config.toml.template" ]] || \
274
+ fail "npm tarball omitted the Codex MCP configuration template"
270
275
  [[ -f "$installed_root/schemas/note.schema.json" ]] || \
271
276
  fail "npm tarball omitted the note schema"
272
277
  [[ -f "$installed_root/schemas/memory-node.schema.json" ]] || \
@@ -336,8 +336,18 @@ const cancelTask={...tasks[0],id:"task-cancel",input:"cancel"};
336
336
  const cancelPlan=makePlan([cancelTask]);
337
337
  const cancelStore=await reports.createEvalReportStore({root:join(fixtureRoot,"reports"),experiment_id:"cancel-fixture"});
338
338
  const controller=new AbortController();
339
- setTimeout(()=>controller.abort(),100);
340
- const cancelled=await runner.runTwoPassExperiment({plan:cancelPlan,report_store:cancelStore,temporary_root:fixtureRoot,signal:controller.signal,distill_command:{program:process.execPath,args:[distillerScript]}});
339
+ const cancellation=runner.runTwoPassExperiment({plan:cancelPlan,report_store:cancelStore,temporary_root:fixtureRoot,signal:controller.signal,distill_command:{program:process.execPath,args:[distillerScript]}});
340
+ let adapterStarted=false;
341
+ for (let attempt=0; attempt<200; attempt+=1) {
342
+ await new Promise((resolve)=>setTimeout(resolve,25));
343
+ if (existsSync(invocationLog) && readFileSync(invocationLog,"utf8").includes("task-cancel:run1")) {
344
+ adapterStarted=true;
345
+ break;
346
+ }
347
+ }
348
+ assert.equal(adapterStarted,true,"cancellation fixture adapter did not start");
349
+ controller.abort();
350
+ const cancelled=await cancellation;
341
351
  assert.equal(cancelled.report.status,"partial");
342
352
  assert.deepEqual(cancelled.report.observations.map(({terminal_state})=>terminal_state),["cancelled"]);
343
353
  assert.equal(cancelled.report.observations[0].process.cleanup!=="pending",true);
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
18
18
  import { dirname, join, relative, resolve } from "node:path";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
20
  import { bootstrapWindows } from "./windows-platform.mjs";
21
+ import { HARNESS_IDS } from "./harness-registry.mjs";
21
22
 
22
23
  const EXPECTED_RED_EXIT = 86;
23
24
  const RED_MARKER = "PHASE26_RED:SETUP_RECONCILE_MISSING";
@@ -25,8 +26,8 @@ const here = dirname(fileURLToPath(import.meta.url));
25
26
  const root = resolve(here, "..");
26
27
  const setupCorePath = join(here, "setup-core.mjs");
27
28
  const setupReconcilePath = join(here, "setup-reconcile.mjs");
28
- const HARNESSES = ["claude", "opencode", "pi", "kimi", "qwen"];
29
- const MANAGED_ROOTS = [".ai", ".planning", ".agentfs"];
29
+ const HARNESSES = HARNESS_IDS;
30
+ const MANAGED_ROOTS = [".ai", ".codex", ".planning", ".agentfs"];
30
31
 
31
32
  function run(executable, args, options = {}) {
32
33
  const result = spawnSync(executable, args, {
@@ -141,9 +142,9 @@ async function testSetupCompatibility(production, sandbox, legacy) {
141
142
  choices: Object.freeze({ git: "existing", harnesses: HARNESSES, memory: "local", confirmed: true }),
142
143
  });
143
144
  const result = await production.reconcile.reconcileSetupPlan(plan);
144
- assert.deepEqual(result.counts, { created: legacy.posixBefore.size, updated: 0, unchanged: 0, skipped: 0 });
145
+ assert.deepEqual(result.counts, { created: legacy.posixBefore.size + 1, updated: 0, unchanged: 0, skipped: 0 });
145
146
  const current = snapshot(target);
146
- assertSnapshotEqual(current, legacy.posixBefore, "deterministic setup", [".ai/cairnkeep.json"]);
147
+ assertSnapshotEqual(current, legacy.posixBefore, "deterministic setup", [".ai/cairnkeep.json", ".codex/config.toml"]);
147
148
  assert.ok(current.has(".ai/cairnkeep.json"), "setup state missing");
148
149
  const state = JSON.parse(current.get(".ai/cairnkeep.json").bytes.toString("utf8"));
149
150
  assert.deepEqual(state.harnesses, HARNESSES);
@@ -16,6 +16,7 @@ import {
16
16
  runWindowsCommand,
17
17
  } from "./windows-platform.mjs";
18
18
  import { runNativeContainer } from "./cairn-container-cli.mjs";
19
+ import { HARNESS_IDS } from "./harness-registry.mjs";
19
20
 
20
21
  const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
21
22
  const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
@@ -28,7 +29,7 @@ try {
28
29
  const project = join(sandbox, "Project with spaces – Unicode");
29
30
  mkdirSync(project, { recursive: true });
30
31
  bootstrapWindows(root, [project]);
31
- for (const harness of ["claude", "opencode", "pi", "kimi", "qwen"]) {
32
+ for (const harness of HARNESS_IDS) {
32
33
  assert.ok(existsSync(join(project, ".ai", `start-${harness}.cmd`)), `${harness} cmd launcher`);
33
34
  }
34
35
  assert.match(readFileSync(join(project, ".ai", "start-claude.cmd"), "utf8"), /start-harness\.ps1/);
@@ -125,6 +126,28 @@ try {
125
126
  assert.match(powershellCompletion(), /--policy/);
126
127
  assert.match(powershellCompletion(), /--yes/);
127
128
  assert.match(powershellCompletion(), /--json/);
129
+ assert.match(powershellCompletion(), /codex/);
130
+
131
+ const codexProject = join(sandbox, "Guided Codex Project");
132
+ let codexOutput = "";
133
+ process.stdout.write = ((chunk) => {
134
+ codexOutput += String(chunk);
135
+ return true;
136
+ });
137
+ try {
138
+ await runWindowsCommand({
139
+ command: "setup",
140
+ args: [codexProject, "--git", "none", "--harness", "codex", "--memory", "local", "--yes", "--json"],
141
+ root,
142
+ });
143
+ } finally {
144
+ process.stdout.write = originalWrite;
145
+ }
146
+ const codexResult = JSON.parse(codexOutput);
147
+ assert.deepEqual(codexResult.harnesses, ["codex"]);
148
+ assert.equal(codexResult.machine_sync.command, null);
149
+ assert.ok(existsSync(join(codexProject, ".ai", "start-codex.cmd")));
150
+ assert.match(readFileSync(join(codexProject, ".codex", "config.toml"), "utf8"), /mcp_servers\.cairn-memory/);
128
151
  console.log("PASS: simulated Windows setup parity, private state, recovery, and completion contract");
129
152
  }
130
153
  } finally {
@@ -45,6 +45,7 @@ PROJECT_AI_FILES=(
45
45
  start-pi.sh
46
46
  start-kimi.sh
47
47
  start-qwen.sh
48
+ start-codex.sh
48
49
  env.example
49
50
  trajectory-redaction.json
50
51
  capabilities.json
@@ -129,9 +130,11 @@ remove_path() {
129
130
  # record. The record identifies candidates, but never grants authority to an
130
131
  # arbitrary path. Modified candidates are still backed up before removal.
131
132
  setup_owned_paths() {
132
- node --input-type=module - "$1" <<'NODE'
133
+ node --input-type=module - "$1" "$CAIRN_ROOT/scripts/harness-registry.mjs" <<'NODE'
133
134
  import { lstatSync, readFileSync } from "node:fs";
135
+ import { pathToFileURL } from "node:url";
134
136
  const path = process.argv[2];
137
+ const registry = await import(pathToFileURL(process.argv[3]).href);
135
138
  const info = lstatSync(path);
136
139
  if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) process.exit(1);
137
140
  if (process.platform !== "win32" && (info.mode & 0o777) !== 0o600) process.exit(1);
@@ -153,7 +156,8 @@ const allowed = new Set([
153
156
  ".planning/wiki/LOG.md", ".planning/alignment/policy.md", ".planning/alignment/gap-register.yaml",
154
157
  ".planning/graphs/policy.md", ".planning/graphs/.gitignore", ".planning/security/policy.md",
155
158
  ".ai/start-harness.mjs", ".ai/start-harness.ps1",
156
- ...["claude", "opencode", "pi", "kimi", "qwen"].flatMap((name) => [`.ai/start-${name}.sh`, `.ai/start-${name}.cmd`]),
159
+ ...registry.HARNESS_IDS.flatMap((name) => [`.ai/start-${name}.sh`, `.ai/start-${name}.cmd`]),
160
+ ...registry.HARNESS_IDS.flatMap((name) => registry.harnessProjectAssets(name, "local").map(({ path }) => path)),
157
161
  ]);
158
162
  for (const [asset, record] of Object.entries(state.assets)) {
159
163
  if (!allowed.has(asset)
@@ -321,7 +325,7 @@ for proj in "${PROJECTS[@]:-}"; do
321
325
  if [[ $DRY_RUN -eq 0 ]]; then
322
326
  for managed_dir in \
323
327
  "$proj/.planning/wiki" "$proj/.planning/alignment" "$proj/.planning/graphs" \
324
- "$proj/.planning/security" "$proj/.planning" "$proj/.ai" "$proj/.agentfs"; do
328
+ "$proj/.planning/security" "$proj/.planning" "$proj/.codex" "$proj/.ai" "$proj/.agentfs"; do
325
329
  rmdir "$managed_dir" 2>/dev/null || true
326
330
  done
327
331
  fi
@@ -20,6 +20,7 @@ import { gzipSync, gunzipSync } from "node:zlib";
20
20
  import { spawnSync } from "node:child_process";
21
21
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
22
22
  import { homedir, tmpdir } from "node:os";
23
+ import { HARNESS_IDS, harnessProjectAssets } from "./harness-registry.mjs";
23
24
 
24
25
  const BOOTSTRAP_FILES = [
25
26
  ["env.example.template", ".ai/env.example"],
@@ -39,7 +40,7 @@ const BOOTSTRAP_FILES = [
39
40
  ["security-policy.md.template", ".planning/security/policy.md"],
40
41
  ];
41
42
 
42
- const HARNESSES = ["claude", "opencode", "pi", "kimi", "qwen"];
43
+ const HARNESSES = HARNESS_IDS;
43
44
  const HOOK_EVENTS = new Map([
44
45
  ["memory-wakeup", ["SessionStart", ""]],
45
46
  ["memory-capture", ["SessionEnd", ""]],
@@ -249,7 +250,8 @@ export function bootstrapWindows(root, args) {
249
250
  installFile(join(templates, source), join(target, ...destination.split("/")), destination.endsWith("capabilities.json") ? 0o600 : 0o644);
250
251
  }
251
252
  for (const harness of HARNESSES) {
252
- installFile(join(templates, `start-${harness}.sh.template`), join(target, ".ai", `start-${harness}.sh`), 0o755);
253
+ const asset = harnessProjectAssets(harness, "local")[0];
254
+ installFile(join(templates, asset.template), join(target, ...asset.path.split("/")), asset.mode);
253
255
  const cmdPath = join(target, ".ai", `start-${harness}.cmd`);
254
256
  if (!existsSync(cmdPath)) atomicWrite(cmdPath, windowsLauncher(harness), 0o755);
255
257
  }
@@ -267,7 +269,9 @@ export function bootstrapWindows(root, args) {
267
269
  }
268
270
 
269
271
  console.log(`\nCairnkeep bootstrapped into ${target}`);
270
- console.log(`Launch from PowerShell or Command Prompt with ${join(target, ".ai", "start-claude.cmd")}`);
272
+ console.log(`Codex: run 'cairn setup' in the project and select Codex, review trust, then use ${join(target, ".ai", "start-codex.cmd")}`);
273
+ console.log(`Claude: register 'cairn memory-server', run 'cairn sync --apply', then use ${join(target, ".ai", "start-claude.cmd")}`);
274
+ console.log("For new projects, prefer 'cairn setup'; guide: docs/quickstart.md");
271
275
  }
272
276
 
273
277
  function walkFiles(root, predicate = () => true, prefix = "") {
@@ -616,7 +620,10 @@ function uninstallWindows(root, args) {
616
620
  for (const project of projects) {
617
621
  const base = resolve(project);
618
622
  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`));
623
+ for (const harness of HARNESSES) {
624
+ for (const asset of harnessProjectAssets(harness, "local")) targets.push(join(base, ...asset.path.split("/")));
625
+ targets.push(join(base, ".ai", `start-${harness}.cmd`));
626
+ }
620
627
  targets.push(join(base, ".ai", "start-harness.mjs"));
621
628
  targets.push(join(base, ".ai", "start-harness.ps1"));
622
629
  if (options.has("--purge-memory")) targets.push(join(base, ".agentfs"));
@@ -685,10 +692,11 @@ foreach ($Item in $Manifest.items) {
685
692
  }
686
693
 
687
694
  export function powershellCompletion() {
695
+ const harnesses = HARNESS_IDS.map((id) => `'${id}'`).join(",");
688
696
  return `Register-ArgumentCompleter -Native -CommandName cairn -ScriptBlock {
689
697
  param($wordToComplete, $commandAst, $cursorPosition)
690
698
  $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'
699
+ $setup = '--git','--harness','--memory','--policy','--yes','--json','init','existing','none',${harnesses},'local'
692
700
  $candidates = if ($commandAst.ToString() -match '^\\s*cairn\\s+setup(?:\\s|$)') { $setup } else { $commands }
693
701
  $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
694
702
  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
@@ -0,0 +1,5 @@
1
+ # Cairnkeep project-scoped memory server. Codex loads project configuration only
2
+ # after the project has been trusted.
3
+ [mcp_servers.cairn-memory]
4
+ command = "cairn"
5
+ args = ["memory-server"]
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # Cairnkeep launcher for Codex CLI. Loads this project's private .ai/.env and
3
+ # starts Codex in the project root. The project-scoped MCP registration lives in
4
+ # .codex/config.toml and is only loaded after Codex trusts the project.
5
+ set -euo pipefail
6
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
7
+
8
+ if [[ -f "$ROOT_DIR/.ai/.env" ]]; then
9
+ set -a
10
+ # shellcheck disable=SC1091
11
+ . "$ROOT_DIR/.ai/.env"
12
+ set +a
13
+ fi
14
+ if [[ -f "$ROOT_DIR/.ai/pre-launch.sh" ]]; then
15
+ # shellcheck disable=SC1091
16
+ . "$ROOT_DIR/.ai/pre-launch.sh"
17
+ fi
18
+ cd "$ROOT_DIR"
19
+
20
+ if [[ -f "$ROOT_DIR/.ai/post-exit.sh" ]]; then
21
+ set +e
22
+ codex "$@"
23
+ CAIRN_EXIT_STATUS=$?
24
+ set -e
25
+ export CAIRN_EXIT_STATUS
26
+ # shellcheck disable=SC1091
27
+ . "$ROOT_DIR/.ai/post-exit.sh" || true
28
+ exit "$CAIRN_EXIT_STATUS"
29
+ fi
30
+ exec codex "$@"