@cairnkeep/cli 2.9.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 (86) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +83 -29
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +42 -5
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +64 -3
  8. package/docs/learning/CURRICULUM-MAP.md +13 -6
  9. package/docs/learning/FEATURE-GUIDE.md +14 -3
  10. package/docs/learning/README.md +5 -2
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +24 -8
  14. package/docs/learning/lessons/L03-first-project.md +34 -15
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +83 -0
  19. package/docs/learning/lessons/L23-guided-setup.md +142 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +9 -4
  22. package/docs/native-windows.md +136 -0
  23. package/docs/operating.md +142 -20
  24. package/docs/privacy-and-data-flow.md +41 -1
  25. package/docs/quickstart.md +86 -0
  26. package/docs/storage.md +14 -2
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  29. package/mcp-memory-server/dist/artifact-store.js +196 -55
  30. package/mcp-memory-server/dist/capability-cli.js +2 -1
  31. package/mcp-memory-server/dist/capability-config.js +4 -3
  32. package/mcp-memory-server/dist/capability-harness.js +34 -7
  33. package/mcp-memory-server/dist/context-pack.js +84 -25
  34. package/mcp-memory-server/dist/eval-cli.js +6 -5
  35. package/mcp-memory-server/dist/eval-plan.js +35 -13
  36. package/mcp-memory-server/dist/eval-process.js +11 -4
  37. package/mcp-memory-server/dist/eval-report.js +7 -5
  38. package/mcp-memory-server/dist/graph-cli.js +7 -1
  39. package/mcp-memory-server/dist/index.js +19 -20
  40. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  41. package/mcp-memory-server/dist/note-store.js +12 -3
  42. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  43. package/mcp-memory-server/dist/platform-security.js +102 -0
  44. package/mcp-memory-server/dist/skill-store.js +27 -12
  45. package/mcp-memory-server/package.json +2 -1
  46. package/package.json +6 -4
  47. package/pi/extensions/cairnkeep-memory.ts +61 -0
  48. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  49. package/schemas/cairnkeep-setup.schema.json +36 -0
  50. package/scripts/bootstrap.sh +7 -15
  51. package/scripts/cairn-cli.mjs +119 -0
  52. package/scripts/cairn-container-cli.mjs +121 -0
  53. package/scripts/cairn-hook.mjs +199 -0
  54. package/scripts/completion.sh +17 -6
  55. package/scripts/doctor.sh +58 -0
  56. package/scripts/fix-permissions.mjs +12 -0
  57. package/scripts/harness-registry.mjs +126 -0
  58. package/scripts/memory-wiki-audit.mjs +95 -0
  59. package/scripts/phase26-test-manifest.mjs +26 -0
  60. package/scripts/run-repository-tests.mjs +76 -0
  61. package/scripts/setup-core.mjs +312 -0
  62. package/scripts/setup-reconcile.mjs +332 -0
  63. package/scripts/setup.mjs +342 -0
  64. package/scripts/sync-pi-assets.sh +19 -4
  65. package/scripts/test-bootstrap-untracked.sh +2 -1
  66. package/scripts/test-completion.sh +41 -0
  67. package/scripts/test-doctor.sh +66 -0
  68. package/scripts/test-harness-registry.sh +94 -0
  69. package/scripts/test-learning-docs.sh +52 -3
  70. package/scripts/test-package-install.sh +69 -0
  71. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  72. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  73. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  74. package/scripts/test-pi-lifecycle.mjs +190 -0
  75. package/scripts/test-setup-compatibility.mjs +187 -0
  76. package/scripts/test-setup-output.sh +131 -0
  77. package/scripts/test-setup-overlay.sh +113 -0
  78. package/scripts/test-setup-preflight.mjs +281 -0
  79. package/scripts/test-setup-reconcile.mjs +257 -0
  80. package/scripts/test-uninstall.sh +50 -0
  81. package/scripts/test-windows-native.mjs +159 -0
  82. package/scripts/uninstall.sh +73 -6
  83. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  84. package/scripts/windows-platform.mjs +733 -0
  85. package/templates/codex-config.toml.template +5 -0
  86. package/templates/start-codex.sh.template +30 -0
