@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.
- package/CHANGELOG.md +68 -1
- package/README.md +82 -27
- package/bin/cairn +6 -153
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/context-packs.md +94 -0
- package/docs/harness-compatibility.md +37 -3
- package/docs/learning/CURRICULUM-MAP.md +16 -5
- package/docs/learning/FEATURE-GUIDE.md +15 -4
- package/docs/learning/README.md +8 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +10 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +157 -18
- package/docs/privacy-and-data-flow.md +51 -1
- package/docs/storage.md +29 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +889 -0
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +8 -5
- package/mcp-memory-server/dist/eval-runner.js +2 -0
- package/mcp-memory-server/dist/eval-schema.js +1 -0
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +87 -37
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +4 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +1 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +33 -5
- package/scripts/doctor.sh +96 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +44 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +53 -3
- package/scripts/test-package-install.sh +81 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +71 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +95 -7
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +725 -0
- 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,
|
|
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 (
|
|
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 (
|
|
64
|
+
if (!info.isDirectory() || pathCrossesSymlink(path)) {
|
|
53
65
|
throw new Error(`Unsafe skill state directory: ${path}`);
|
|
54
66
|
}
|
|
55
|
-
|
|
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
|
-
|
|
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 || (
|
|
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() || (
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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 || (
|
|
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.
|
|
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": "
|
|
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": "
|
|
46
|
+
"fix:perms": "node scripts/fix-permissions.mjs",
|
|
47
47
|
"prepack": "npm run build:server && npm run fix:perms",
|
|
48
|
-
"test": "
|
|
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
|
+
}
|