@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
@@ -32,6 +32,20 @@ recovery contract as POSIX. It writes a restricted `.ai/cairnkeep.json` setup
32
32
  record and never syncs machine assets automatically. `cairn bootstrap` remains
33
33
  the deterministic compatibility primitive for existing scripts.
34
34
 
35
+ For Codex memory without a harness-wide sync, select only Codex:
36
+
37
+ ```powershell
38
+ cairn setup $Project --git init --harness codex --memory local --yes
39
+ Set-Location $Project
40
+ cairn doctor
41
+ .\.ai\start-codex.cmd
42
+ ```
43
+
44
+ Review `.codex\config.toml` and accept Codex's project-trust prompt. Setup does
45
+ not edit the user-wide Codex configuration or grant trust. It preserves a
46
+ different existing project configuration and doctor verifies a manually merged
47
+ `mcp_servers.cairn-memory` table.
48
+
35
49
  Setup and bootstrap write both the established Unix launchers and native Windows
36
50
  launchers. Each `.cmd` file enters `start-harness.ps1`, loads `.ai/.env`, changes
37
51
  to the project root, and forwards arguments without shell interpolation.
package/docs/operating.md CHANGED
@@ -70,7 +70,7 @@ cairn setup /path/to/project --git init --harness claude,pi --memory local --yes
70
70
  ```
71
71
 
72
72
  The deterministic form accepts `--git init|existing|none`, a comma-separated
73
- subset of `claude,opencode,pi,kimi,qwen`, and `--memory local|none`. `--git
73
+ subset of `claude,opencode,pi,kimi,qwen,codex`, and `--memory local|none`. `--git
74
74
  existing` requires the target to be in an existing work tree. `--git init`
75
75
  requires Git and is the only non-interactive authorization to initialize a
76
76
  repository. `--git none` is an explicit limited mode: repository-aware features
@@ -91,8 +91,10 @@ assets. It contains no credentials, endpoints, or absolute paths.
91
91
 
92
92
  Setup never installs or refreshes machine-level harness assets. Its
93
93
  `machine_sync.automatic` field is always false, and the human output labels the
94
- reported command as not run automatically. Apply the relevant machine command
95
- explicitly, check it, then diagnose and launch:
94
+ reported command as not run automatically. For selections such as Codex and
95
+ Qwen that need no machine sync, `machine_sync.command` is `null` and the human
96
+ output says that sync is not required. Apply any reported command explicitly,
97
+ check it, then diagnose and launch:
96
98
 
