@cairnkeep/cli 2.8.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +82 -27
  3. package/bin/cairn +6 -153
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +41 -4
  6. package/docs/containers.md +10 -0
  7. package/docs/context-packs.md +94 -0
  8. package/docs/harness-compatibility.md +37 -3
  9. package/docs/learning/CURRICULUM-MAP.md +16 -5
  10. package/docs/learning/FEATURE-GUIDE.md +15 -4
  11. package/docs/learning/README.md +8 -2
  12. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  13. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  14. package/docs/learning/lessons/L02-installation.md +11 -2
  15. package/docs/learning/lessons/L03-first-project.md +1 -1
  16. package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
  17. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  18. package/docs/learning/lessons/L21-context-packs.md +79 -0
  19. package/docs/learning/lessons/L22-native-windows.md +83 -0
  20. package/docs/learning/lessons/L23-guided-setup.md +138 -0
  21. package/docs/learning/tracks/operator.md +10 -3
  22. package/docs/learning/tracks/quickstart.md +7 -3
  23. package/docs/mcp-tool-profiles.md +43 -0
  24. package/docs/native-windows.md +122 -0
  25. package/docs/operating.md +157 -18
  26. package/docs/privacy-and-data-flow.md +51 -1
  27. package/docs/storage.md +29 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  30. package/mcp-memory-server/dist/artifact-store.js +196 -55
  31. package/mcp-memory-server/dist/capability-cli.js +2 -1
  32. package/mcp-memory-server/dist/capability-config.js +4 -3
  33. package/mcp-memory-server/dist/capability-harness.js +34 -7
  34. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  35. package/mcp-memory-server/dist/context-pack.js +889 -0
  36. package/mcp-memory-server/dist/eval-cli.js +6 -5
  37. package/mcp-memory-server/dist/eval-plan.js +35 -13
  38. package/mcp-memory-server/dist/eval-process.js +11 -4
  39. package/mcp-memory-server/dist/eval-report.js +8 -5
  40. package/mcp-memory-server/dist/eval-runner.js +2 -0
  41. package/mcp-memory-server/dist/eval-schema.js +1 -0
  42. package/mcp-memory-server/dist/graph-cli.js +7 -1
  43. package/mcp-memory-server/dist/index.js +87 -37
  44. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  45. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  46. package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
  47. package/mcp-memory-server/dist/note-store.js +12 -3
  48. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  49. package/mcp-memory-server/dist/platform-security.js +102 -0
  50. package/mcp-memory-server/dist/skill-store.js +27 -12
  51. package/mcp-memory-server/package.json +4 -1
  52. package/package.json +5 -4
  53. package/pi/extensions/cairnkeep-memory.ts +61 -0
  54. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  55. package/schemas/cairnkeep-setup.schema.json +36 -0
  56. package/schemas/context-pack.schema.json +33 -0
  57. package/schemas/eval-report.schema.json +1 -0
  58. package/scripts/cairn-cli.mjs +119 -0
  59. package/scripts/cairn-container-cli.mjs +121 -0
  60. package/scripts/cairn-hook.mjs +199 -0
  61. package/scripts/completion.sh +33 -5
  62. package/scripts/doctor.sh +96 -0
  63. package/scripts/fix-permissions.mjs +12 -0
  64. package/scripts/memory-wiki-audit.mjs +95 -0
  65. package/scripts/phase26-test-manifest.mjs +26 -0
  66. package/scripts/run-repository-tests.mjs +76 -0
  67. package/scripts/setup-core.mjs +310 -0
  68. package/scripts/setup-reconcile.mjs +331 -0
  69. package/scripts/setup.mjs +306 -0
  70. package/scripts/sync-pi-assets.sh +19 -4
  71. package/scripts/test-cli-dispatch.sh +6 -0
  72. package/scripts/test-completion.sh +44 -0
  73. package/scripts/test-doctor.sh +66 -0
  74. package/scripts/test-learning-docs.sh +53 -3
  75. package/scripts/test-package-install.sh +81 -0
  76. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  77. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  78. package/scripts/test-pi-lifecycle.mjs +190 -0
  79. package/scripts/test-setup-compatibility.mjs +186 -0
  80. package/scripts/test-setup-output.sh +131 -0
  81. package/scripts/test-setup-overlay.sh +113 -0
  82. package/scripts/test-setup-preflight.mjs +281 -0
  83. package/scripts/test-setup-reconcile.mjs +257 -0
  84. package/scripts/test-uninstall.sh +71 -0
  85. package/scripts/test-windows-native.mjs +136 -0
  86. package/scripts/uninstall.sh +95 -7
  87. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  88. package/scripts/windows-platform.mjs +725 -0
  89. package/templates/env.example.template +11 -0