@@ -0,0 +1,185 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
4
+ const DEFAULT_CALL_TIMEOUT_MS = 30_000;
5
+ const DEFAULT_STDERR_LIMIT_BYTES = 16 * 1024;
6
+ const DEFAULT_RESULT_LIMIT_BYTES = 4 * 1024 * 1024;
7
+ const MAX_CATALOG_PAGES = 1_000;
8
+ function positiveInteger(value, fallback, name) {
9
+ const selected = value ?? fallback;
10
+ if (!Number.isSafeInteger(selected) || selected <= 0)
11
+ throw new Error(`${name} must be a positive integer.`);
12
+ return selected;
13
+ }
14
+ function childEnvironment(source) {
15
+ const env = {};
16
+ for (const [name, value] of Object.entries(source)) {
17
+ if (value !== undefined && name !== "MCP_HTTP_PORT")
18
+ env[name] = value;
19
+ }
20
+ return env;
21
+ }
22
+ function byteLength(value) {
23
+ try {
24
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
25
+ }
26
+ catch {
27
+ throw new Error("MCP result could not be measured safely.");
28
+ }
29
+ }
30
+ function boundedMessage(error, stderr) {
31
+ const base = error instanceof Error ? error.message : String(error);
32
+ const tail = stderr.trim();
33
+ return tail ? `${base} (server stderr: ${tail})` : base;
34
+ }
35
+ function transportBufferSize(resultLimitBytes) {
36
+ return Math.min(Math.max(resultLimitBytes + 64 * 1024, resultLimitBytes * 4), 64 * 1024 * 1024);
37
+ }
38
+ function failureText(result) {
39
+ const text = result.content
40
+ .filter((item) => item.type === "text")
41
+ .map((item) => item.text.trim())
42
+ .filter(Boolean)
43
+ .join("; ");
44
+ return text || "The MCP server reported a tool failure.";
45
+ }
46
+ function piContent(result) {
47
+ return result.content.map((item) => {
48
+ if (item.type === "text")
49
+ return { ...item };
50
+ if (item.type === "image")
51
+ return { ...item };
52
+ throw new Error(`Unsupported MCP result content type: ${item.type}.`);
53
+ });
54
+ }
55
+ export async function connectCairnPiBridge(options = {}) {
56
+ const startupTimeoutMs = positiveInteger(options.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs");
57
+ const callTimeoutMs = positiveInteger(options.callTimeoutMs, DEFAULT_CALL_TIMEOUT_MS, "callTimeoutMs");
58
+ const stderrLimitBytes = positiveInteger(options.stderrLimitBytes, DEFAULT_STDERR_LIMIT_BYTES, "stderrLimitBytes");
59
+ const resultLimitBytes = positiveInteger(options.resultLimitBytes, DEFAULT_RESULT_LIMIT_BYTES, "resultLimitBytes");
60
+ const transport = new StdioClientTransport({
61
+ command: options.command ?? "cairn",
62
+ args: options.args ? [...options.args] : ["memory-server"],
63
+ cwd: options.cwd ?? process.cwd(),
64
+ env: childEnvironment(options.env ?? process.env),
65
+ stderr: "pipe",
66
+ maxBufferSize: transportBufferSize(resultLimitBytes),
67
+ });
68
+ const client = new Client({ name: "cairnkeep-pi-bridge", version: "1" }, { capabilities: {} });
69
+ let state = "idle";
70
+ let stderr = Buffer.alloc(0);
71
+ let transportFailure;
72
+ let catalog = [];
73
+ let closePromise;
74
+ transport.stderr?.on("data", (chunk) => {
75
+ const next = Buffer.concat([stderr, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
76
+ stderr = next.byteLength <= stderrLimitBytes ? next : next.subarray(next.byteLength - stderrLimitBytes);
77
+ });
78
+ const stderrTail = () => stderr.toString("utf8");
79
+ const requireReady = () => {
80
+ if (state !== "ready")
81
+ throw new Error(`Pi MCP bridge is ${state}; calls require ready state.`);
82
+ };
83
+ const discover = async () => {
84
+ const tools = [];
85
+ const names = new Set();
86
+ const cursors = new Set();
87
+ let cursor;
88
+ for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber += 1) {
89
+ const page = await client.listTools(cursor ? { cursor } : undefined, { timeout: startupTimeoutMs });
90
+ if (byteLength(page) > resultLimitBytes)
91
+ throw new Error("MCP tool catalog exceeds the configured result size limit.");
92
+ for (const tool of page.tools) {
93
+ if (names.has(tool.name))
94
+ throw new Error(`MCP tool catalog contains duplicate name: ${tool.name}.`);
95
+ names.add(tool.name);
96
+ tools.push(tool);
97
+ }
98
+ if (!page.nextCursor)
99
+ return tools;
100
+ if (cursors.has(page.nextCursor))
101
+ throw new Error("MCP tool catalog pagination repeated a cursor.");
102
+ cursors.add(page.nextCursor);
103
+ cursor = page.nextCursor;
104
+ }
105
+ throw new Error("MCP tool catalog exceeded the pagination limit.");
106
+ };
107
+ const close = () => {
108
+ if (closePromise)
109
+ return closePromise;
110
+ state = "closing";
111
+ closePromise = (async () => {
112
+ try {
113
+ await client.close();
114
+ }
115
+ finally {
116
+ state = "closed";
117
+ catalog = [];
118
+ }
119
+ })();
120
+ return closePromise;
121
+ };
122
+ state = "starting";
123
+ try {
124
+ await client.connect(transport, { timeout: startupTimeoutMs, maxTotalTimeout: startupTimeoutMs });
125
+ const protocolErrorHandler = transport.onerror;
126
+ transport.onerror = (error) => {
127
+ transportFailure = error;
128
+ protocolErrorHandler?.(error);
129
+ };
130
+ const protocolCloseHandler = transport.onclose;
131
+ transport.onclose = () => {
132
+ protocolCloseHandler?.();
133
+ if (state === "starting" || state === "ready") {
134
+ state = "closed";
135
+ catalog = [];
136
+ }
137
+ };
138
+ const pid = transport.pid;
139
+ if (pid !== null)
140
+ options.onSpawn?.({ pid });
141
+ catalog = await discover();
142
+ state = "ready";
143
+ }
144
+ catch (error) {
145
+ await close().catch(() => undefined);
146
+ throw new Error(`Unable to start local Pi MCP bridge: ${boundedMessage(error, stderrTail())}`, { cause: error });
147
+ }
148
+ return {
149
+ get state() { return state; },
150
+ async listAllTools() {
151
+ requireReady();
152
+ return catalog.map((tool) => structuredClone(tool));
153
+ },
154
+ async call(tool, args, callOptions = {}) {
155
+ requireReady();
156
+ const discovered = catalog.find(({ name }) => name === tool.name);
157
+ if (!discovered)
158
+ throw new Error(`MCP tool was not discovered for this session: ${tool.name}.`);
159
+ try {
160
+ const result = await client.callTool({ name: discovered.name, arguments: args }, undefined, { signal: callOptions.signal, timeout: callTimeoutMs, maxTotalTimeout: callTimeoutMs });
161
+ if (byteLength(result) > resultLimitBytes)
162
+ throw new Error("MCP tool result exceeds the configured size limit.");
163
+ if (result.isError)
164
+ throw new Error(failureText(result));
165
+ const details = {
166
+ tool: discovered,
167
+ annotations: discovered.annotations,
168
+ outputSchema: discovered.outputSchema,
169
+ content: result.content,
170
+ structuredContent: result.structuredContent,
171
+ isError: false,
172
+ ...(result._meta ? { _meta: result._meta } : {}),
173
+ };
174
+ return { content: piContent(result), details };
175
+ }
176
+ catch (error) {
177
+ const failure = transportFailure && /closed/i.test(error instanceof Error ? error.message : String(error))
178
+ ? transportFailure
179
+ : error;
180
+ throw new Error(`Pi MCP call ${discovered.name} failed: ${boundedMessage(failure, stderrTail())}`, { cause: error });
181
+ }
182
+ },
183
+ close,
184
+ };
185
+ }
@@ -0,0 +1,102 @@
1
+ import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
2
+ import { rename } from "node:fs/promises";
3
+ import { spawnSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ function currentWindowsSid() {
6
+ const result = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
7
+ if (result.status !== 0)
8
+ throw new Error("Unable to resolve the current Windows security identity.");
9
+ const match = result.stdout.match(/"(S-1-[0-9-]+)"/i);
10
+ if (!match)
11
+ throw new Error("Unable to resolve the current Windows security identity.");
12
+ return match[1];
13
+ }
14
+ export function hardenPrivatePath(path) {
15
+ if (process.platform !== "win32") {
16
+ chmodSync(path, lstatSync(path).isDirectory() ? 0o700 : 0o600);
17
+ return;
18
+ }
19
+ const sid = currentWindowsSid();
20
+ const args = [
21
+ path,
22
+ "/inheritance:r",
23
+ "/grant:r", `*${sid}:(F)`,
24
+ "/grant:r", "*S-1-5-18:(F)",
25
+ "/grant:r", "*S-1-5-32-544:(F)",
26
+ ];
27
+ const result = spawnSync("icacls.exe", args, { encoding: "utf8", windowsHide: true });
28
+ if (result.status !== 0)
29
+ throw new Error("Unable to restrict Windows ACLs for private Cairnkeep state.");
30
+ }
31
+ export function privatePathIsSafe(path) {
32
+ if (!existsSync(path))
33
+ return false;
34
+ const info = lstatSync(path);
35
+ if (info.isSymbolicLink())
36
+ return false;
37
+ if (process.platform !== "win32")
38
+ return (info.mode & 0o077) === 0;
39
+ const script = [
40
+ "$p=$env:CK_INTERNAL_PRIVATE_PATH",
41
+ "$me=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
42
+ "$ok=@($me,'S-1-5-18','S-1-5-32-544')",
43
+ "$acl=Get-Acl -LiteralPath $p",
44
+ "foreach($a in $acl.Access){",
45
+ "if($a.AccessControlType -eq 'Allow'){",
46
+ "try{$sid=$a.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value}catch{exit 1}",
47
+ "if($ok -notcontains $sid){exit 1}",
48
+ "}",
49
+ "}",
50
+ "exit 0",
51
+ ].join(";");
52
+ const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
53
+ encoding: "utf8",
54
+ windowsHide: true,
55
+ env: { ...process.env, CK_INTERNAL_PRIVATE_PATH: path },
56
+ });
57
+ return result.status === 0;
58
+ }
59
+ function delay(milliseconds) {
60
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
61
+ }
62
+ export async function atomicReplace(source, destination) {
63
+ if (process.platform === "win32") {
64
+ let lastError = "Windows atomic replacement failed.";
65
+ for (let attempt = 0; attempt < 12; attempt += 1) {
66
+ if (!existsSync(destination)) {
67
+ try {
68
+ await rename(source, destination);
69
+ return;
70
+ }
71
+ catch (error) {
72
+ lastError = error instanceof Error ? error.message : String(error);
73
+ }
74
+ }
75
+ else {
76
+ const backup = `${destination}.replace-backup-${randomUUID()}`;
77
+ const result = spawnSync("powershell.exe", [
78
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
79
+ "[IO.File]::Replace($env:CK_INTERNAL_ATOMIC_SOURCE,$env:CK_INTERNAL_ATOMIC_DESTINATION,$env:CK_INTERNAL_ATOMIC_BACKUP,$true)",
80
+ ], {
81
+ encoding: "utf8",
82
+ windowsHide: true,
83
+ env: {
84
+ ...process.env,
85
+ CK_INTERNAL_ATOMIC_SOURCE: source,
86
+ CK_INTERNAL_ATOMIC_DESTINATION: destination,
87
+ CK_INTERNAL_ATOMIC_BACKUP: backup,
88
+ },
89
+ });
90
+ if (result.status === 0) {
91
+ rmSync(backup, { force: true });
92
+ return;
93
+ }
94
+ rmSync(backup, { force: true });
95
+ lastError = result.stderr.trim() || result.stdout.trim() || lastError;
96
+ }
97
+ await delay(25 * (attempt + 1));
98
+ }
99
+ throw new Error(lastError);
100
+ }
101
+ await rename(source, destination);
102
+ }
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { constants, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync } from "node:fs";
3
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { constants, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { delimiter, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { canonicalDigest } from "./eval-schema.js";
5
5
  import { EvalProcessError, runBoundedJsonProcess } from "./eval-process.js";
6
6
  import { noteNodeSchema } from "./node-schema.js";
7
7
  import { getNoteLayout, listAddressedNotes } from "./note-store.js";
8
+ import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
8
9
  import { MAX_SKILL_BYTES, SKILL_SCHEMA_VERSION, skillAdapterConfigSchema, skillApplicationSchema, skillCandidateSchema, skillEvaluationSchema, skillProposalRequestSchema, skillProposalResponseSchema, skillProposalSchema, skillRelativePathSchema, } from "./skill-schema.js";
9
10
  const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
10
11
  const PROTECTED_ADAPTER_ENVIRONMENT = new Set([
@@ -38,10 +39,21 @@ function isContained(root, candidate) {
38
39
  const path = relative(root, candidate);
39
40
  return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
40
41
  }
42
+ function pathCrossesSymlink(path) {
43
+ const absolute = resolve(path);
44
+ const root = parse(absolute).root;
45
+ let cursor = root;
46
+ for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
47
+ cursor = join(cursor, segment);
48
+ if (lstatSync(cursor).isSymbolicLink())
49
+ return true;
50
+ }
51
+ return false;
52
+ }
41
53
  function requireProjectRoot(projectRoot) {
42
54
  const absolute = resolve(projectRoot);
43
55
  const info = lstatSync(absolute);
44
- if (info.isSymbolicLink() || !info.isDirectory() || realpathSync(absolute) !== absolute) {
56
+ if (!info.isDirectory() || pathCrossesSymlink(absolute)) {
45
57
  throw new Error("Project root must be a real, non-symlink directory.");
46
58
  }
47
59
  return absolute;
@@ -49,10 +61,10 @@ function requireProjectRoot(projectRoot) {
49
61
  function privateDirectory(path) {
50
62
  mkdirSync(path, { recursive: true, mode: 0o700 });
51
63
  const info = lstatSync(path);
52
- if (info.isSymbolicLink() || !info.isDirectory() || realpathSync(path) !== path) {
64
+ if (!info.isDirectory() || pathCrossesSymlink(path)) {
53
65
  throw new Error(`Unsafe skill state directory: ${path}`);
54
66
  }
55
- chmodSync(path, 0o700);
67
+ hardenPrivatePath(path);
56
68
  }
57
69
  export function getSkillStore(projectRoot = process.cwd(), create = true) {
58
70
  const rootProject = requireProjectRoot(projectRoot);
@@ -97,12 +109,13 @@ function atomicWrite(path, value) {
97
109
  finally {
98
110
  closeSync(descriptor);
99
111
  }
100
- chmodSync(temporary, 0o600);
112
+ hardenPrivatePath(temporary);
101
113
  renameSync(temporary, path);
114
+ hardenPrivatePath(path);
102
115
  }
103
116
  function readJson(path) {
104
117
  const info = lstatSync(path);
105
- if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_ARTIFACT_BYTES || (info.mode & 0o077) !== 0) {
118
+ if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_ARTIFACT_BYTES || !privatePathIsSafe(path)) {
106
119
  throw new Error(`Unsafe skill artifact: ${path}`);
107
120
  }
108
121
  const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
@@ -117,7 +130,7 @@ function listJson(directory, parse) {
117
130
  if (!existsSync(directory))
118
131
  return [];
119
132
  const info = lstatSync(directory);
120
- if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o077) !== 0)
133
+ if (info.isSymbolicLink() || !info.isDirectory() || !privatePathIsSafe(directory))
121
134
  throw new Error("Unsafe skill artifact directory.");
122
135
  return [...new Set(readdirSync(directory))]
123
136
  .filter((name) => /^[a-z0-9][a-z0-9._-]{0,127}\.json$/.test(name))
@@ -315,8 +328,10 @@ function loadAdapterConfig(path) {
315
328
  if (!isAbsolute(config.command.program))
316
329
  throw new Error("Skill adapter program must be an absolute path.");
317
330
  const programInfo = lstatSync(config.command.program);
318
- if (programInfo.isSymbolicLink() || !programInfo.isFile() || (programInfo.mode & 0o111) === 0
319
- || realpathSync(config.command.program) !== config.command.program) {
331
+ const windowsRunnable = /\.(?:cjs|mjs|js|exe|com)$/i.test(config.command.program);
332
+ if (!programInfo.isFile()
333
+ || pathCrossesSymlink(config.command.program)
334
+ || (process.platform === "win32" ? !windowsRunnable : (programInfo.mode & 0o111) === 0)) {
320
335
  throw new Error("Skill adapter program must be a real executable file.");
321
336
  }
322
337
  return { config, digest: canonicalDigest(config), program_digest: sha256File(config.command.program) };
@@ -502,7 +517,7 @@ export function applySkillProposal(options) {
502
517
  rolled_back_at: null,
503
518
  });
504
519
  writeFileSync(backupPath, target.content, { encoding: "utf8", mode: 0o600, flag: "wx" });
505
- chmodSync(backupPath, 0o600);
520
+ hardenPrivatePath(backupPath);
506
521
  try {
507
522
  atomicWriteTarget(target.path, proposal.candidate_content, proposal.target_mode);
508
523
  if (sha256(readFileSync(target.path)) !== proposal.candidate_content_digest) {
@@ -563,7 +578,7 @@ export function rollbackSkillApplication(options) {
563
578
  const backup = resolve(store.project_root, application.backup_path);
564
579
  const backupInfo = lstatSync(backup);
565
580
  if (!isContained(store.backups, backup) || backupInfo.isSymbolicLink() || !backupInfo.isFile()
566
- || backupInfo.size > MAX_SKILL_BYTES || (backupInfo.mode & 0o077) !== 0) {
581
+ || backupInfo.size > MAX_SKILL_BYTES || !privatePathIsSafe(backup)) {
567
582
  throw new Error("Skill backup is missing or unsafe.");
568
583
  }
569
584
  const original = readFileSync(backup, "utf8");
@@ -47,10 +47,11 @@
47
47
  "check:skill-lifecycle": "node scripts/smoke-skill-lifecycle.mjs",
48
48
  "check:graph-cli": "node scripts/smoke-graph-cli.mjs",
49
49
  "check:mcp-trust": "node scripts/smoke-mcp-trust.mjs",
50
+ "check:pi-mcp-bridge": "node scripts/smoke-pi-mcp-bridge.mjs",
50
51
  "check:context-pack": "node scripts/smoke-context-pack.mjs",
51
52
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
52
53
  "test": "npm run test:smoke",
53
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:context-pack",
54
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack",
54
55
  "test:search": "npm run build && npm run check:search",
55
56
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
56
57
  "test:all": "npm run test:memory"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.9.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",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "bin": {
11
11
  "cairn": "bin/cairn",
12
- "cairn-container": "scripts/cairn-container"
12
+ "cairn-container": "bin/cairn-container"
13
13
  },
14
14
  "files": [
15
15
  "bin/",
@@ -43,9 +43,10 @@
43
43
  },
44
44
  "scripts": {
45
45
  "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
46
- "fix:perms": "git ls-files -s | awk '$1==\"100755\"{print $4}' | xargs chmod 755",
46
+ "fix:perms": "node scripts/fix-permissions.mjs",
47
47
  "prepack": "npm run build:server && npm run fix:perms",
48
- "test": "for test in scripts/test-*.sh; do \"$test\" || exit 1; done",
48
+ "test": "node scripts/run-repository-tests.mjs",
49
+ "test:windows": "node scripts/test-windows-native.mjs",
49
50
  "audit:runtime": "node scripts/verify-dependency-security.mjs && npm audit --omit=dev --audit-level=high && npm --prefix mcp-memory-server audit --omit=dev --audit-level=high",
50
51
  "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run build:server && npm test",
51
52
  "prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server test --global=false --dry-run=false"
@@ -63,6 +64,7 @@
63
64
  "memory",
64
65
  "agent",
65
66
  "claude-code",
67
+ "codex-cli",
66
68
  "opencode",
67
69
  "qwen-code",
68
70
  "pi-coding-agent",
@@ -0,0 +1,61 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const CAIRN_ROOT = "@@INFRA_ROOT@@";
6
+ const BRIDGE_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "pi-mcp-bridge.js");
7
+
8
+ type BridgeModule = typeof import("../../mcp-memory-server/dist/pi-mcp-bridge.js");
9
+ type Bridge = Awaited<ReturnType<BridgeModule["connectCairnPiBridge"]>>;
10
+
11
+ function existingToolNames(pi: ExtensionAPI): Set<string> {
12
+ return new Set(pi.getAllTools().map((tool) => tool.name));
13
+ }
14
+
15
+ export default function cairnMemoryExtension(pi: ExtensionAPI): void {
16
+ let bridge: Bridge | undefined;
17
+ let startPromise: Promise<void> | undefined;
18
+ let shutdownPromise: Promise<void> | undefined;
19
+
20
+ const start = async (ctx: ExtensionContext): Promise<void> => {
21
+ const module = await import(pathToFileURL(BRIDGE_ENTRY).href) as BridgeModule;
22
+ const connected = await module.connectCairnPiBridge({ cwd: ctx.cwd, env: { ...process.env } });
23
+ try {
24
+ const tools = await connected.listAllTools();
25
+ const existing = existingToolNames(pi);
26
+ const collisions = tools.map(({ name }) => name).filter((name) => existing.has(name));
27
+ if (collisions.length) {
28
+ throw new Error(`Cairnkeep MCP tool collision would override an existing Pi tool: ${collisions.join(", ")}.`);
29
+ }
30
+ for (const tool of tools) {
31
+ pi.registerTool({
32
+ name: tool.name,
33
+ label: tool.title ?? tool.name,
34
+ description: tool.description ?? tool.title ?? tool.name,
35
+ parameters: tool.inputSchema,
36
+ execute: async (_callId, args, signal) => connected.call(tool, args, { signal }),
37
+ });
38
+ }
39
+ bridge = connected;
40
+ } catch (error) {
41
+ await connected.close().catch(() => undefined);
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
47
+ if (shutdownPromise) throw new Error("Cairnkeep MCP bridge is already shutting down.");
48
+ if (!startPromise) startPromise = start(ctx);
49
+ await startPromise;
50
+ });
51
+
52
+ pi.on("session_shutdown", async () => {
53
+ if (!shutdownPromise) {
54
+ shutdownPromise = (async () => {
55
+ await startPromise?.catch(() => undefined);
56
+ await bridge?.close();
57
+ })();
58
+ }
59
+ await shutdownPromise;
60
+ });
61
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/cairnkeep-setup-policy.schema.json",
4
+ "title": "Cairnkeep guided setup policy",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "defaults", "constraints"],
8
+ "properties": {
9
+ "schema_version": {"const": 1},
10
+ "defaults": {
11
+ "type": "object",
12
+ "additionalProperties": false,
13
+ "properties": {
14
+ "git": {"enum": ["init", "existing", "none"]},
15
+ "harnesses": {
16
+ "type": "array",
17
+ "minItems": 1,
18
+ "uniqueItems": true,
19
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
20
+ },
21
+ "memory": {"enum": ["local", "none"]}
22
+ }
23
+ },
24
+ "constraints": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "properties": {
28
+ "git": {
29
+ "type": "array",
30
+ "minItems": 1,
31
+ "uniqueItems": true,
32
+ "items": {"enum": ["init", "existing", "none"]}
33
+ },
34
+ "harnesses": {
35
+ "type": "array",
36
+ "minItems": 1,
37
+ "uniqueItems": true,
38
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
39
+ },
40
+ "required_harnesses": {
41
+ "type": "array",
42
+ "minItems": 1,
43
+ "uniqueItems": true,
44
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
45
+ },
46
+ "memory": {
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "uniqueItems": true,
50
+ "items": {"enum": ["local", "none"]}
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/cairnkeep-setup.schema.json",
4
+ "title": "Cairnkeep project setup state",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"],
8
+ "properties": {
9
+ "schema_version": {"const": 1},
10
+ "cairnkeep_version": {"type": "string", "minLength": 1, "maxLength": 128},
11
+ "git": {"enum": ["init", "existing", "none"]},
12
+ "memory": {"enum": ["local", "none"]},
13
+ "harnesses": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "uniqueItems": true,
17
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen", "codex"]}
18
+ },
19
+ "assets": {
20
+ "type": "object",
21
+ "propertyNames": {
22
+ "pattern": "^\\.(?:ai|planning|agentfs|codex)/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
23
+ },
24
+ "additionalProperties": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": ["digest", "mode", "template"],
28
+ "properties": {
29
+ "digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
30
+ "mode": {"type": "integer", "minimum": 0, "maximum": 511},
31
+ "template": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"}
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
@@ -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"