@kal-elsam/kairo-runtime 0.13.1 → 0.15.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 (53) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +12 -10
  3. package/bin/kairo-runtime.js +0 -0
  4. package/bin/kairo.js +0 -0
  5. package/package.json +5 -1
  6. package/scripts/cockpit-smoke.mjs +14 -10
  7. package/src/cli.js +182 -11
  8. package/src/global/check-resolutions.js +31 -0
  9. package/src/global/cli-help.js +23 -4
  10. package/src/global/component-ecosystem-checks.js +2 -0
  11. package/src/global/component-integration-cli.js +29 -10
  12. package/src/global/components-resolve-cli.js +246 -0
  13. package/src/global/connection-actions.js +147 -0
  14. package/src/global/connections.js +269 -0
  15. package/src/global/fleet-configure-plan.js +123 -0
  16. package/src/global/fleet-configure.js +303 -0
  17. package/src/global/fleet-models.js +188 -0
  18. package/src/global/fleet-set.js +219 -0
  19. package/src/global/fleet-shared.js +38 -0
  20. package/src/global/ink/cockpit-controller.js +2 -4
  21. package/src/global/ink/cockpit-enter.js +3 -1
  22. package/src/global/ink/cockpit-focus.js +7 -1
  23. package/src/global/ink/cockpit-models.js +33 -21
  24. package/src/global/ink/cockpit-palette.js +8 -3
  25. package/src/global/ink/cockpit-views.js +9 -2
  26. package/src/global/ink/orchestrator-app.js +42 -4
  27. package/src/global/ink/ux/live-overview.js +53 -26
  28. package/src/global/ink/ux/overview-actions.js +80 -0
  29. package/src/global/ink/ux/overview-needs.js +1 -1
  30. package/src/global/integrations/engram-evidence.js +7 -2
  31. package/src/global/integrations/sdd-apply.js +17 -7
  32. package/src/global/integrations/sdd-evidence.js +22 -3
  33. package/src/global/integrations/sdd-plan.js +21 -3
  34. package/src/global/integrations/sdd-resolutions.js +73 -0
  35. package/src/global/integrations/sdd-state.js +69 -0
  36. package/src/global/integrations/sdd-verify.js +9 -4
  37. package/src/global/mcp/kairo-mcp.js +56 -5
  38. package/src/global/mcp/resolve-mcp-workspace.js +51 -0
  39. package/src/global/mcp/work-snapshot-rule.js +89 -0
  40. package/src/global/mcp/work-snapshot-tool.js +49 -0
  41. package/src/global/mcp-install.js +239 -0
  42. package/src/global/next/next-cli.js +35 -0
  43. package/src/global/next/next-report.js +145 -0
  44. package/src/global/next/project-key.js +36 -0
  45. package/src/global/next/publish-work-snapshot.js +116 -0
  46. package/src/global/next/work-enroll.js +91 -0
  47. package/src/global/next/work-snapshot.js +216 -0
  48. package/src/global/observability/fleet-activity.js +197 -0
  49. package/src/global/observability/fleet-models-catalog.js +137 -0
  50. package/src/global/observability/fleet-platforms.js +166 -0
  51. package/src/global/observability/fleet-probe.js +229 -0
  52. package/src/global/paths.js +2 -1
  53. package/src/global/self-update.js +216 -0
@@ -8,6 +8,7 @@ export function defaultSddState() {
8
8
  personaAgentIds: [],
9
9
  agentIds: [],
10
10
  files: [],
11
+ adopted: [],
11
12
  lastReceiptId: null,
12
13
  updatedAt: null
13
14
  };
@@ -25,6 +26,7 @@ export function normalizeSddState(raw) {
25
26
  personaAgentIds,
26
27
  agentIds: Array.isArray(raw.agentIds) ? [...raw.agentIds] : [],
27
28
  files: Array.isArray(raw.files) ? raw.files.map(normalizeSddFile) : [],
29
+ adopted: Array.isArray(raw.adopted) ? raw.adopted.map(normalizeAdoptedFile).filter(Boolean) : [],
28
30
  lastReceiptId: typeof raw.lastReceiptId === "string" ? raw.lastReceiptId : null,
29
31
  updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null
30
32
  };
@@ -42,6 +44,72 @@ function normalizeSddFile(entry) {
42
44
  };
43
45
  }
44
46
 