@@ -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");
@@ -46,9 +46,12 @@
46
46
  "check:eval-statistics": "node scripts/smoke-eval-statistics.mjs",
47
47
  "check:skill-lifecycle": "node scripts/smoke-skill-lifecycle.mjs",
48
48
  "check:graph-cli": "node scripts/smoke-graph-cli.mjs",
49
+ "check:mcp-trust": "node scripts/smoke-mcp-trust.mjs",
50
+ "check:pi-mcp-bridge": "node scripts/smoke-pi-mcp-bridge.mjs",
51
+ "check:context-pack": "node scripts/smoke-context-pack.mjs",
49
52
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
50
53
  "test": "npm run test:smoke",
51
- "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",
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",
52
55
  "test:search": "npm run build && npm run check:search",
53
56
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
54
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.8.0",
3
+ "version": "2.11.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"
@@ -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"]}
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"]}
39
+ },
40
+ "required_harnesses": {
41
+ "type": "array",
42
+ "minItems": 1,
43
+ "uniqueItems": true,
44
+ "items": {"enum": ["claude", "opencode", "pi", "kimi", "qwen"]}
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"]}
18
+ },
19
+ "assets": {
20
+ "type": "object",
21
+ "propertyNames": {
22
+ "pattern": "^\\.(?:ai|planning|agentfs)/(?!.*(?:^|/)\\.\\.(?:/|$))[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
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/context-pack.schema.json",
4
+ "title": "Cairnkeep context pack manifest v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "id", "version", "title", "description", "license", "files"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 },
11
+ "version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", "maxLength": 128 },
12
+ "title": { "type": "string", "minLength": 1, "maxLength": 256 },
13
+ "description": { "type": "string", "minLength": 1, "maxLength": 2048 },
14
+ "license": { "type": "string", "minLength": 1, "maxLength": 128 },
15
+ "files": {
16
+ "type": "array",
17
+ "maxItems": 1024,
18
+ "items": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "required": ["path", "kind", "title", "description", "keywords", "sha256"],
22
+ "properties": {
23
+ "path": { "type": "string", "minLength": 1, "maxLength": 512 },
24
+ "kind": { "enum": ["document", "skill"] },
25
+ "title": { "type": "string", "minLength": 1, "maxLength": 256 },
26
+ "description": { "type": "string", "maxLength": 2048 },
27
+ "keywords": { "type": "array", "maxItems": 64, "items": { "type": "string", "minLength": 1, "maxLength": 128 } },
28
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
@@ -412,6 +412,7 @@
412
412
  "package_version": { "type": "string", "minLength": 1, "maxLength": 128 },
413
413
  "runtime_id": { "$ref": "#/$defs/identifier" },
414
414
  "task_set_digest": { "$ref": "#/$defs/digest" },
415
+ "mcp_tool_profile_digest": { "$ref": "#/$defs/digest" },
415
416
  "report_digest": { "$ref": "#/$defs/digest" },
416
417
  "schema_digests": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/digest" } },
417
418
  "note_snapshot_digests": { "type": "array", "maxItems": 10000, "items": { "$ref": "#/$defs/digest" } },
@@ -0,0 +1,119 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { runWindowsCommand, powershellCompletion } from "./windows-platform.mjs";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ const NODE_COMMANDS = new Map([
10
+ ["memory-server", "index.js"],
11
+ ["trajectory", "trajectory-cli.js"],
12
+ ["artifact", "artifact-cli.js"],
13
+ ["capabilities", "capability-cli.js"],
14
+ ["mcp-tools", "mcp-tool-cli.js"],
15
+ ["pack", "context-pack-cli.js"],
16
+ ["notes", "note-cli.js"],
17
+ ["eval", "eval-cli.js"],
18
+ ["skill", "skill-cli.js"],
19
+ ["graph", "graph-cli.js"],
20
+ ]);
21
+
22
+ const POSIX_COMMANDS = new Map([
23
+ ["bootstrap", "bootstrap.sh"],
24
+ ["doctor", "doctor.sh"],
25
+ ["uninstall", "uninstall.sh"],
26
+ ["memory", "memory-store.sh"],
27
+ ["sync", "sync-claude-assets.sh"],
28
+ ["sync-pi", "sync-pi-assets.sh"],
29
+ ["sync-kimi", "sync-kimi-assets.sh"],
30
+ ["audit-timer", "install-audit-timer.sh"],
31
+ ["completion", "completion.sh"],
32
+ ]);
33
+
34
+ export const USAGE = `cairn — Cairnkeep CLI
35
+
36
+ Usage:
37
+ cairn bootstrap [--untracked] [path]
38
+ cairn setup PATH --git init|existing|none --harness LIST --memory local|none [--policy PATH] --yes [--json]
39
+ cairn memory-server
40
+ cairn sync [--check|--apply] [--live-root DIR]
41
+ cairn sync-pi [--check|--apply] [--live-root DIR]
42
+ cairn sync-kimi [--check|--apply] [--live-root DIR]
43
+ cairn doctor [--repair]
44
+ cairn trajectory <list|show|prune>
45
+ cairn artifact <list|show|delete|prune>
46
+ cairn capabilities <list|status|enable|disable|reset|logging>
47
+ cairn mcp-tools <list|status|set|reset>
48
+ cairn pack <init|lock|validate|install|list|show|remove|enable|disable|update|skills|approve-skill|revoke-skill>
49
+ cairn notes <distill|search-error|promote|doctor>
50
+ cairn eval <validate|run|ablate|report|prune|delete>
51
+ cairn skill <harvest|list|show|review|propose|evaluate|apply|rollback>
52
+ cairn graph <build|query|status|diff|explain|path>
53
+ cairn memory <path|export|import>
54
+ cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
55
+ cairn uninstall [--dry-run] [--yes] [--purge-memory] [--purge-packs] [PROJECT ...]
56
+ cairn completion bash|zsh|fish|powershell
57
+ cairn version
58
+ cairn help
59
+
60
+ Native Windows support does not require WSL or Git Bash. Windows x64 is the
61
+ supported native target; Windows ARM64 currently runs through x64 emulation.`;
62
+
63
+ function run(executable, args, options = {}) {
64
+ const result = spawnSync(executable, args, { stdio: "inherit", ...options });
65
+ if (result.error) throw result.error;
66
+ process.exitCode = result.status ?? 1;
67
+ }
68
+
69
+ function version() {
70
+ const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
71
+ console.log(`cairnkeep ${pkg.version}`);
72
+ }
73
+
74
+ export async function main(argv) {
75
+ const [command = "help", ...args] = argv;
76
+
77
+ if (["help", "--help", "-h"].includes(command)) {
78
+ console.log(USAGE);
79
+ return;
80
+ }
81
+ if (["version", "--version", "-v"].includes(command)) {
82
+ version();
83
+ return;
84
+ }
85
+
86
+ const nodeEntry = NODE_COMMANDS.get(command);
87
+ if (nodeEntry) {
88
+ // Preserve the historical dispatcher contract: child Node resolution comes
89
+ // from PATH, which supports managed runtimes and test/probe wrappers.
90
+ run("node", [resolve(ROOT, "mcp-memory-server", "dist", nodeEntry), ...args]);
91
+ return;
92
+ }
93
+
94
+ if (command === "completion" && args[0] === "powershell") {
95
+ process.stdout.write(powershellCompletion());
96
+ return;
97
+ }
98
+
99
+ if (command === "setup" && process.platform !== "win32") {
100
+ const { runSetup } = await import("./setup.mjs");
101
+ process.exitCode = await runSetup(args);
102
+ return;
103
+ }
104
+
105
+ if (process.platform === "win32") {
106
+ const handled = await runWindowsCommand({ command, args, root: ROOT });
107
+ if (handled) return;
108
+ } else {
109
+ const script = POSIX_COMMANDS.get(command);
110
+ if (script) {
111
+ run(resolve(ROOT, "scripts", script), args);
112
+ return;
113
+ }
114
+ }
115
+
116
+ console.error(`Unknown command: ${command}`);
117
+ console.error(USAGE);
118
+ process.exitCode = 2;
119
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const USAGE = `Usage:
9
+ cairn-container stdio [--image IMAGE] [--volume VOLUME]
10
+ cairn-container http --token-file FILE [--port PORT] [--name NAME]
11
+ [--allowed-hosts HOSTS] [--image IMAGE]
12
+ [--volume VOLUME] [--env-file FILE]
13
+ cairn-container workspace --repo PATH [--mode sandbox|shared]
14
+ [--image IMAGE] [--data-volume VOLUME]
15
+ [--workspace-volume VOLUME] [--home-volume VOLUME]
16
+ [--env-file FILE] [--secret NAME=FILE]... [--] [COMMAND...]`;
17
+
18
+ function options(args) {
19
+ const values = new Map();
20
+ const rest = [];
21
+ const valued = new Set(["--image", "--volume", "--token-file", "--port", "--name", "--allowed-hosts", "--env-file", "--repo", "--mode", "--data-volume", "--workspace-volume", "--home-volume", "--secret"]);
22
+ for (let index = 0; index < args.length; index += 1) {
23
+ if (args[index] === "--") { rest.push(...args.slice(index + 1)); break; }
24
+ if (!args[index].startsWith("--")) { rest.push(...args.slice(index)); break; }
25
+ if (!valued.has(args[index])) throw new Error(`unknown option: ${args[index]}`);
26
+ if (index + 1 >= args.length) throw new Error(`${args[index]} requires a value`);
27
+ const value = args[++index];
28
+ if (args[index - 1] === "--secret") values.set("--secret", [...(values.get("--secret") ?? []), value]);
29
+ else values.set(args[index - 1], value);
30
+ }
31
+ return { values, rest };
32
+ }
33
+
34
+ function regularFile(path) {
35
+ const absolute = resolve(path);
36
+ if (!existsSync(absolute)) throw new Error(`not a regular file: ${path}`);
37
+ const info = lstatSync(absolute);
38
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error(`refusing non-regular or symlink file: ${path}`);
39
+ if (process.platform === "win32") {
40
+ const script = "$m=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$a=Get-Acl -LiteralPath $args[0];$ok=@($m,'S-1-5-18','S-1-5-32-544');foreach($e in $a.Access){if($e.AccessControlType -eq 'Allow'){$s=$e.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;if($ok -notcontains $s){exit 1}}};exit 0";
41
+ const acl = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script, absolute], { stdio: "ignore", windowsHide: true });
42
+ if (acl.status !== 0) throw new Error(`secret file ACL must be restricted to the current user and system administrators: ${path}`);
43
+ }
44
+ return absolute;
45
+ }
46
+
47
+ function execute(engine, args) {
48
+ const result = spawnSync(engine, args, { stdio: "inherit", windowsHide: true });
49
+ if (result.error) throw result.error;
50
+ process.exitCode = result.status ?? 1;
51
+ }
52
+
53
+ function baseArgs() {
54
+ return ["run", "--rm", "--userns=keep-id:uid=10001,gid=10001", "--cap-drop=all", "--security-opt=no-new-privileges", "--read-only", "--tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777"];
55
+ }
56
+
57
+ export function runNativeContainer(args, root = ROOT, executor = execute) {
58
+ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
59
+ const engine = process.env.CONTAINER_ENGINE || "podman.exe";
60
+ const serverImage = process.env.CAIRNKEEP_CONTAINER_IMAGE || `ghcr.io/cairnkeep/cairnkeep:${pkg.version}`;
61
+ const workspaceImage = process.env.CAIRNKEEP_WORKSPACE_IMAGE || `ghcr.io/cairnkeep/cairnkeep-workspace:${pkg.version}`;
62
+ const [command = "help", ...commandArgs] = args;
63
+ if (["help", "-h", "--help"].includes(command)) { console.log(USAGE); return; }
64
+ const { values, rest } = options(commandArgs);
65
+ const common = baseArgs();
66
+ if (command === "stdio") {
67
+ executor(engine, [...common, "-i", "--volume", `${values.get("--volume") || "cairnkeep-data"}:/data:Z,U`, values.get("--image") || serverImage, "stdio"]);
68
+ return;
69
+ }
70
+ if (command === "http") {
71
+ if (!values.get("--token-file")) throw new Error("http mode requires --token-file");
72
+ const token = regularFile(values.get("--token-file"));
73
+ const port = values.get("--port") || "7801";
74
+ if (!/^\d+$/.test(port)) throw new Error(`invalid port: ${port}`);
75
+ const hosts = values.get("--allowed-hosts") || `localhost:${port},127.0.0.1:${port},localhost:7801,127.0.0.1:7801`;
76
+ const extra = values.get("--env-file") ? ["--env-file", regularFile(values.get("--env-file"))] : [];
77
+ executor(engine, [...common, "--detach", "--replace", "--name", values.get("--name") || "cairnkeep", ...extra,
78
+ "--publish", `127.0.0.1:${port}:7801`, "--volume", `${values.get("--volume") || "cairnkeep-data"}:/data:Z,U`,
79
+ "--volume", `${token}:/run/secrets/http-token:ro,Z`, "--env", "MCP_HTTP_HOST=0.0.0.0", "--env", "MCP_HTTP_PORT=7801",
80
+ "--env", `CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=${hosts}`, "--env", "CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token",
81
+ values.get("--image") || serverImage, "http"]);
82
+ return;
83
+ }
84
+ if (command === "workspace") {
85
+ if (!values.get("--repo")) throw new Error("workspace mode requires --repo");
86
+ const repo = resolve(values.get("--repo"));
87
+ if (!existsSync(repo) || !lstatSync(repo).isDirectory()) throw new Error(`not a directory: ${repo}`);
88
+ const mode = values.get("--mode") || "sandbox";
89
+ if (!["sandbox", "shared"].includes(mode)) throw new Error("--mode must be sandbox or shared");
90
+ const slug = basename(repo).toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
91
+ const id = createHash("sha256").update(repo).digest("hex").slice(0, 12);
92
+ const workspace = values.get("--workspace-volume") || `cairnkeep-workspace-${slug}-${id}`;
93
+ const mounts = mode === "shared" ? ["--volume", `${repo}:/workspace:rw,Z`] : ["--volume", `${repo}:/source:ro,Z`, "--volume", `${workspace}:/workspace:Z,U`];
94
+ const secrets = [];
95
+ for (const specification of values.get("--secret") ?? []) {
96
+ const index = specification.indexOf("=");
97
+ if (index < 1) throw new Error("--secret requires NAME=FILE");
98
+ const name = specification.slice(0, index);
99
+ if (!/^[A-Z0-9_]+$/.test(name)) throw new Error(`invalid secret variable name: ${name}`);
100
+ const file = regularFile(specification.slice(index + 1));
101
+ const target = name.toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
102
+ secrets.push("--volume", `${file}:/run/secrets/${target}:ro,Z`, "--env", `${name}_FILE=/run/secrets/${target}`);
103
+ }
104
+ const envFile = values.get("--env-file") ? ["--env-file", regularFile(values.get("--env-file"))] : [];
105
+ executor(engine, [...common, "-i", "--volume", `${values.get("--data-volume") || "cairnkeep-data"}:/data:Z,U`,
106
+ "--volume", `${values.get("--home-volume") || "cairnkeep-home"}:/home/cairn:Z,U`, "--env", `CAIRN_WORKSPACE_MODE=${mode}`,
107
+ ...mounts, ...envFile, ...secrets, values.get("--image") || workspaceImage, ...(rest.length ? rest : ["bash"])]);
108
+ return;
109
+ }
110
+ throw new Error(`unknown command: ${command}`);
111
+ }
112
+
113
+ export async function main(args) {
114
+ if (process.platform !== "win32") {
115
+ const result = spawnSync(resolve(ROOT, "scripts", "cairn-container"), args, { stdio: "inherit" });
116
+ if (result.error) throw result.error;
117
+ process.exitCode = result.status ?? 1;
118
+ return;
119
+ }
120
+ runNativeContainer(args);
121
+ }