97
99
  ```bash
98
100
  cairn sync --apply # Claude Code operating assets
@@ -105,6 +107,25 @@ cd /path/to/project && cairn doctor
105
107
  `cairn bootstrap [--untracked] PATH` remains available for scripts that depend
106
108
  on its original scaffold and Git-exclusion contract.
107
109
 
110
+ ## Setup order (Codex CLI)
111
+
112
+ Codex uses project-scoped configuration, so setup owns the complete local MCP
113
+ wiring without editing user-wide state:
114
+
115
+ ```bash
116
+ npm install -g @cairnkeep/cli
117
+ cairn setup /path/to/project --git init --harness codex --memory local --yes
118
+ cd /path/to/project
119
+ cairn doctor
120
+ ./.ai/start-codex.sh # native Windows: .\.ai\start-codex.cmd
121
+ ```
122
+
123
+ Review `.codex/config.toml` and accept Codex's project-trust prompt before use.
124
+ An existing different file is preserved and reported as skipped; merge the
125
+ generated `mcp_servers.cairn-memory` table into it, then run `cairn doctor`.
126
+ Uninstall leaves that operator-owned file intact. Selecting
127
+ `--memory none` creates the launcher but deliberately omits the MCP entry.
128
+
108
129
  ## Setup order (Claude Code)
109
130
 
110
131
  The commands below install the default local topology: `cairn-memory` runs as a
@@ -865,13 +886,13 @@ maintain a running install — without forking the core. All are opt-in.
865
886
  ### Launcher seams
866
887
 
867
888
  The generic launchers (`.ai/start-claude.sh`, `.ai/start-opencode.sh`,
868
- `.ai/start-kimi.sh`, `.ai/start-qwen.sh`, and `.ai/start-pi.sh`) run optional hooks around the
889
+ `.ai/start-kimi.sh`, `.ai/start-qwen.sh`, `.ai/start-codex.sh`, and `.ai/start-pi.sh`) run optional hooks around the
869
890
  harness, each a no-op when absent:
870
891
 
871
892
  | Seam | When | Purpose |
872
893
  |---|---|---|
873
894
  | `.ai/pre-launch.sh` | sourced after `.env`, before launch | export env (e.g. a provider base URL / auth), refresh credentials, or abort by returning non-zero |
874
- | `CAIRN_EXTRA_SETTINGS` | read just before launch | path to a settings file layered on Claude Code (`--settings`) or OpenCode (`OPENCODE_CONFIG`); Kimi, Qwen, and Pi leave the variable available to hooks but do not interpret it |
895
+ | `CAIRN_EXTRA_SETTINGS` | read just before launch | path to a settings file layered on Claude Code (`--settings`) or OpenCode (`OPENCODE_CONFIG`); Kimi, Qwen, Codex, and Pi leave the variable available to hooks but do not interpret it |
875
896
  | `.ai/post-exit.sh` | sourced after the harness exits | teardown; `CAIRN_EXIT_STATUS` holds the exit code |
876
897
 
877
898
  A wrapper that needs a non-default provider drops a `pre-launch.sh` that renders
@@ -37,6 +37,7 @@ request unless the corresponding endpoint and credential are configured.
37
37
  | Explicit artifact read/show | The redacted body only when HTTP is separately enabled and the operator/client explicitly reads it | Local terminal/stdio, or the explicitly registered authenticated server |
38
38
  | Opt-in evaluation coordinator | Adapter-defined inference may leave the machine only according to the explicit operator-owned adapter and inherited environment | Local adapter subprocess; Cairnkeep supplies no provider, endpoint, credential, model, or network default |
39
39
  | Guided project setup | None | Selected scaffold assets and private `.ai/cairnkeep.json` state in the target; machine sync is never automatic |
40
+ | Codex project setup and launcher | None | Local `.codex/config.toml` starts `cairn memory-server` over stdio only after the operator trusts the project |
40
41
  | Pi memory extension | None | Local `cairn memory-server` stdio child; no HTTP transport is inherited |
41
42
 
42
43
  Model endpoints may be local or remote. Cairnkeep cannot determine a provider's
@@ -93,7 +94,7 @@ and its generated `revert.sh` restores exact bytes, modes, and layout. Explicit
93
94
  `cairn eval delete` and `prune` remove selected contained experiment trees with
94
95
  no hidden tombstone.
95
96
 
96
- ## Guided setup and Pi bridge
97
+ ## Guided setup, Codex, and Pi bridge
97
98
 
98
99
  Guided setup performs a read-only target and Git preflight before project
99
100
  writes. Its schema-v1 `.ai/cairnkeep.json` record contains the package version,
@@ -103,6 +104,12 @@ endpoints, absolute paths, prompts, or memory values. The file is mode `0600`
103
104
  on POSIX and receives the private managed-file ACL on native Windows. Setup
104
105
  never invokes machine sync, and it does not start a harness or server child.
105
106
 
107
+ Selecting Codex with local memory adds a non-secret project MCP table that
108
+ invokes `cairn memory-server`. Setup never edits user-wide Codex configuration,
109
+ grants project trust, or overwrites a different `.codex/config.toml`. Doctor can
110
+ validate a manually merged Cairnkeep table without claiming ownership of the
111
+ surrounding operator configuration, and uninstall leaves that file intact.
112
+
106
113
  When the explicitly installed Pi memory extension starts a session, it spawns
107
114
  `cairn memory-server` as a child in the project directory over local stdio. The
108
115
  bridge removes `MCP_HTTP_PORT` from the child environment, retains only the
@@ -0,0 +1,86 @@
1
+ # Cairnkeep quickstart
2
+
3
+ This is the shortest supported route from an empty directory or existing Git
4
+ repository to durable, local agent memory. The default transport is stdio and
5
+ the default store stays on this computer.
6
+
7
+ ## Codex CLI
8
+
9
+ Install Cairnkeep, then let guided setup initialize an empty project and write
10
+ Codex's project-scoped MCP entry:
11
+
12
+ ```bash
13
+ npm install --global @cairnkeep/cli
14
+ cairn setup /path/to/project --git init --harness codex --memory local --yes
15
+ cd /path/to/project
16
+ cairn doctor
17
+ ./.ai/start-codex.sh
18
+ ```
19
+
20
+ Use `--git existing` instead when the target is already a Git work tree. On
21
+ native Windows, launch with `.\.ai\start-codex.cmd`.
22
+
23
+ Before the first session, review `.codex/config.toml` and accept Codex's project
24
+ trust prompt. The generated entry runs `cairn memory-server` locally. Setup
25
+ does not edit the user-wide Codex configuration and does not enable networking,
26
+ telemetry, embeddings, session capture, or context packs.
27
+
28
+ If `.codex/config.toml` already exists and differs, setup reports it as
29
+ `skipped` and preserves it byte-for-byte. Merge this table into the existing
30
+ file, then run `cairn doctor`:
31
+
32
+ ```toml
33
+ [mcp_servers.cairn-memory]
34
+ command = "cairn"
35
+ args = ["memory-server"]
36
+ ```
37
+
38
+ ## Claude Code
39
+
40
+ Claude Code has the full Cairnkeep operating layer, so it needs one explicit
41
+ machine-level sync in addition to project setup:
42
+
43
+ ```bash
44
+ npm install --global @cairnkeep/cli
45
+ claude mcp add cairn-memory -s user -- cairn memory-server
46
+ cairn sync --apply
47
+ cairn setup /path/to/project --git init --harness claude --memory local --yes
48
+ cd /path/to/project
49
+ cairn doctor
50
+ ./.ai/start-claude.sh
51
+ ```
52
+
53
+ Setup reports machine-level commands but never executes them. This keeps
54
+ project scaffolding separate from harness-wide changes.
55
+
56
+ ## First memory cycle
57
+
58
+ In a harness with Cairnkeep commands, remember one durable fact and recall it:
59
+
60
+ ```text
61
+ > /remember Use transactional migrations for schema changes
62
+ > /recall transactional migrations
63
+ ```
64
+
65
+ In Codex or another MCP client, ask the client to call `memory_write`, then
66
+ `memory_search`. Close the session, start a new one, and repeat the search. The
67
+ result should survive the restart.
68
+
69
+ ## What setup owns
70
+
71
+ `cairn setup` creates only the selected harness assets plus the common `.ai`,
72
+ `.planning`, and `.agentfs` scaffold. It initializes Git only with explicit
73
+ `--git init`, preserves unrelated files, does not overwrite modified files it
74
+ does not own, and records managed digests in private `.ai/cairnkeep.json`.
75
+
76
+ Useful follow-ups:
77
+
78
+ ```bash
79
+ cairn setup . --git existing --harness codex --memory local --yes # reconcile
80
+ cairn doctor # diagnose
81
+ cairn uninstall --dry-run . # preview removal
82
+ ```
83
+
84
+ For all harnesses, remote HTTP, storage, profiles, context packs, overlays, and
85
+ recovery details, continue with [Operating Cairnkeep](operating.md) or the
86
+ [learning path](learning/README.md).
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.11.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.12.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -64,6 +64,7 @@
64
64
  "memory",
65
65
  "agent",
66
66
  "claude-code",
67
+ "codex-cli",
67
68
  "opencode",
68
69
  "qwen-code",
69
70
  "pi-coding-agent",
@@ -16,7 +16,7 @@
16
16
  "type": "array",
17
17
  "minItems": 1,
18
18
  "uniqueItems": true,
19
- "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
19
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
20
20
  },
21
21
  "memory": {"enum": ["local", "none"]}
22
22
  }
@@ -35,13 +35,13 @@
35
35
  "type": "array",
36
36
  "minItems": 1,
37
37
  "uniqueItems": true,
38
- "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
38
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
39
39
  },
40
40
  "required_harnesses": {
41
41
  "type": "array",
42
42
  "minItems": 1,
43
43
  "uniqueItems": true,
44
- "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
44
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
45
45
  },
46
46
  "memory": {
47
47
  "type": "array",
@@ -14,12 +14,12 @@
14
14
  "type": "array",
15
15
  "minItems": 1,
16
16
  "uniqueItems": true,
17
- "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
17
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
18
18
  },
19
19
  "assets": {
20
20
  "type": "object",
21
21
  "propertyNames": {
22
- "pattern": "^\\.(?:ai|planning|agentfs)/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
22
+ "pattern": "^\\.(?:ai|planning|agentfs|codex)/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
23
23
  },
24
24
  "additionalProperties": {
25
25
  "type": "object",
@@ -53,11 +53,9 @@ install_file() {
53
53
  }
54
54
 
55
55
  # .ai/ launchers + env
56
- install_file "$TPL/start-claude.sh.template" "$target/.ai/start-claude.sh" 0755
57
- install_file "$TPL/start-opencode.sh.template" "$target/.ai/start-opencode.sh" 0755
58
- install_file "$TPL/start-pi.sh.template" "$target/.ai/start-pi.sh" 0755
59
- install_file "$TPL/start-kimi.sh.template" "$target/.ai/start-kimi.sh" 0755
60
- install_file "$TPL/start-qwen.sh.template" "$target/.ai/start-qwen.sh" 0755
56
+ while IFS=$'\t' read -r template destination mode; do
57
+ install_file "$TPL/$template" "$target/$destination" "0$mode"
58
+ done < <(node "$CAIRN_ROOT/scripts/harness-registry.mjs" bootstrap-assets)
61
59
  install_file "$TPL/env.example.template" "$target/.ai/env.example" 0644
62
60
  install_file "$TPL/trajectory-redaction.json.template" "$target/.ai/trajectory-redaction.json" 0644
63
61
  install_file "$TPL/capabilities.json.template" "$target/.ai/capabilities.json" 0600
@@ -100,13 +98,7 @@ fi
100
98
 
101
99
  echo
102
100
  echo "Cairnkeep bootstrapped into $target"
103
- echo "Next steps (full guide: docs/operating.md):"
104
- echo " 1. cp .ai/env.example .ai/.env and edit"
105
- echo " 2. Register the memory server: claude mcp add cairn-memory -s user -- cairn memory-server"
106
- echo " 3. Install the operating layer (commands/agents/hooks): cairn sync --apply"
107
- echo " For Pi trajectory + /graphify, also run: cairn sync-pi --apply"
108
- echo " For Kimi /graphify, also run: cairn sync-kimi --apply"
109
- echo " From a source clone, use bin/cairn in place of cairn."
110
- echo " 4. Launch: $target/.ai/start-claude.sh"
111
- echo " Kimi memory-client launch: $target/.ai/start-kimi.sh"
112
- echo " Qwen memory-client launch: $target/.ai/start-qwen.sh"
101
+ echo "Codex: run 'cairn setup' in the project and select Codex; then review trust and run .ai/start-codex.sh"
102
+ echo "Claude: claude mcp add cairn-memory -s user -- cairn memory-server; cairn sync --apply"
103
+ echo "Optional project overrides: cp .ai/env.example .ai/.env"
104
+ echo "For new projects, prefer 'cairn setup'; guide: docs/quickstart.md"
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ CAIRN_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
5
+ harnesses=$(node "$CAIRN_ROOT/scripts/harness-registry.mjs" ids)
4
6
  shell=${1:-}
5
7
 
6
8
  case "$shell" in
7
9
  bash)
8
- cat <<'EOF'
10
+ sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
9
11
  _cairn_complete() {
10
12
  local current previous commands
11
13
  COMPREPLY=()
@@ -18,7 +20,7 @@ _cairn_complete() {
18
20
  fi
19
21
  case "${COMP_WORDS[1]}" in
20
22
  bootstrap) COMPREPLY=( $(compgen -W "--untracked" -- "$current") ) ;;
21
- setup) COMPREPLY=( $(compgen -W "--git --harness --memory --policy --yes --json init existing none claude opencode pi kimi qwen local" -- "$current") ) ;;
23
+ setup) COMPREPLY=( $(compgen -W "--git --harness --memory --policy --yes --json init existing none __CAIRN_HARNESSES__ local" -- "$current") ) ;;
22
24
  sync) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
23
25
  sync-pi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
24
26
  sync-kimi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
@@ -78,7 +80,7 @@ complete -F _cairn_complete cairn
78
80
  EOF
79
81
  ;;
80
82
  zsh)
81
- cat <<'EOF'
83
+ sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
82
84
  #compdef cairn
83
85
  _cairn() {
84
86
  local -a commands
@@ -112,7 +114,7 @@ _cairn() {
112
114
  fi
113
115
  case $words[2] in
114
116
  bootstrap) _arguments '--untracked[keep scaffold out of Git]' '*:project directory:_files -/' ;;
115
- setup) _arguments '*:project directory:_files -/' '--git[Git mode]:mode:(init existing none)' '--harness[selected harnesses]:harnesses:(claude opencode pi kimi qwen)' '--memory[memory mode]:mode:(local none)' '--policy[setup policy]:file:_files' '--yes[confirm setup]' '--json[emit JSON]' ;;
117
+ setup) _arguments '*:project directory:_files -/' '--git[Git mode]:mode:(init existing none)' '--harness[selected harnesses]:harnesses:(__CAIRN_HARNESSES__)' '--memory[memory mode]:mode:(local none)' '--policy[setup policy]:file:_files' '--yes[confirm setup]' '--json[emit JSON]' ;;
116
118
  sync) _arguments '--apply[apply changes]' '--live-root[project Claude root]:directory:_files -/' ;;
117
119
  sync-pi) _arguments '--apply[apply changes]' '--live-root[Pi agent root]:directory:_files -/' ;;
118
120
  sync-kimi) _arguments '--apply[apply changes]' '--live-root[Kimi Code root]:directory:_files -/' ;;
@@ -175,7 +177,7 @@ _cairn "$@"
175
177
  EOF
176
178
  ;;
177
179
  fish)
178
- cat <<'EOF'
180
+ sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
179
181
  complete -c cairn -f
180
182
  for command in 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
181
183
  complete -c cairn -n "not __fish_seen_subcommand_from 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" -a $command
@@ -188,7 +190,7 @@ complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l apply
188
190
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l live-root -r
189
191
  complete -c cairn -n "__fish_seen_subcommand_from bootstrap" -l untracked
190
192
  complete -c cairn -n "__fish_seen_subcommand_from setup" -l git -r -a "init existing none"
191
- complete -c cairn -n "__fish_seen_subcommand_from setup" -l harness -r -a "claude opencode pi kimi qwen"
193
+ complete -c cairn -n "__fish_seen_subcommand_from setup" -l harness -r -a "__CAIRN_HARNESSES__"
192
194
  complete -c cairn -n "__fish_seen_subcommand_from setup" -l memory -r -a "local none"
193
195
  complete -c cairn -n "__fish_seen_subcommand_from setup" -l policy -r
194
196
  complete -c cairn -n "__fish_seen_subcommand_from setup" -l yes
package/scripts/doctor.sh CHANGED
@@ -443,18 +443,19 @@ fi
443
443
  # repairing project or machine assets. The Node controller returns only bounded
444
444
  # states and exact recovery commands; setup remains the sole owner of project
445
445
  # reconciliation.
446
- setup_diagnosis=$(node --input-type=module - "$CAIRN_ROOT/scripts/setup.mjs" "$PWD" <<'NODE' 2>/dev/null
446
+ setup_diagnosis=$(node --input-type=module - "$CAIRN_ROOT/scripts/setup.mjs" "$CAIRN_ROOT/scripts/harness-registry.mjs" "$PWD" <<'NODE' 2>/dev/null
447
447
  import { pathToFileURL } from "node:url";
448
448
  import { readFileSync } from "node:fs";
449
449
  import { join } from "node:path";
450
450
  const module = await import(pathToFileURL(process.argv[2]).href);
451
- const value = module.diagnoseSetup(process.argv[3]);
451
+ const registry = await import(pathToFileURL(process.argv[3]).href);
452
+ const value = module.diagnoseSetup(process.argv[4]);
452
453
  const allowed = new Set(["absent", "complete", "limited", "incomplete"]);
453
454
  if (value.schema_version !== 1 || !allowed.has(value.status) || !Array.isArray(value.recovery)) process.exit(1);
454
455
  let piSelected = false;
455
456
  if (value.status === "complete" || value.status === "limited") {
456
- const state = JSON.parse(readFileSync(join(process.argv[3], ".ai", "cairnkeep.json"), "utf8"));
457
- if (!Array.isArray(state.harnesses) || state.harnesses.some((name) => !["claude", "opencode", "pi", "kimi", "qwen"].includes(name))) process.exit(1);
457
+ const state = JSON.parse(readFileSync(join(process.argv[4], ".ai", "cairnkeep.json"), "utf8"));
458
+ if (!Array.isArray(state.harnesses) || state.harnesses.some((name) => !registry.HARNESS_IDS.includes(name))) process.exit(1);
458
459
  piSelected = state.harnesses.includes("pi");
459
460
  }
460
461
  process.stdout.write(`${value.status}:${value.code}:${value.recovery.join("|")}:${piSelected ? "yes" : "no"}`);
@@ -0,0 +1,126 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ /**
4
+ * Declarative registry for every CLI harness supported by project setup.
5
+ *
6
+ * Keep harness-specific project assets and lifecycle guidance here. Consumers
7
+ * must derive choices from this registry instead of maintaining local lists.
8
+ */
9
+ const DEFINITIONS = [
10
+ {
11
+ id: "claude",
12
+ title: "Claude Code",
13
+ launcher: { template: "start-claude.sh.template", path: ".ai/start-claude.sh", mode: 0o755 },
14
+ project_assets: [],
15
+ machine_sync: "cairn sync --apply",
16
+ },
17
+ {
18
+ id: "opencode",
19
+ title: "OpenCode",
20
+ launcher: { template: "start-opencode.sh.template", path: ".ai/start-opencode.sh", mode: 0o755 },
21
+ project_assets: [],
22
+ machine_sync: "cairn sync --apply",
23
+ },
24
+ {
25
+ id: "pi",
26
+ title: "Pi",
27
+ launcher: { template: "start-pi.sh.template", path: ".ai/start-pi.sh", mode: 0o755 },
28
+ project_assets: [],
29
+ machine_sync: "cairn sync-pi --apply",
30
+ },
31
+ {
32
+ id: "kimi",
33
+ title: "Kimi Code",
34
+ launcher: { template: "start-kimi.sh.template", path: ".ai/start-kimi.sh", mode: 0o755 },
35
+ project_assets: [],
36
+ machine_sync: "cairn sync-kimi --apply",
37
+ },
38
+ {
39
+ id: "qwen",
40
+ title: "Qwen Code",
41
+ launcher: { template: "start-qwen.sh.template", path: ".ai/start-qwen.sh", mode: 0o755 },
42
+ project_assets: [],
43
+ machine_sync: null,
44
+ },
45
+ {
46
+ id: "codex",
47
+ title: "Codex CLI",
48
+ launcher: { template: "start-codex.sh.template", path: ".ai/start-codex.sh", mode: 0o755 },
49
+ project_assets: [
50
+ {
51
+ template: "codex-config.toml.template",
52
+ path: ".codex/config.toml",
53
+ mode: 0o644,
54
+ memory: "local",
55
+ // An existing operator-owned TOML file is valid when doctor finds the
56
+ // exact MCP table; it must not become setup/uninstall-owned.
57
+ required: false,
58
+ },
59
+ ],
60
+ machine_sync: null,
61
+ },
62
+ ];
63
+
64
+ function freezeDefinition(definition) {
65
+ const launcher = Object.freeze({ ...definition.launcher });
66
+ const projectAssets = Object.freeze(definition.project_assets.map((asset) => Object.freeze({ ...asset })));
67
+ return Object.freeze({ ...definition, launcher, project_assets: projectAssets });
68
+ }
69
+
70
+ export const HARNESS_REGISTRY = Object.freeze(DEFINITIONS.map(freezeDefinition));
71
+ export const HARNESS_IDS = Object.freeze(HARNESS_REGISTRY.map(({ id }) => id));
72
+
73
+ export function harnessDefinition(id) {
74
+ return HARNESS_REGISTRY.find((definition) => definition.id === id) ?? null;
75
+ }
76
+
77
+ export function harnessProjectAssets(id, memory = "local") {
78
+ const definition = harnessDefinition(id);
79
+ if (!definition) throw new Error(`Unknown harness: ${id}.`);
80
+ return Object.freeze([
81
+ definition.launcher,
82
+ ...definition.project_assets.filter((asset) => !asset.memory || asset.memory === memory),
83
+ ]);
84
+ }
85
+
86
+ export function requiredHarnessAssetPaths(harnesses, memory = "local") {
87
+ return Object.freeze(harnesses.flatMap((id) => harnessProjectAssets(id, memory)
88
+ .filter((asset) => asset.required !== false)
89
+ .map(({ path }) => path)));
90
+ }
91
+
92
+ export function machineSyncCommand(harnesses) {
93
+ const commands = harnesses.map(harnessDefinition).map((definition) => definition?.machine_sync).filter(Boolean);
94
+ if (commands.includes("cairn sync-pi --apply")) return "cairn sync-pi --apply";
95
+ return commands[0] ?? null;
96
+ }
97
+
98
+ function validateRegistry() {
99
+ if (new Set(HARNESS_IDS).size !== HARNESS_IDS.length) throw new Error("Harness registry contains duplicate IDs.");
100
+ for (const definition of HARNESS_REGISTRY) {
101
+ if (!/^[a-z][a-z0-9-]*$/.test(definition.id) || !definition.title) throw new Error("Invalid harness registry entry.");
102
+ const assets = [definition.launcher, ...definition.project_assets];
103
+ if (new Set(assets.map(({ path }) => path)).size !== assets.length) throw new Error(`Duplicate project asset for ${definition.id}.`);
104
+ for (const asset of assets) {
105
+ if (!asset.template || !asset.path || ![0o600, 0o644, 0o755].includes(asset.mode)) throw new Error(`Invalid project asset for ${definition.id}.`);
106
+ }
107
+ }
108
+ }
109
+
110
+ validateRegistry();
111
+
112
+ // The legacy bootstrap is a shell script for Bash 3.2 portability. This bounded
113
+ // output lets it consume the same registry without parsing JavaScript or JSON.
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
115
+ if (!new Set(["bootstrap-assets", "ids"]).has(process.argv[2])) {
116
+ console.error("Usage: node harness-registry.mjs bootstrap-assets|ids");
117
+ process.exitCode = 2;
118
+ } else if (process.argv[2] === "ids") {
119
+ process.stdout.write(`${HARNESS_IDS.join(" ")}\n`);
120
+ } else {
121
+ for (const definition of HARNESS_REGISTRY) {
122
+ const asset = definition.launcher;
123
+ process.stdout.write(`${asset.template}\t${asset.path}\t${asset.mode.toString(8)}\n`);
124
+ }
125
+ }
126
+ }
@@ -3,12 +3,13 @@ import { accessSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { dirname, join, parse, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { HARNESS_IDS, harnessProjectAssets } from "./harness-registry.mjs";
6
7
 
7
8
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
9
  const TEMPLATE_ROOT = join(ROOT, "templates");
9
10
  const POLICY_LIMIT_BYTES = 1024 * 1024;
10
11
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
11
- const HARNESSES = Object.freeze(["claude", "opencode", "pi", "kimi", "qwen"]);
12
+ const HARNESSES = HARNESS_IDS;
12
13
  const MEMORY_MODES = Object.freeze(["local", "none"]);
13
14
 
14
15
  const COMMON_ASSETS = Object.freeze([
@@ -293,7 +294,8 @@ export function buildSetupPlan({ target, preflight, choices }) {
293
294
  if (choices.git !== "none" && preflight.gitExecutable === "missing") throw operational("missing-git", "The selected Git mode requires the Git executable.");
294
295
  const harnesses = HARNESSES.filter((name) => choices.harnesses.includes(name));
295
296
  const assets = [
296
- ...harnesses.map((harness) => templateAsset(`start-${harness}.sh.template`, `.ai/start-${harness}.sh`, 0o755, harness)),
297
+ ...harnesses.flatMap((harness) => harnessProjectAssets(harness, choices.memory)
298
+ .map(({ template, path, mode }) => templateAsset(template, path, mode, harness))),
297
299
  ...COMMON_ASSETS.map(([template, path, mode]) => templateAsset(template, path, mode)),
298
300
  ];
299
301
  const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
@@ -16,16 +16,17 @@ import {
16
16
  import { open, rename } from "node:fs/promises";
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
19
+ import { HARNESS_IDS } from "./harness-registry.mjs";
19
20
 
20
21
  export const SETUP_STATE_SCHEMA_VERSION = 1;
21
22
 
22
- const HARNESSES = Object.freeze(["claude", "opencode", "pi", "kimi", "qwen"]);
23
+ const HARNESSES = HARNESS_IDS;
23
24
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
24
25
  const MEMORY_MODES = Object.freeze(["local", "none"]);
25
26
  const STATE_KEYS = Object.freeze(["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"]);
26
27
  const ASSET_KEYS = Object.freeze(["digest", "mode", "template"]);
27
28
  const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
28
- const RELATIVE_PATH_PATTERN = /^\.(?:ai|planning|agentfs)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
29
+ const RELATIVE_PATH_PATTERN = /^\.(?:ai|planning|agentfs|codex)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
29
30
  const TEMPLATE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
30
31
 
31
32
  export function hashSetupAsset(bytes) {
package/scripts/setup.mjs CHANGED
@@ -21,13 +21,14 @@ import {
21
21
  resolveSetupChoices,
22
22
  } from "./setup-core.mjs";
23
23
  import { reconcileSetupPlan } from "./setup-reconcile.mjs";
24
+ import { HARNESS_IDS, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
24
25
 
25
- const HARNESSES = Object.freeze(["claude", "opencode", "pi", "kimi", "qwen"]);
26
+ const HARNESSES = HARNESS_IDS;
26
27
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
27
28
  const MEMORY_MODES = Object.freeze(["local", "none"]);
28
29
  const STATE_KEYS = Object.freeze(["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"]);
29
30
  const ASSET_KEYS = Object.freeze(["digest", "mode", "template"]);
30
- const SAFE_ASSET = /^\.(?:ai|planning|agentfs)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
31
+ const SAFE_ASSET = /^\.(?:ai|planning|agentfs|codex)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
31
32
  const COMMON_SETUP_ASSETS = Object.freeze([
32
33
  ".ai/env.example",
33
34
  ".ai/trajectory-redaction.json",
@@ -108,7 +109,7 @@ function setupRecovery(plan) {
108
109
  }
109
110
 
110
111
  function resultFor(plan, reconciliation, platform) {
111
- const machineCommand = plan.harnesses.includes("pi") ? "cairn sync-pi --apply" : "cairn sync --apply";
112
+ const machineCommand = machineSyncCommand(plan.harnesses);
112
113
  return Object.freeze({
113
114
  schema_version: 1,
114
115
  status: plan.limited ? "limited" : "complete",
@@ -139,7 +140,12 @@ export function renderSetupResult(result, stream) {
139
140
  for (const command of result.launch_commands) writeLine(stream, `Launch: ${command}`);
140
141
  for (const command of result.recovery) writeLine(stream, `Recovery: ${command}`);
141
142
  for (const limitation of result.limitations) writeLine(stream, `Limitation: ${limitation}`);
142
- writeLine(stream, `Machine sync: ${result.machine_sync.command} (not run automatically)`);
143
+ if (result.harnesses.includes("codex") && result.memory === "local") {
144
+ writeLine(stream, "Codex: review .codex/config.toml and accept the project trust prompt before use");
145
+ }
146
+ writeLine(stream, result.machine_sync.command
147
+ ? `Machine sync: ${result.machine_sync.command} (not run automatically)`
148
+ : "Machine sync: not required for the selected harnesses");
143
149
  }
144
150
 
145
151
  function createTarget(target) {
@@ -260,6 +266,33 @@ function readPrivateState(path) {
260
266
  }
261
267
  }
262
268
 
269
+ function hasCodexMemoryConfig(project) {
270
+ const path = join(project, ".codex", "config.toml");
271
+ if (!existsSync(path)) return false;
272
+ const info = lstatSync(path);
273
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) return false;
274
+ let descriptor;
275
+ let text;
276
+ try {
277
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
278
+ const opened = fstatSync(descriptor);
279
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) return false;
280
+ text = readFileSync(descriptor, "utf8");
281
+ } finally {
282
+ if (descriptor !== undefined) closeSync(descriptor);
283
+ }
284
+ const lines = text.split(/\r?\n/);
285
+ const headers = lines.flatMap((line, index) => (
286
+ ["[mcp_servers.cairn-memory]", "[mcp_servers.\"cairn-memory\"]"].includes(line.trim()) ? [index] : []
287
+ ));
288
+ if (headers.length !== 1) return false;
289
+ const [start] = headers;
290
+ const next = lines.findIndex((line, index) => index > start && /^\s*\[[^\]]+\]\s*$/.test(line));
291
+ const section = lines.slice(start + 1, next < 0 ? lines.length : next).join("\n");
292
+ return (section.match(/^\s*command\s*=\s*["']cairn["']\s*$/gm) ?? []).length === 1
293
+ && (section.match(/^\s*args\s*=\s*\[\s*["']memory-server["']\s*\]\s*$/gm) ?? []).length === 1;
294
+ }
295
+
263
296
  export function diagnoseSetup(target = ".") {
264
297
  const project = resolve(target);
265
298
  const statePath = join(project, ".ai", "cairnkeep.json");
@@ -292,8 +325,11 @@ export function diagnoseSetup(target = ".") {
292
325
  const digest = createHash("sha256").update(readFileSync(destination)).digest("hex");
293
326
  if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
294
327
  }
295
- const requiredAssets = [...COMMON_SETUP_ASSETS, ...state.harnesses.map((harness) => `.ai/start-${harness}.sh`)];
328
+ const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
296
329
  if (requiredAssets.some((path) => !Object.hasOwn(state.assets, path))) return incompleteDiagnosis(recovery);
330
+ if (state.memory === "local" && state.harnesses.includes("codex") && !hasCodexMemoryConfig(project)) {
331
+ return incompleteDiagnosis(recovery);
332
+ }
297
333
  return Object.freeze({
298
334
  schema_version: 1,
299
335
  status: state.git === "none" ? "limited" : "complete",