47
+ function normalizeAdoptedFile(entry) {
48
+ if (!entry || typeof entry !== "object") return null;
49
+ if (typeof entry.destinationPath !== "string" || !entry.destinationPath) return null;
50
+ if (typeof entry.hash !== "string" || !entry.hash) return null;
51
+ return {
52
+ destinationPath: entry.destinationPath,
53
+ hash: entry.hash,
54
+ skillId: typeof entry.skillId === "string" ? entry.skillId : null,
55
+ agentIds: Array.isArray(entry.agentIds) ? [...entry.agentIds] : [],
56
+ relativePath: typeof entry.relativePath === "string" ? entry.relativePath : "SKILL.md",
57
+ adoptedAt: typeof entry.adoptedAt === "string" ? entry.adoptedAt : null,
58
+ reason: typeof entry.reason === "string" ? entry.reason : null
59
+ };
60
+ }
61
+
62
+ /** Map destinationPath → adopted hash for verify/plan. */
63
+ export function adoptedHashesFromState(sdd) {
64
+ const adopted = normalizeSddState(sdd).adopted;
65
+ return Object.fromEntries(adopted.map((entry) => [entry.destinationPath, entry.hash]));
66
+ }
67
+
68
+ /** Record conflict findings as adopted disk hashes (no file writes). */
69
+ export function recordSddAdoptions(state, {
70
+ adoptions = [],
71
+ now = () => new Date().toISOString()
72
+ } = {}) {
73
+ const current = normalizeSddState(state?.sdd);
74
+ const byPath = new Map(current.adopted.map((entry) => [entry.destinationPath, entry]));
75
+ for (const entry of adoptions) {
76
+ const normalized = normalizeAdoptedFile({
77
+ ...entry,
78
+ adoptedAt: entry.adoptedAt ?? now()
79
+ });
80
+ if (!normalized) continue;
81
+ byPath.set(normalized.destinationPath, normalized);
82
+ }
83
+ const adopted = [...byPath.values()].sort((a, b) =>
84
+ a.destinationPath.localeCompare(b.destinationPath)
85
+ );
86
+ return {
87
+ ...(state ?? {}),
88
+ sdd: {
89
+ ...current,
90
+ adopted,
91
+ updatedAt: now()
92
+ }
93
+ };
94
+ }
95
+
96
+ /** Drop adopted entries for paths that become Kairo-managed (overwrite/apply). */
97
+ export function clearSddAdoptionsForPaths(state, paths = [], {
98
+ now = () => new Date().toISOString()
99
+ } = {}) {
100
+ const current = normalizeSddState(state?.sdd);
101
+ const drop = new Set(paths);
102
+ const adopted = current.adopted.filter((entry) => !drop.has(entry.destinationPath));
103
+ return {
104
+ ...(state ?? {}),
105
+ sdd: {
106
+ ...current,
107
+ adopted,
108
+ updatedAt: now()
109
+ }
110
+ };
111
+ }
112
+
45
113
  function verifiedNoopHash(file) {
46
114
  const disk = file.afterHash ?? file.diskHash ?? file.beforeHash ?? null;
47
115
  if (disk == null || file.canonicalHash == null || disk !== file.canonicalHash) return null;
@@ -116,6 +184,7 @@ export function recordSddMaterialization(state, { receipt, now = () => new Date(
116
184
  personaAgentIds,
117
185
  agentIds: collectAgentIds(files),
118
186
  files,
187
+ adopted: current.adopted,
119
188
  lastReceiptId: receipt.id ?? current.lastReceiptId,
120
189
  updatedAt: now()
121
190
  }
@@ -26,6 +26,7 @@ export async function verifySddConfigure({
26
26
  homeDir,
27
27
  packageRoot,
28
28
  trackedFiles = {},
29
+ adoptedFiles = {},
29
30
  personaAgentIds = [],
30
31
  exists = existsSync,
31
32
  readFileImpl = readFile
@@ -49,14 +50,15 @@ export async function verifySddConfigure({
49
50
  const fileExists = exists(destinationPath);
50
51
  const diskHash = fileExists ? hashBuffer(await readFileImpl(destinationPath)) : null;
51
52
  const trackedHash = trackedFiles[destinationPath] ?? null;
53
+ const adoptedHash = adoptedFiles[destinationPath] ?? null;
52
54
  const health = classifySddVerifyHealth({
53
- exists: fileExists, canonicalHash, diskHash, trackedHash
55
+ exists: fileExists, canonicalHash, diskHash, trackedHash, adoptedHash
54
56
  });
55
57
  findings.push({
56
58
  skillId, relativePath: file.relativePath, destinationPath,
57
59
  agentIds: [...group.agentIds], kind: group.kind,
58
60
  status: health.status, drift: health.drift, reason: health.reason,
59
- canonicalHash, skillHash, diskHash, trackedHash
61
+ canonicalHash, skillHash, diskHash, trackedHash, adoptedHash
60
62
  });
61
63
  }
62
64
  }
@@ -66,13 +68,15 @@ export async function verifySddConfigure({
66
68
  || compareSkillPaths(a.relativePath, b.relativePath)
67
69
  || compareSkillPaths(a.destinationPath, b.destinationPath));
68
70
 
69
- const summary = { configured: 0, missing: 0, drifted: 0, conflict: 0 };
71
+ const summary = { configured: 0, adopted: 0, missing: 0, drifted: 0, conflict: 0 };
70
72
  for (const entry of findings) summary[entry.status] += 1;
71
73
 
72
74
  const consumers = normalizePersonaAgentIds(personaAgentIds);
73
75
  const incompleteAgentIds = consumers.filter((id) => {
74
76
  const mine = findings.filter((e) => e.agentIds.includes(id));
75
- return !mine.length || mine.some((e) => e.status !== SDD_HEALTH.CONFIGURED);
77
+ return !mine.length || mine.some((e) =>
78
+ e.status !== SDD_HEALTH.CONFIGURED && e.status !== SDD_HEALTH.ADOPTED
79
+ );
76
80
  });
77
81
  let gatePresent = true;
78
82
  for (const id of consumers) {
@@ -100,6 +104,7 @@ export function summarizeSddHealth(summary) {
100
104
  if (summary.conflict > 0) return SDD_HEALTH.CONFLICT;
101
105
  if (summary.missing > 0) return SDD_HEALTH.MISSING;
102
106
  if (summary.drifted > 0) return SDD_HEALTH.DRIFTED;
107
+ if ((summary.adopted ?? 0) > 0 && (summary.configured ?? 0) === 0) return SDD_HEALTH.ADOPTED;
103
108
  return SDD_HEALTH.CONFIGURED;
104
109
  }
105
110
 
@@ -12,10 +12,20 @@ import { runGraphifyOp } from "../observability/graphify-ops.js";
12
12
  import { resolveGitHeadSha } from "../observability/graphify-probe.js";
13
13
  import { runPassiveObservabilitySnapshot } from "../observability/passive-snapshot-flight.js";
14
14
  import { inspectEngramIntegration } from "../integrations/engram-evidence.js";
15
+ import { buildFleetReport } from "../observability/fleet-probe.js";
16
+ import {
17
+ createPublishWorkSnapshotHandler,
18
+ workSnapshotPublishSchema
19
+ } from "./work-snapshot-tool.js";
20
+ import { resolveMcpWorkspaceCwd } from "./resolve-mcp-workspace.js";
21
+
22
+ /** Sole MCP write tool for companion snapshots. */
23
+ export const KAIRO_MCP_WRITE_TOOLS = Object.freeze(["kairo_publish_work_snapshot"]);
15
24
 
16
25
  export const KAIRO_MCP_TOOLS = Object.freeze([
17
26
  "kairo_status", "kairo_runs", "kairo_alerts", "kairo_gentle_status",
18
- "kairo_graph_query", "kairo_graph_path", "kairo_context_summary"
27
+ "kairo_graph_query", "kairo_graph_path", "kairo_context_summary", "kairo_fleet",
28
+ ...KAIRO_MCP_WRITE_TOOLS
19
29
  ]);
20
30
 
21
31
  const empty = z.object({});
@@ -30,7 +40,8 @@ export const mcpSchemas = Object.freeze({
30
40
  graph: z.string().min(1), question: z.string().min(1),
31
41
  budget: z.number().int().min(1).max(8000).default(2000)
32
42
  }),
33
- graphPath: z.object({ graph: z.string().min(1), from: z.string().min(1), to: z.string().min(1) })
43
+ graphPath: z.object({ graph: z.string().min(1), from: z.string().min(1), to: z.string().min(1) }),
44
+ workSnapshotPublish: workSnapshotPublishSchema
34
45
  });
35
46
 
36
47
  const CODE_RE = /^(?:[a-z][a-z0-9_]{0,48}|status=\d+)$/;
@@ -94,7 +105,11 @@ function graphEnvelope(result) {
94
105
 
95
106
  export function createToolHandlers(deps = {}) {
96
107
  const homeDir = deps.homeDir ?? resolveHomeDir();
97
- const cwd = deps.cwd ?? process.cwd();
108
+ // Cursor may spawn MCP under $HOME; prefer VSCODE_CWD / WORKSPACE_FOLDER_PATHS.
109
+ const cwd = resolveMcpWorkspaceCwd({
110
+ cwd: deps.cwd,
111
+ env: deps.env ?? process.env
112
+ });
98
113
  const listRuns = deps.listRuns ?? ((o) => listRunRecords(homeDir, o));
99
114
  const listAlertRows = deps.listAlerts ?? ((o) => listAlerts({ homeDir, ...o }));
100
115
  const listReviews = deps.listReviews ?? (() => listReviewReceipts({ homeDir, limit: 20 }));
@@ -117,6 +132,7 @@ export function createToolHandlers(deps = {}) {
117
132
  observabilityContext: { cwd, homeDir, workspaceRoot: cwd, headSha: requestHead() }
118
133
  }));
119
134
  const gentleProbe = deps.probeGentle ?? ((ctx) => probeGentle(ctx));
135
+ const fleetProbe = deps.buildFleet ?? ((ctx) => buildFleetReport(ctx));
120
136
  const graphOp = deps.runGraphifyOp ?? runGraphifyOp;
121
137
  const gOpts = () => ({
122
138
  cwd, workspaceRoot: cwd, headSha: requestHead(), whichCommand: deps.whichCommand,
@@ -200,7 +216,36 @@ export function createToolHandlers(deps = {}) {
200
216
  } catch {
201
217
  return soft("degraded", { signals: null, engram: null, links: [], alertsCount: null, nextSafeAction: null });
202
218
  }
203
- }
219
+ },
220
+ async kairo_fleet() {
221
+ try {
222
+ const report = await fleetProbe({ homeDir });
223
+ return mcpResult({
224
+ ok: true,
225
+ code: "ok",
226
+ data: {
227
+ kind: report?.kind ?? "declared",
228
+ note: report?.note ?? null,
229
+ orchestratorAuthority: report?.orchestratorAuthority ?? null,
230
+ fleets: Array.isArray(report?.fleets) ? report.fleets : [],
231
+ activity: report?.activity ?? null,
232
+ generatedAt: report?.generatedAt ?? null
233
+ }
234
+ });
235
+ } catch {
236
+ return soft("degraded", {
237
+ kind: "declared", fleets: [], activity: null, note: null, orchestratorAuthority: null
238
+ });
239
+ }
240
+ },
241
+ kairo_publish_work_snapshot: createPublishWorkSnapshotHandler({
242
+ homeDir,
243
+ cwd,
244
+ now: deps.now,
245
+ writeAtomic: deps.writeAtomic,
246
+ publishWorkSnapshot: deps.publishWorkSnapshot,
247
+ mcpResult
248
+ })
204
249
  };
205
250
  }
206
251
 
@@ -213,7 +258,13 @@ export function registerKairoMcpTools(registerTool, deps = {}) {
213
258
  ["kairo_gentle_status", "Gentle probe / companion gentle signal", empty],
214
259
  ["kairo_graph_query", "Read-only Graphify query", mcpSchemas.graphQuery],
215
260
  ["kairo_graph_path", "Read-only Graphify path", mcpSchemas.graphPath],
216
- ["kairo_context_summary", "Companion + soft links + alerts count", empty]
261
+ ["kairo_context_summary", "Companion + soft links + alerts count", empty],
262
+ ["kairo_fleet", "Declared fleet topology + OpenCode live activity", empty],
263
+ [
264
+ "kairo_publish_work_snapshot",
265
+ "Publish kairo.work-snapshot/v1 for the runtime workspace (enrolls conversation)",
266
+ mcpSchemas.workSnapshotPublish
267
+ ]
217
268
  ]) registerTool(name, { description, inputSchema }, h[name]);
218
269
  return h;
219
270
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Resolve the workspace path for Kairo MCP identity.
3
+ *
4
+ * Cursor IDE launches global `mcpServers.kairo` with process cwd = $HOME even
5
+ * when the entry sets `cwd: "."`. It does inject the open folder via
6
+ * VSCODE_CWD / WORKSPACE_FOLDER_PATHS — prefer those over process.cwd().
7
+ */
8
+ import { resolve } from "node:path";
9
+ import { canonicalizeProjectPath } from "../next/project-key.js";
10
+
11
+ function firstNonEmpty(value) {
12
+ if (typeof value !== "string") return null;
13
+ const trimmed = value.trim();
14
+ return trimmed ? trimmed : null;
15
+ }
16
+
17
+ /**
18
+ * Parse Cursor/VS Code workspace folder env into an ordered path list.
19
+ * WORKSPACE_FOLDER_PATHS uses commas when Cursor opens multiple roots.
20
+ */
21
+ export function parseWorkspaceFolderPaths(raw) {
22
+ const text = firstNonEmpty(raw);
23
+ if (!text) return [];
24
+ return text
25
+ .split(",")
26
+ .map((part) => part.trim())
27
+ .filter(Boolean);
28
+ }
29
+
30
+ /**
31
+ * @param {{ cwd?: string, env?: NodeJS.ProcessEnv }} [options]
32
+ * @returns {string} absolute workspace path
33
+ */
34
+ export function resolveMcpWorkspaceCwd({
35
+ cwd,
36
+ env = process.env
37
+ } = {}) {
38
+ let chosen;
39
+ if (typeof cwd === "string" && cwd.trim()) {
40
+ chosen = resolve(cwd.trim());
41
+ } else {
42
+ const fromFolders = parseWorkspaceFolderPaths(env.WORKSPACE_FOLDER_PATHS);
43
+ if (fromFolders.length > 0) {
44
+ chosen = resolve(fromFolders[0]);
45
+ } else {
46
+ const vscodeCwd = firstNonEmpty(env.VSCODE_CWD);
47
+ chosen = vscodeCwd ? resolve(vscodeCwd) : resolve(process.cwd());
48
+ }
49
+ }
50
+ return canonicalizeProjectPath(chosen);
51
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Managed Cursor rule: instruct agents to publish kairo.work-snapshot/v1.
3
+ */
4
+ import { mkdir, readFile, writeFile, rename, copyFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ export const WORK_SNAPSHOT_RULE_FILENAME = "kairo-work-snapshot.mdc";
10
+
11
+ export function resolveCursorRulesDir(homeDir = homedir()) {
12
+ return join(homeDir, ".cursor", "rules");
13
+ }
14
+
15
+ export function resolveWorkSnapshotRulePath(homeDir = homedir()) {
16
+ return join(resolveCursorRulesDir(homeDir), WORK_SNAPSHOT_RULE_FILENAME);
17
+ }
18
+
19
+ export const WORK_SNAPSHOT_RULE_BODY = `# Kairo work snapshot
20
+
21
+ After each significant turn, publish the true work state with MCP \`kairo_publish_work_snapshot\`:
22
+
23
+ - Required: \`conversationId\`, \`provider\` (\`cursor\`), \`goal\`, \`now\`, \`next\`
24
+ - Optional: \`progress\` (≤3), \`blockers\`, \`delegations\` (only real ones)
25
+ - Workspace identity is derived by Kairo from the runtime — never send \`projectKey\`, paths, or \`cwd\`
26
+ - Never invent work. Never send prompts, transcripts, or tool dumps
27
+ - Reuse the same \`conversationId\` for later turns in this chat
28
+ `;
29
+
30
+ export function buildWorkSnapshotRuleFile() {
31
+ return `---
32
+ description: Publish Kairo work snapshot after significant Cursor turns
33
+ alwaysApply: true
34
+ ---
35
+
36
+ ${WORK_SNAPSHOT_RULE_BODY}
37
+ `;
38
+ }
39
+
40
+ async function writeAtomicText(targetPath, text, deps = {}) {
41
+ const write = deps.writeFileFn ?? writeFile;
42
+ const renameFn = deps.renameFn ?? rename;
43
+ const tempPath = join(
44
+ dirname(targetPath),
45
+ `.${WORK_SNAPSHOT_RULE_FILENAME}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`
46
+ );
47
+ await write(tempPath, text, "utf8");
48
+ await renameFn(tempPath, targetPath);
49
+ }
50
+
51
+ /**
52
+ * Plan or apply the managed work-snapshot rule under ~/.cursor/rules/.
53
+ */
54
+ export async function ensureWorkSnapshotRule({
55
+ homeDir = homedir(),
56
+ apply = false,
57
+ now = () => Date.now(),
58
+ readFileFn = readFile,
59
+ mkdirFn = mkdir,
60
+ copyFileFn = copyFile,
61
+ writeFileFn = writeFile,
62
+ renameFn = rename
63
+ } = {}) {
64
+ const path = resolveWorkSnapshotRulePath(homeDir);
65
+ const desired = buildWorkSnapshotRuleFile();
66
+ let existing = null;
67
+ try {
68
+ existing = await readFileFn(path, "utf8");
69
+ } catch (error) {
70
+ if (error?.code !== "ENOENT") throw error;
71
+ }
72
+ const wouldWrite = existing !== desired;
73
+ const backupPath = wouldWrite && existing != null
74
+ ? `${path}.kairo-backup.${now()}`
75
+ : null;
76
+
77
+ if (!apply) {
78
+ return { path, wouldWrite, wrote: false, backupPath: null };
79
+ }
80
+
81
+ await mkdirFn(dirname(path), { recursive: true });
82
+ if (wouldWrite && existing != null) {
83
+ await copyFileFn(path, backupPath);
84
+ }
85
+ if (wouldWrite) {
86
+ await writeAtomicText(path, desired, { writeFileFn, renameFn });
87
+ }
88
+ return { path, wouldWrite, wrote: wouldWrite, backupPath };
89
+ }
@@ -0,0 +1,49 @@
1
+ import * as z from "zod";
2
+ import { publishWorkSnapshot } from "../next/publish-work-snapshot.js";
3
+
4
+ export const workSnapshotPublishSchema = z.object({
5
+ conversationId: z.string().min(1).max(160),
6
+ provider: z.enum(["cursor", "codex", "claude", "opencode", "pi", "other"]),
7
+ goal: z.string().min(1).max(160),
8
+ now: z.string().min(1).max(240),
9
+ next: z.string().min(1).max(240),
10
+ progress: z.array(z.string().max(160)).max(3).optional(),
11
+ blockers: z.array(z.string().max(200)).max(12).optional(),
12
+ delegations: z.array(z.object({
13
+ workId: z.string().max(64).optional(),
14
+ title: z.string().max(160).optional(),
15
+ role: z.enum(["orchestrator", "worker"]).optional(),
16
+ state: z.enum(["assigned", "working", "blocked", "completed", "failed"]).optional()
17
+ }).strict()).max(12).optional()
18
+ }).strict();
19
+
20
+ export function createPublishWorkSnapshotHandler(deps = {}) {
21
+ const publishSnapshot = deps.publishWorkSnapshot ?? ((input) => publishWorkSnapshot(input, {
22
+ homeDir: deps.homeDir,
23
+ cwd: deps.cwd,
24
+ now: deps.now,
25
+ writeAtomic: deps.writeAtomic
26
+ }));
27
+ const toResult = deps.mcpResult;
28
+ if (typeof toResult !== "function") {
29
+ throw new Error("mcpResult dependency is required");
30
+ }
31
+
32
+ return async function kairo_publish_work_snapshot(args = {}) {
33
+ try {
34
+ const result = await publishSnapshot(args);
35
+ return toResult({
36
+ ok: Boolean(result?.ok),
37
+ code: result?.code ?? "publish_failed",
38
+ data: result?.data ?? null,
39
+ diagnostics: result?.diagnostics ?? [],
40
+ isError: !result?.ok
41
+ });
42
+ } catch {
43
+ return toResult({
44
+ ok: false, code: "publish_failed", data: null,
45
+ diagnostics: ["publish_failed"], isError: true
46
+ });
47
+ }
48
+ };
49
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Register Kairo as an MCP server in a client config (Cursor v1).
3
+ * Consent-gated: plan by default, --yes applies. Atomic write + backup.
4
+ */
5
+ import { copyFile, mkdir, readFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ import { writeAtomicJson } from "./runtime/write-atomic-json.js";
8
+ import {
9
+ MCP_CLIENTS,
10
+ detectAgentMcpRegistration,
11
+ resolveMcpConfigPath
12
+ } from "./connections.js";
13
+ import { printJson } from "./json-output.js";
14
+ import { commandHeader } from "./brand/index.js";
15
+ import { formatCliCommand } from "./brand/cli.js";
16
+ import {
17
+ buildWorkSnapshotRuleFile,
18
+ ensureWorkSnapshotRule,
19
+ resolveWorkSnapshotRulePath
20
+ } from "./mcp/work-snapshot-rule.js";
21
+ import { resolveHomeDir } from "./paths.js";
22
+
23
+ /**
24
+ * Cursor MCP entry. `cwd: "."` is best-effort for clients that honor it.
25
+ * Cursor IDE 3.15+ may still spawn under $HOME — runtime identity then uses
26
+ * VSCODE_CWD / WORKSPACE_FOLDER_PATHS (see resolve-mcp-workspace.js).
27
+ */
28
+ export const KAIRO_MCP_SERVER_ENTRY = Object.freeze({
29
+ command: "kairo",
30
+ args: Object.freeze(["mcp"]),
31
+ cwd: "."
32
+ });
33
+
34
+ export function buildKairoMcpServerEntry() {
35
+ return {
36
+ command: KAIRO_MCP_SERVER_ENTRY.command,
37
+ args: [...KAIRO_MCP_SERVER_ENTRY.args],
38
+ cwd: KAIRO_MCP_SERVER_ENTRY.cwd
39
+ };
40
+ }
41
+
42
+ function backupPath(configPath, stamp = Date.now()) {
43
+ return `${configPath}.kairo-backup.${stamp}`;
44
+ }
45
+
46
+ export function buildMcpInstallPlan({
47
+ client = "cursor",
48
+ homeDir = resolveHomeDir(),
49
+ existing = null,
50
+ alreadyConnected = false
51
+ } = {}) {
52
+ const clientMeta = MCP_CLIENTS[client] ?? MCP_CLIENTS.cursor;
53
+ const path = resolveMcpConfigPath(client, { homeDir });
54
+ const entry = buildKairoMcpServerEntry();
55
+ const next = {
56
+ ...(existing && typeof existing === "object" ? existing : {}),
57
+ mcpServers: {
58
+ ...((existing && typeof existing === "object" && existing.mcpServers) || {}),
59
+ kairo: entry
60
+ }
61
+ };
62
+ return {
63
+ client: clientMeta.id,
64
+ clientLabel: clientMeta.label,
65
+ path,
66
+ alreadyConnected,
67
+ wouldWrite: !alreadyConnected || JSON.stringify(existing?.mcpServers?.kairo)
68
+ !== JSON.stringify(next.mcpServers.kairo),
69
+ entry: next.mcpServers.kairo,
70
+ next,
71
+ backupPath: backupPath(path),
72
+ rulePath: resolveWorkSnapshotRulePath(homeDir),
73
+ ruleBody: buildWorkSnapshotRuleFile(),
74
+ note: alreadyConnected
75
+ ? "Kairo MCP already registered; --yes rewrites the entry if it drifted."
76
+ : `Will add mcpServers.kairo to ${path}. Reload Cursor MCP after apply.`
77
+ };
78
+ }
79
+
80
+ async function readExistingConfig(path, readFileFn) {
81
+ try {
82
+ const raw = await readFileFn(path, "utf8");
83
+ return JSON.parse(raw);
84
+ } catch (error) {
85
+ if (error?.code === "ENOENT") return null;
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Plan (default) or apply (--yes) Cursor MCP registration for Kairo.
92
+ */
93
+ export async function runMcpInstall({
94
+ client = "cursor",
95
+ yes = false,
96
+ json = false,
97
+ homeDir = resolveHomeDir(),
98
+ readFileFn = readFile,
99
+ mkdirFn = mkdir,
100
+ copyFileFn = copyFile,
101
+ writeAtomicJsonFn = writeAtomicJson,
102
+ writeFileFn = null,
103
+ renameFn = null,
104
+ ensureRule = ensureWorkSnapshotRule,
105
+ detectAgent = detectAgentMcpRegistration,
106
+ now = () => Date.now()
107
+ } = {}) {
108
+ if (client !== "cursor") {
109
+ throw new Error(
110
+ `Unsupported MCP client "${client}". v1 supports --client=cursor only.`
111
+ );
112
+ }
113
+
114
+ const detection = await detectAgent({ client, homeDir, readFileFn });
115
+ const path = detection.path ?? resolveMcpConfigPath(client, { homeDir });
116
+ const existing = await readExistingConfig(path, readFileFn);
117
+ const plan = buildMcpInstallPlan({
118
+ client,
119
+ homeDir,
120
+ existing,
121
+ alreadyConnected: detection.connected === true
122
+ });
123
+ plan.backupPath = backupPath(path, now());
124
+ const rulePlan = await ensureRule({
125
+ homeDir,
126
+ apply: false,
127
+ now,
128
+ readFileFn,
129
+ mkdirFn,
130
+ copyFileFn,
131
+ writeFileFn: writeFileFn ?? undefined,
132
+ renameFn: renameFn ?? undefined
133
+ });
134
+ plan.ruleWouldWrite = rulePlan.wouldWrite === true;
135
+ plan.rulePath = rulePlan.path;
136
+
137
+ if (!yes) {
138
+ const payload = {
139
+ ok: true,
140
+ applied: false,
141
+ plan: {
142
+ path: plan.path,
143
+ client: plan.client,
144
+ alreadyConnected: plan.alreadyConnected,
145
+ wouldWrite: plan.wouldWrite,
146
+ entry: plan.entry,
147
+ rulePath: plan.rulePath,
148
+ ruleWouldWrite: plan.ruleWouldWrite,
149
+ note: plan.note,
150
+ applyWith: formatCliCommand("mcp install --yes")
151
+ }
152
+ };
153
+ if (json) {
154
+ printJson(payload);
155
+ } else {
156
+ console.log(commandHeader("MCP install"));
157
+ console.log(`Client · ${plan.clientLabel}`);
158
+ console.log(`Path · ${plan.path}`);
159
+ console.log(`Entry · ${JSON.stringify(plan.entry)}`);
160
+ console.log(`Rule · ${plan.rulePath}${plan.ruleWouldWrite ? " (will write)" : " (up to date)"}`);
161
+ console.log(plan.note);
162
+ console.log(`Apply · ${formatCliCommand("mcp install --yes")}`);
163
+ }
164
+ return payload;
165
+ }
166
+
167
+ await mkdirFn(dirname(path), { recursive: true });
168
+ if (existing != null && plan.wouldWrite) {
169
+ await copyFileFn(path, plan.backupPath);
170
+ }
171
+ if (plan.wouldWrite) {
172
+ await writeAtomicJsonFn(path, plan.next);
173
+ }
174
+
175
+ const ruleReceipt = await ensureRule({
176
+ homeDir,
177
+ apply: true,
178
+ now,
179
+ readFileFn,
180
+ mkdirFn,
181
+ copyFileFn,
182
+ writeFileFn: writeFileFn ?? undefined,
183
+ renameFn: renameFn ?? undefined
184
+ });
185
+
186
+ const receipt = {
187
+ ok: true,
188
+ applied: true,
189
+ path,
190
+ backupPath: existing != null && plan.wouldWrite ? plan.backupPath : null,
191
+ entry: plan.entry,
192
+ client: plan.client,
193
+ rulePath: ruleReceipt.path,
194
+ ruleWrote: ruleReceipt.wrote === true,
195
+ ruleBackupPath: ruleReceipt.backupPath,
196
+ note: "Reload Cursor MCP (Command Palette → MCP: Restart) to load kairo_* tools."
197
+ };
198
+
199
+ if (json) {
200
+ printJson(receipt);
201
+ } else {
202
+ console.log(commandHeader("MCP install"));
203
+ if (plan.wouldWrite) console.log(`Wrote · ${path}`);
204
+ else console.log(`MCP · up to date (${path})`);
205
+ if (receipt.backupPath) console.log(`Backup · ${receipt.backupPath}`);
206
+ console.log(`Rule · ${receipt.rulePath}${receipt.ruleWrote ? " (wrote)" : " (up to date)"}`);
207
+ if (receipt.ruleBackupPath) console.log(`Rule backup · ${receipt.ruleBackupPath}`);
208
+ console.log(receipt.note);
209
+ }
210
+ return receipt;
211
+ }
212
+
213
+ export function resolveMcpServeCwd(options = {}) {
214
+ return options.cwdExplicit === false ? undefined : options.cwd;
215
+ }
216
+
217
+ export async function runMcpCli(options = {}) {
218
+ const action = options.mcpAction ?? "serve";
219
+ if (action === "install") {
220
+ return runMcpInstall({
221
+ client: options.mcpClient ?? "cursor",
222
+ yes: options.yes === true,
223
+ json: options.json === true,
224
+ homeDir: options.homeDir ?? resolveHomeDir()
225
+ });
226
+ }
227
+ if (action === "serve" || action == null) {
228
+ const { runKairoMcp } = await import("./mcp/kairo-mcp.js");
229
+ return runKairoMcp({
230
+ cwd: resolveMcpServeCwd(options),
231
+ packageRoot: options.packageRoot,
232
+ packageName: options.packageName,
233
+ version: options.version
234
+ });
235
+ }
236
+ throw new Error(
237
+ `Unknown mcp action "${action}". Use: ${formatCliCommand("mcp")} or ${formatCliCommand("mcp install [--yes]")}`
238
+ );
239
+ }