cairn-ai 0.2.2 → 0.2.4

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.
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { realpathSync } from "node:fs";
4
- import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
6
- import { dirname, join, posix, resolve, win32 } from "node:path";
5
+ import {
6
+ copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile,
7
+ } from "node:fs/promises";
8
+ import { homedir, hostname, tmpdir } from "node:os";
9
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
7
10
  import { fileURLToPath, pathToFileURL } from "node:url";
8
11
  import {
9
12
  RUNTIME_LOCATOR_SCHEMA_VERSION,
@@ -16,193 +19,950 @@ const pluginRoot = resolvePluginRoot(import.meta.url);
16
19
  const homeRoot = process.env.HOME ?? process.env.USERPROFILE ?? homedir() ?? ".";
17
20
  const codexHome = resolve(process.env.CODEX_HOME ?? join(homeRoot, ".codex"));
18
21
  const claudeHome = resolve(process.env.CLAUDE_HOME ?? join(homeRoot, ".claude"));
19
- const antigravityHome = resolve(process.env.ANTIGRAVITY_HOME ?? join(homeRoot, ".agents"));
22
+ // ANTIGRAVITY_HOME denotes the IDE's current ~/.gemini/config root.
23
+ const antigravityHome = resolve(process.env.ANTIGRAVITY_HOME ?? join(homeRoot, ".gemini", "config"));
24
+ const legacyAntigravityHome = resolve(process.env.CAIRN_LEGACY_ANTIGRAVITY_HOME ?? join(homeRoot, ".agents"));
20
25
  const antigravityCliHome = resolve(process.env.ANTIGRAVITY_CLI_HOME ?? join(homeRoot, ".gemini", "antigravity-cli"));
21
- const configPath = process.env.CODEX_CONFIG_PATH ?? join(codexHome, "config.toml");
26
+ const configPath = resolve(process.env.CODEX_CONFIG_PATH ?? join(codexHome, "config.toml"));
22
27
  const marketplaceName = "cairn";
23
28
  const pluginName = "cairn";
24
29
  const marketplaceRoot = join(codexHome, "plugins", "cache", marketplaceName);
25
30
  const installedPluginRoot = join(marketplaceRoot, "plugins", pluginName);
26
31
  const marketplaceJsonPath = join(marketplaceRoot, ".agents", "plugins", "marketplace.json");
27
- const installedRuntimeLocatorPath = join(installedPluginRoot, ".cairn-runtime.json");
32
+ const ownershipPath = join(marketplaceRoot, ".cairn", "lifecycle.json");
33
+ const transactionPath = join(marketplaceRoot, ".cairn", "transaction.json");
34
+ const transactionRoot = join(marketplaceRoot, ".cairn", "transactions");
35
+ const lifecycleLockPath = join(marketplaceRoot, ".cairn", "lifecycle.lock");
28
36
  const claudeRuntimeLocatorPath = join(claudeHome, "cairn", "runtime.json");
37
+ const sourceManifest = JSON.parse(await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"));
38
+ const releaseVersion = sourceManifest.version;
39
+ // Codex custom marketplace discovery expects <marketplace>/<plugin>/<version>.
40
+ const versionedPluginRoot = join(marketplaceRoot, pluginName, releaseVersion);
41
+ const installedRuntimeLocatorPath = join(versionedPluginRoot, ".cairn-runtime.json");
42
+ const phases = ["codex", "claude", "antigravity", "config", "cleanup"];
43
+ export const DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS = 60_000;
29
44
 
30
45
  if (isCliEntry()) {
31
- const command = process.argv[2] ?? "help";
32
- if (command === "install" || command === "upgrade") await install(command);
33
- else if (command === "doctor") await doctor();
34
- else if (command === "uninstall") await uninstall();
35
- else help();
46
+ try {
47
+ const command = process.argv[2] ?? "help";
48
+ if (command === "install" || command === "upgrade") await withLifecycleLock(() => install(command));
49
+ else if (command === "doctor") await doctor();
50
+ else if (command === "uninstall") await withLifecycleLock(uninstall, { afterRelease: pruneUninstallRoots });
51
+ else help();
52
+ } catch (error) {
53
+ console.error(`Cairn lifecycle error: ${error.message}`);
54
+ process.exitCode = 1;
55
+ }
36
56
  }
37
57
 
38
58
  async function install(mode) {
39
- if (samePath(pluginRoot, installedPluginRoot)) {
59
+ if (samePath(pluginRoot, installedPluginRoot) || samePath(pluginRoot, versionedPluginRoot)) {
40
60
  throw new Error("Run install or upgrade from the Cairn package/global source, not from the installed cache.");
41
61
  }
42
- await copyPlugin();
43
- await writeRuntimeLocator(installedRuntimeLocatorPath);
44
- await writeInstalledSkillLocators();
45
- await writeMarketplace();
46
- await installClaudeFiles();
47
- await installAntigravityFiles();
48
- const hookStates = await trustedHookStates();
49
- const config = await readText(configPath);
50
- await backupConfig();
51
- await writeFile(configPath, `${updateConfig(config, hookStates).trimEnd()}\n`);
52
- console.log(t(mode === "upgrade" ? "upgradeComplete" : "installComplete"));
53
- console.log(`Codex plugin: ${installedPluginRoot}`);
54
- console.log(`Marketplace: ${marketplaceJsonPath}`);
55
- console.log(`Hook trust states: ${hookStates.length}`);
56
- console.log(`Antigravity skills: ${join(antigravityHome, "skills")}`);
62
+ await recoverInterruptedTransaction();
63
+ const transaction = await createTransaction();
64
+ try {
65
+ const targets = await stageInstall(transaction.stageRoot);
66
+ targets.push(...await legacyMigrationTargets());
67
+ let ownership = await readOwnership();
68
+ if (ownership) {
69
+ validateOwnershipShape(ownership);
70
+ await assertOwnershipDigests(ownership);
71
+ if (ownership.version !== releaseVersion) {
72
+ targets.push({
73
+ id: "previous-codex-runtime",
74
+ phase: "cleanup",
75
+ path: versionedRuntimeRoot(ownership.version),
76
+ type: "tree",
77
+ operation: "remove",
78
+ });
79
+ }
80
+ }
81
+ else if (await anyManagedArtifactExists(targets)) ownership = await adoptLegacy(targets, transaction);
82
+ await assertTargetsReplaceable(targets, ownership);
83
+ for (const phase of phases) {
84
+ for (const target of targets.filter((candidate) => candidate.phase === phase)) {
85
+ if (target.operation === "remove") await removeManagedTarget(target, transaction);
86
+ else await replaceTarget(target, transaction);
87
+ }
88
+ injectFailure(`after-${phase}`);
89
+ }
90
+ const records = [];
91
+ for (const target of targets.filter((candidate) => candidate.operation !== "remove")) records.push(await targetRecord(target));
92
+ const nextOwnership = {
93
+ schemaVersion: 1,
94
+ plugin: pluginName,
95
+ version: releaseVersion,
96
+ transactionId: transaction.id,
97
+ installedAt: new Date().toISOString(),
98
+ targets: records,
99
+ };
100
+ const ownershipStage = join(transaction.stageRoot, "lifecycle.json");
101
+ await mkdir(dirname(ownershipStage), { recursive: true });
102
+ await writeFile(ownershipStage, `${JSON.stringify(nextOwnership, null, 2)}\n`);
103
+ await replaceTarget({ id: "ownership", phase: "manifest", path: ownershipPath, staged: ownershipStage, type: "file" }, transaction);
104
+ await finishTransaction(transaction);
105
+ console.log(t(mode === "upgrade" ? "upgradeComplete" : "installComplete"));
106
+ console.log(`Codex plugin source: ${installedPluginRoot}`);
107
+ console.log(`Codex versioned runtime: ${versionedPluginRoot}`);
108
+ console.log(`Ownership manifest: ${ownershipPath}`);
109
+ } catch (error) {
110
+ if (transaction.state !== "committed") await rollback(transaction);
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ async function uninstall() {
116
+ await recoverInterruptedTransaction();
117
+ const ownership = await readOwnership({ required: true });
118
+ validateOwnershipShape(ownership, { required: true });
119
+ const conflicts = [];
120
+ for (const record of ownership.targets) {
121
+ const current = await targetDigest(record.path, record.type);
122
+ if (current !== record.installedDigest) conflicts.push(record.path);
123
+ }
124
+ if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
125
+ const transaction = await createTransaction();
126
+ try {
127
+ const configRecord = ownership.targets.find((record) => record.type === "config");
128
+ if (configRecord) {
129
+ const staged = join(transaction.stageRoot, "uninstall-config.toml");
130
+ await mkdir(dirname(staged), { recursive: true });
131
+ await writeFile(staged, removeCairnConfig(await readText(configRecord.path)));
132
+ await replaceTarget({ ...configRecord, staged }, transaction);
133
+ }
134
+ injectFailure("uninstall-after-config");
135
+ for (const phase of ["antigravity", "claude"]) {
136
+ for (const record of ownership.targets.filter((candidate) => candidate.phase === phase).reverse()) await removeManagedTarget(record, transaction);
137
+ }
138
+ injectFailure("uninstall-after-external");
139
+ for (const record of ownership.targets.filter((candidate) => candidate.phase === "codex").reverse()) await removeManagedTarget(record, transaction);
140
+ injectFailure("uninstall-after-codex");
141
+ await removeManagedTarget({ id: "ownership", phase: "manifest", path: ownershipPath, type: "file" }, transaction);
142
+ injectFailure("uninstall-after-ownership");
143
+ await finishTransaction(transaction);
144
+ injectFailure("uninstall-after-commit");
145
+ } catch (error) {
146
+ if (transaction.state !== "committed") await rollback(transaction);
147
+ throw error;
148
+ }
149
+ await pruneUninstallScaffolds();
150
+ console.log(t("uninstallComplete"));
57
151
  }
58
152
 
59
153
  async function doctor() {
60
154
  const checks = [];
155
+ const ownership = await readOwnership();
61
156
  checks.push(["source", await exists(join(pluginRoot, ".codex-plugin", "plugin.json"))]);
62
- checks.push(["installed plugin", await exists(join(installedPluginRoot, ".codex-plugin", "plugin.json"))]);
157
+ checks.push(["ownership manifest", Boolean(ownership)]);
158
+ checks.push(["ownership digests", await ownershipDigestsValid(ownership)]);
159
+ checks.push(["custom marketplace source", await exists(join(installedPluginRoot, ".codex-plugin", "plugin.json"))]);
160
+ checks.push(["versioned runtime", await exists(join(versionedPluginRoot, ".codex-plugin", "plugin.json"))]);
63
161
  checks.push(["marketplace", await exists(marketplaceJsonPath)]);
64
- const installedManifest = await readJson(join(installedPluginRoot, ".codex-plugin", "plugin.json"));
65
- checks.push(["installed hooks manifest field", installedManifest?.hooks === "./hooks/hooks.json"]);
66
- checks.push(["hooks file", await exists(join(installedPluginRoot, "hooks", "hooks.json"))]);
162
+ checks.push(["installed hooks manifest field", (await readJson(join(versionedPluginRoot, ".codex-plugin", "plugin.json")))?.hooks === "./hooks/hooks.json"]);
163
+ checks.push(["hooks file", await exists(join(versionedPluginRoot, "hooks", "hooks.json"))]);
67
164
  checks.push(["installed runtime locator", await validRuntimeLocator(installedRuntimeLocatorPath)]);
68
165
  checks.push(["installed skill locators", (await Promise.all(skillNames().map((name) => validRuntimeLocator(installedSkillLocatorPath(name))))).every(Boolean)]);
69
- checks.push(["installed CLI script", await exists(join(installedPluginRoot, "scripts", "cairn.mjs"))]);
70
- checks.push(["installed plan template", await exists(join(installedPluginRoot, "templates", "work-plan.md"))]);
71
- checks.push(["installed model guidance", await exists(join(installedPluginRoot, "docs", "model-guidance", "codex.md"))]);
166
+ checks.push(["installed CLI script", await exists(join(versionedPluginRoot, "scripts", "cairn.mjs"))]);
167
+ checks.push(["installed plan template", await exists(join(versionedPluginRoot, "templates", "work-plan.md"))]);
168
+ checks.push(["installed model guidance", await exists(join(versionedPluginRoot, "docs", "model-guidance", "codex.md"))]);
72
169
  const config = await readText(configPath);
73
- checks.push(["config features.plugins", hasSetting(config, "features", "plugins", "true")]);
74
- checks.push(["config features.plugin_hooks", hasSetting(config, "features", "plugin_hooks", "true")]);
75
- checks.push(["config features.multi_agent", hasSetting(config, "features", "multi_agent", "true")]);
76
- checks.push(["config agents.max_depth", hasSetting(config, "agents", "max_depth", "2")]);
77
- checks.push(["config marketplace", config.includes("[marketplaces.cairn]")]);
78
- checks.push(["config plugin enabled", config.includes('[plugins."cairn@cairn"]') && hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
79
- const expectedStates = await trustedHookStates();
80
- const missingStates = expectedStates.filter((state) => !config.includes(`[hooks.state.${JSON.stringify(state.key)}]`));
81
- checks.push(["trusted hook states", expectedStates.length > 0 && missingStates.length === 0]);
82
- checks.push(["Claude commands", await exists(join(claudeHome, "commands", "cairn-plan.md"))]);
83
- checks.push(["Claude agents", await exists(join(claudeHome, "agents", "cairn-explorer.md"))]);
170
+ checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
171
+ checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
84
172
  checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath)]);
85
- checks.push(["Antigravity skills", await exists(join(antigravityHome, "skills", "cairn-plan", "SKILL.md"))]);
86
- checks.push(["Antigravity workflows", await exists(join(antigravityHome, "workflows", "cairn-plan.md"))]);
87
- checks.push(["Antigravity runtime locator", await validRuntimeLocator(antigravityRuntimeLocatorPath(antigravityHome))]);
88
- checks.push(["Antigravity skill locators", (await Promise.all(skillNames().map((name) => validRuntimeLocator(antigravitySkillLocatorPath(antigravityHome, name))))).every(Boolean)]);
89
- checks.push(["Antigravity CLI skills", await exists(join(antigravityCliHome, "skills", "cairn-plan", "SKILL.md"))]);
90
- checks.push(["Antigravity CLI workflows", await exists(join(antigravityCliHome, "workflows", "cairn-plan.md"))]);
91
- checks.push(["Antigravity CLI runtime locator", await validRuntimeLocator(antigravityRuntimeLocatorPath(antigravityCliHome))]);
92
- checks.push(["Antigravity CLI skill locators", (await Promise.all(skillNames().map((name) => validRuntimeLocator(antigravitySkillLocatorPath(antigravityCliHome, name))))).every(Boolean)]);
173
+ checks.push(["Antigravity IDE skills", await exists(join(antigravityHome, "skills", "cairn-plan", "SKILL.md"))]);
174
+ checks.push(["Antigravity CLI flat skills", await exists(join(antigravityCliHome, "skills", "cairn-plan.md"))]);
175
+
176
+ const codexStatus = hostJson(["plugin", "list", "--json"], { CODEX_HOME: codexHome });
177
+ const codexEntry = findPluginStatus(codexStatus, "cairn@cairn");
178
+ checks.push(["Codex plugin installed", Boolean(codexEntry?.installed)]);
179
+ checks.push(["Codex plugin enabled", Boolean(codexEntry?.enabled)]);
180
+ checks.push(["Codex plugin version", codexEntry?.version === releaseVersion]);
181
+
182
+ const featureResult = spawnSync("codex", ["features", "list"], { encoding: "utf8", env: { ...process.env, CODEX_HOME: codexHome } });
183
+ const featureStates = featureResult.status === 0 && !featureResult.error ? parseCodexFeatureList(featureResult.stdout) : null;
184
+ checks.push(["Codex feature plugins", featureStates?.plugins === true]);
185
+ checks.push(["Codex feature hooks", featureStates?.hooks === true]);
186
+ checks.push(["Codex feature multi_agent", featureStates?.multi_agent === true]);
187
+
188
+ const agy = spawnSync("agy", ["--version"], { encoding: "utf8", env: process.env });
189
+ checks.push(["Antigravity CLI readiness", !agy.error && agy.status === 0 && /^\d+\.\d+\.\d+/.test(agy.stdout.trim())]);
190
+ checks.push(["Antigravity CLI skill validation", await validateAntigravitySkills()]);
93
191
  for (const [name, ok] of checks) console.log(`${ok ? "OK" : "FAIL"} ${name}`);
94
192
  if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
95
193
  }
96
194
 
97
- async function uninstall() {
98
- const config = await readText(configPath);
99
- await backupConfig();
100
- await writeFile(configPath, `${removeCairnConfig(config).trimEnd()}\n`);
101
- await rm(marketplaceRoot, { recursive: true, force: true });
195
+ async function ownershipDigestsValid(ownership) {
196
+ try {
197
+ validateOwnershipShape(ownership, { required: true });
198
+ for (const record of ownership.targets) if (await targetDigest(record.path, record.type) !== record.installedDigest) return false;
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ async function stageInstall(stageRoot) {
206
+ const targets = [];
207
+ const add = (id, phase, destination, staged, type = "tree") => targets.push({ id, phase, path: destination, staged, type });
208
+ const sourceStage = join(stageRoot, "codex-source");
209
+ const runtimeStage = join(stageRoot, "codex-runtime");
210
+ await copyPluginCandidate(sourceStage, versionedPluginRoot);
211
+ await copyPluginCandidate(runtimeStage, versionedPluginRoot);
212
+ add("codex-source", "codex", installedPluginRoot, sourceStage);
213
+ add("codex-runtime", "codex", versionedPluginRoot, runtimeStage);
214
+
215
+ const marketplaceStage = join(stageRoot, "marketplace.json");
216
+ await writeFile(marketplaceStage, `${JSON.stringify({
217
+ name: marketplaceName,
218
+ interface: { displayName: "Cairn" },
219
+ plugins: [{ name: pluginName, source: { source: "local", path: "./plugins/cairn" }, policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }],
220
+ }, null, 2)}\n`);
221
+ add("marketplace", "codex", marketplaceJsonPath, marketplaceStage, "file");
222
+
223
+ const claudeLocator = join(stageRoot, "claude-runtime.json");
224
+ await writeLocator(claudeLocator, versionedPluginRoot);
225
+ add("claude-runtime", "claude", claudeRuntimeLocatorPath, claudeLocator, "file");
102
226
  for (const name of commandNames()) {
103
- await rm(join(claudeHome, "commands", `cairn-${name}.md`), { force: true });
227
+ const staged = join(stageRoot, "claude-commands", `cairn-${name}.md`);
228
+ await renderMirrorFile(join(runtimeStage, ".claude", "commands", `cairn-${name}.md`), staged, claudeRuntimeLocatorPath);
229
+ add(`claude-command-${name}`, "claude", join(claudeHome, "commands", `cairn-${name}.md`), staged, "file");
104
230
  }
105
- for (const name of ["architect", "planner", "builder", "reviewer", "explorer", "worker"]) {
106
- await rm(join(claudeHome, "agents", `cairn-${name}.md`), { force: true });
231
+ for (const name of ["explorer", "worker"]) {
232
+ const staged = join(stageRoot, "claude-agents", `cairn-${name}.md`);
233
+ await renderMirrorFile(join(runtimeStage, ".claude", "agents", `${name}.md`), staged, claudeRuntimeLocatorPath);
234
+ add(`claude-agent-${name}`, "claude", join(claudeHome, "agents", `cairn-${name}.md`), staged, "file");
235
+ }
236
+
237
+ await stageAntigravity(targets, stageRoot, runtimeStage, antigravityHome, "ide", false);
238
+ await stageAntigravity(targets, stageRoot, runtimeStage, antigravityCliHome, "cli", true);
239
+
240
+ const configStage = join(stageRoot, "config.toml");
241
+ const hookStates = await trustedHookStates(runtimeStage);
242
+ await writeFile(configStage, updateConfig(await readText(configPath), hookStates));
243
+ add("codex-config", "config", configPath, configStage, "config");
244
+ for (const target of targets) await assertRegularTree(target.staged);
245
+ return targets;
246
+ }
247
+
248
+ async function stageAntigravity(targets, stageRoot, runtimeStage, destinationRoot, label, flatSkills) {
249
+ const locatorPath = antigravityRuntimeLocatorPath(destinationRoot);
250
+ const locatorStage = join(stageRoot, `${label}-runtime.json`);
251
+ await writeLocator(locatorStage, versionedPluginRoot);
252
+ targets.push({ id: `${label}-runtime`, phase: "antigravity", path: locatorPath, staged: locatorStage, type: "file" });
253
+ for (const name of skillNames()) {
254
+ if (flatSkills) {
255
+ const staged = join(stageRoot, `${label}-skills`, `${name}.md`);
256
+ await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), staged, locatorPath, { includeLocatorNotice: true });
257
+ targets.push({ id: `${label}-skill-${name}`, phase: "antigravity", path: join(destinationRoot, "skills", `${name}.md`), staged, type: "file" });
258
+ } else {
259
+ const staged = join(stageRoot, `${label}-skills`, name);
260
+ await cp(join(runtimeStage, "skills", name), staged, { recursive: true });
261
+ await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), join(staged, "SKILL.md"), antigravitySkillLocatorPath(destinationRoot, name), { includeLocatorNotice: true });
262
+ await writeLocator(join(staged, "references", "cairn-runtime.json"), versionedPluginRoot);
263
+ targets.push({ id: `${label}-skill-${name}`, phase: "antigravity", path: join(destinationRoot, "skills", name), staged, type: "tree" });
264
+ }
265
+ }
266
+ for (const name of commandNames()) {
267
+ const staged = join(stageRoot, `${label}-workflows`, `cairn-${name}.md`);
268
+ await renderMirrorFile(join(runtimeStage, ".agents", "workflows", `cairn-${name}.md`), staged, locatorPath);
269
+ targets.push({ id: `${label}-workflow-${name}`, phase: "antigravity", path: join(destinationRoot, "workflows", `cairn-${name}.md`), staged, type: "file" });
107
270
  }
108
- await rm(join(claudeHome, "cairn"), { recursive: true, force: true });
109
- await uninstallAntigravityFiles();
110
- console.log(t("uninstallComplete"));
111
271
  }
112
272
 
113
- async function copyPlugin() {
114
- await rm(installedPluginRoot, { recursive: true, force: true });
115
- await mkdir(dirname(installedPluginRoot), { recursive: true });
116
- await cp(pluginRoot, installedPluginRoot, {
117
- recursive: true,
118
- filter: shouldCopyPluginPath,
119
- });
120
- const manifestPath = join(installedPluginRoot, ".codex-plugin", "plugin.json");
273
+ async function legacyMigrationTargets() {
274
+ const candidates = [{ id: "legacy-ide-runtime", phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file", operation: "remove" }];
275
+ for (const name of skillNames()) {
276
+ candidates.push({ id: `legacy-ide-skill-${name}`, phase: "antigravity", path: join(legacyAntigravityHome, "skills", name), type: "tree", operation: "remove" });
277
+ candidates.push({ id: `legacy-cli-skill-${name}`, phase: "antigravity", path: join(antigravityCliHome, "skills", name), type: "tree", operation: "remove" });
278
+ }
279
+ for (const name of commandNames()) candidates.push({ id: `legacy-ide-workflow-${name}`, phase: "antigravity", path: join(legacyAntigravityHome, "workflows", `cairn-${name}.md`), type: "file", operation: "remove" });
280
+ const present = [];
281
+ for (const candidate of candidates) if (await exists(candidate.path)) present.push(candidate);
282
+ return present;
283
+ }
284
+
285
+ async function copyPluginCandidate(destination, runtimeRoot) {
286
+ await copyPluginTree(pluginRoot, destination);
287
+ const manifestPath = join(destination, ".codex-plugin", "plugin.json");
121
288
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
122
289
  manifest.hooks = "./hooks/hooks.json";
123
290
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
291
+ await writeLocator(join(destination, ".cairn-runtime.json"), runtimeRoot);
292
+ for (const name of skillNames()) await writeLocator(join(destination, "skills", name, "references", "cairn-runtime.json"), runtimeRoot);
124
293
  }
125
294
 
126
- async function writeMarketplace() {
127
- await mkdir(dirname(marketplaceJsonPath), { recursive: true });
128
- const marketplace = {
129
- name: marketplaceName,
130
- interface: { displayName: "Cairn" },
131
- plugins: [{
132
- name: pluginName,
133
- source: { source: "local", path: "./plugins/cairn" },
134
- policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
135
- category: "Productivity",
136
- }],
295
+ async function copyPluginTree(source, destination) {
296
+ if (!shouldCopyPluginPath(source)) return;
297
+ const info = await lstat(source);
298
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
299
+ throw new Error(`Plugin source must contain only regular files and directories: ${source}`);
300
+ }
301
+ if (info.isFile()) {
302
+ await mkdir(dirname(destination), { recursive: true });
303
+ await copyFile(source, destination);
304
+ return;
305
+ }
306
+ await mkdir(destination, { recursive: true });
307
+ const entries = await readdir(source);
308
+ entries.sort();
309
+ for (const entry of entries) await copyPluginTree(join(source, entry), join(destination, entry));
310
+ }
311
+
312
+ async function createTransaction() {
313
+ const root = await mkdtemp(join(tmpdir(), "cairn-lifecycle-"));
314
+ const transaction = {
315
+ schemaVersion: 1,
316
+ id: randomUUID(),
317
+ state: "active",
318
+ root,
319
+ stageRoot: join(root, "stage"),
320
+ backupRoot: "",
321
+ entries: [],
137
322
  };
138
- await writeFile(marketplaceJsonPath, `${JSON.stringify(marketplace, null, 2)}\n`);
323
+ transaction.backupRoot = join(transactionRoot, transaction.id, "backup");
324
+ await mkdir(transaction.backupRoot, { recursive: true });
325
+ await writeTransactionJournal(transaction);
326
+ return transaction;
327
+ }
328
+
329
+ async function withLifecycleLock(operation, {
330
+ timeoutMs = Number(process.env.CAIRN_LIFECYCLE_LOCK_TIMEOUT_MS ?? DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS),
331
+ retryMs = 25,
332
+ malformedStaleMs = 30_000,
333
+ afterRelease,
334
+ } = {}) {
335
+ await mkdir(dirname(lifecycleLockPath), { recursive: true });
336
+ if (process.env.CAIRN_TEST_PRUNE_LOCK_PARENT_BEFORE_ACQUIRE === "1") await rmdir(dirname(lifecycleLockPath));
337
+ const owner = { schemaVersion: 1, pid: process.pid, hostname: hostname(), nonce: randomUUID(), acquiredAt: new Date().toISOString() };
338
+ const deadline = Date.now() + timeoutMs;
339
+ while (true) {
340
+ try {
341
+ await writeFile(lifecycleLockPath, `${JSON.stringify(owner)}\n`, { flag: "wx" });
342
+ if (process.env.CAIRN_TEST_REPLACE_LOCK_AFTER_ACQUIRE === "1") {
343
+ await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
344
+ }
345
+ const acquired = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
346
+ if (acquired?.nonce !== owner.nonce) throw new Error("Cairn lifecycle lock ownership changed after acquisition");
347
+ break;
348
+ } catch (error) {
349
+ if (error?.code === "ENOENT") {
350
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
351
+ await mkdir(dirname(lifecycleLockPath), { recursive: true });
352
+ continue;
353
+ }
354
+ if (error?.code !== "EEXIST") throw error;
355
+ if (await reclaimLifecycleLock(malformedStaleMs)) continue;
356
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
357
+ await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
358
+ }
359
+ }
360
+ let operationSucceeded = false;
361
+ try {
362
+ const result = await operation();
363
+ operationSucceeded = true;
364
+ return result;
365
+ } finally {
366
+ if (process.env.CAIRN_TEST_REPLACE_LOCK_BEFORE_RELEASE === "1") {
367
+ await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
368
+ }
369
+ const released = await releaseLifecycleLock(owner);
370
+ if (operationSucceeded && released && afterRelease) await afterRelease();
371
+ }
372
+ }
373
+
374
+ async function releaseLifecycleLock(owner) {
375
+ let current;
376
+ try {
377
+ current = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
378
+ } catch (error) {
379
+ if (error?.code === "ENOENT") return false;
380
+ throw error;
381
+ }
382
+ if (current?.nonce !== owner.nonce) return false;
383
+ const releasedPath = `${lifecycleLockPath}.released.${owner.nonce}`;
384
+ try {
385
+ await rename(lifecycleLockPath, releasedPath);
386
+ } catch (error) {
387
+ if (error?.code === "ENOENT") return false;
388
+ throw error;
389
+ }
390
+ const released = parseLifecycleLock(await readFile(releasedPath, "utf8"));
391
+ if (released?.nonce !== owner.nonce) {
392
+ if (!(await exists(lifecycleLockPath))) await rename(releasedPath, lifecycleLockPath);
393
+ return false;
394
+ }
395
+ await rm(releasedPath, { force: true });
396
+ return true;
397
+ }
398
+
399
+ async function pruneUninstallScaffolds() {
400
+ for (const path of [
401
+ join(marketplaceRoot, "plugins"),
402
+ join(marketplaceRoot, pluginName),
403
+ join(marketplaceRoot, ".agents", "plugins"),
404
+ join(marketplaceRoot, ".agents"),
405
+ transactionRoot,
406
+ ]) await removeEmptyDirectory(path);
407
+ }
408
+
409
+ async function pruneUninstallRoots() {
410
+ await removeEmptyDirectory(dirname(lifecycleLockPath));
411
+ await removeEmptyDirectory(marketplaceRoot);
412
+ }
413
+
414
+ async function removeEmptyDirectory(path) {
415
+ try {
416
+ await rmdir(path);
417
+ } catch (error) {
418
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
419
+ }
420
+ }
421
+
422
+ async function reclaimLifecycleLock(malformedStaleMs) {
423
+ let observed;
424
+ let info;
425
+ try {
426
+ [observed, info] = await Promise.all([readFile(lifecycleLockPath, "utf8"), lstat(lifecycleLockPath)]);
427
+ } catch (error) {
428
+ if (error?.code === "ENOENT") return true;
429
+ throw error;
430
+ }
431
+ if (info.isSymbolicLink()) throw new Error(`Lifecycle lock must not be a symlink: ${lifecycleLockPath}`);
432
+ const record = parseLifecycleLock(observed);
433
+ if (record) {
434
+ if (record.hostname !== hostname() || pidIsAlive(record.pid)) return false;
435
+ } else if (Date.now() - info.mtimeMs <= malformedStaleMs) return false;
436
+ const quarantine = `${lifecycleLockPath}.stale.${record?.nonce ?? "malformed"}.${randomUUID()}`;
437
+ try {
438
+ await rename(lifecycleLockPath, quarantine);
439
+ } catch (error) {
440
+ if (error?.code === "ENOENT") return true;
441
+ throw error;
442
+ }
443
+ if (await readFile(quarantine, "utf8") !== observed) {
444
+ if (!(await exists(lifecycleLockPath))) await rename(quarantine, lifecycleLockPath);
445
+ throw new Error("Cairn lifecycle lock ownership changed during stale recovery");
446
+ }
447
+ await rm(quarantine, { force: true });
448
+ return true;
449
+ }
450
+
451
+ function parseLifecycleLock(text) {
452
+ try {
453
+ const value = JSON.parse(text);
454
+ if (value?.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid <= 0
455
+ || typeof value.hostname !== "string" || value.hostname.length === 0 || typeof value.nonce !== "string" || value.nonce.length === 0
456
+ || typeof value.acquiredAt !== "string" || Number.isNaN(Date.parse(value.acquiredAt))) return null;
457
+ return value;
458
+ } catch { return null; }
459
+ }
460
+
461
+ function pidIsAlive(pid) {
462
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; }
463
+ }
464
+
465
+ function delay(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); }
466
+
467
+ async function replaceTarget(target, transaction) {
468
+ const existed = await exists(target.path);
469
+ const backup = join(transaction.backupRoot, String(transaction.entries.length));
470
+ await mkdir(dirname(backup), { recursive: true });
471
+ const entry = {
472
+ id: target.id,
473
+ phase: target.phase,
474
+ path: target.path,
475
+ type: target.type,
476
+ backup,
477
+ existed,
478
+ previousDigest: existed ? await targetDigest(target.path, target.type) : "missing",
479
+ expectedNewDigest: await targetDigest(target.staged, target.type),
480
+ operation: "replace",
481
+ status: "prepared",
482
+ };
483
+ transaction.entries.push(entry);
484
+ await writeTransactionJournal(transaction);
485
+ if (existed) await rename(target.path, backup);
486
+ await mkdir(dirname(target.path), { recursive: true });
487
+ await rename(target.staged, target.path);
488
+ entry.status = "applied";
489
+ await writeTransactionJournal(transaction);
490
+ }
491
+
492
+ async function removeManagedTarget(target, transaction) {
493
+ if (!(await exists(target.path))) throw new Error(`Managed uninstall target is missing: ${target.path}`);
494
+ const backup = join(transaction.backupRoot, String(transaction.entries.length));
495
+ await mkdir(dirname(backup), { recursive: true });
496
+ const entry = {
497
+ id: target.id,
498
+ phase: target.phase,
499
+ path: target.path,
500
+ type: target.type,
501
+ backup,
502
+ existed: true,
503
+ previousDigest: await targetDigest(target.path, target.type),
504
+ expectedNewDigest: "missing",
505
+ operation: "remove",
506
+ status: "prepared",
507
+ };
508
+ transaction.entries.push(entry);
509
+ await writeTransactionJournal(transaction);
510
+ await rename(target.path, backup);
511
+ entry.status = "applied";
512
+ await writeTransactionJournal(transaction);
513
+ }
514
+
515
+ async function rollback(transaction) {
516
+ await rollbackEntries(transaction);
517
+ await rm(transaction.root, { recursive: true, force: true });
518
+ await rm(join(transactionRoot, transaction.id), { recursive: true, force: true });
519
+ await rm(transactionPath, { force: true });
520
+ }
521
+
522
+ async function finishTransaction(transaction) {
523
+ transaction.state = "committed";
524
+ try {
525
+ await writeTransactionJournal(transaction);
526
+ } catch (error) {
527
+ transaction.state = "active";
528
+ throw error;
529
+ }
530
+ await rm(transaction.root, { recursive: true, force: true });
531
+ await rm(join(transactionRoot, transaction.id), { recursive: true, force: true });
532
+ await rm(transactionPath, { force: true });
533
+ }
534
+
535
+ async function writeTransactionJournal(transaction) {
536
+ const durable = {
537
+ schemaVersion: transaction.schemaVersion,
538
+ id: transaction.id,
539
+ state: transaction.state,
540
+ entries: transaction.entries,
541
+ };
542
+ await durableReplaceFile(transactionPath, `${JSON.stringify(durable, null, 2)}\n`, transaction.id);
543
+ }
544
+
545
+ async function durableReplaceFile(path, content, nonce = randomUUID()) {
546
+ await mkdir(dirname(path), { recursive: true });
547
+ const candidate = `${path}.${nonce}.tmp`;
548
+ const file = await open(candidate, "w", 0o600);
549
+ try {
550
+ await file.writeFile(content, "utf8");
551
+ await file.sync();
552
+ } finally {
553
+ await file.close();
554
+ }
555
+ await rename(candidate, path);
556
+ // Directory fsync is unavailable on some Windows/filesystem combinations. The
557
+ // file fsync+atomic rename remains the portable floor; directory sync is best effort.
558
+ try {
559
+ const directory = await open(dirname(path), "r");
560
+ try { await directory.sync(); } finally { await directory.close(); }
561
+ } catch (error) {
562
+ if (!(["EINVAL", "ENOTSUP", "EISDIR", "EPERM", "EACCES"].includes(error?.code))) throw error;
563
+ }
564
+ }
565
+
566
+ async function recoverInterruptedTransaction() {
567
+ if (!(await exists(transactionPath))) return;
568
+ let journal;
569
+ try {
570
+ journal = JSON.parse(await readFile(transactionPath, "utf8"));
571
+ } catch {
572
+ throw new Error(`Lifecycle transaction journal is invalid: ${transactionPath}`);
573
+ }
574
+ validateTransactionJournal(journal, { strict: true });
575
+ if (journal.state === "committed") {
576
+ await rm(join(transactionRoot, journal.id), { recursive: true, force: true });
577
+ await rm(transactionPath, { force: true });
578
+ return;
579
+ }
580
+ await rollbackEntries(journal);
581
+ await rm(join(transactionRoot, journal.id), { recursive: true, force: true });
582
+ await rm(transactionPath, { force: true });
583
+ }
584
+
585
+ function validateTransactionJournal(journal, { strict = false } = {}) {
586
+ if (journal?.schemaVersion !== 1 || typeof journal.id !== "string" || !/^[a-f0-9-]{20,}$/i.test(journal.id)
587
+ || !["active", "committed"].includes(journal.state) || !Array.isArray(journal.entries)) {
588
+ throw new Error("Lifecycle transaction journal has an invalid schema.");
589
+ }
590
+ if (strict && !sameKeys(journal, ["schemaVersion", "id", "state", "entries"])) throw new Error("Lifecycle transaction journal has unknown fields.");
591
+ const allowedBackupRoot = resolve(transactionRoot, journal.id, "backup");
592
+ const ids = new Set();
593
+ const paths = new Set();
594
+ for (const entry of journal.entries) {
595
+ const expected = entry.id === "ownership"
596
+ ? { phase: "manifest", path: ownershipPath, type: "file" }
597
+ : entry.id === "previous-codex-runtime"
598
+ ? previousRuntimeTarget(entry.path)
599
+ : expectedManagedTarget(entry.id);
600
+ if (!expected || expected.path !== entry.path || expected.phase !== entry.phase || expected.type !== entry.type
601
+ || !["prepared", "applied"].includes(entry.status)
602
+ || !["replace", "remove"].includes(entry.operation)
603
+ || typeof entry.existed !== "boolean"
604
+ || !isInside(allowedBackupRoot, entry.backup)
605
+ || typeof entry.expectedNewDigest !== "string" || typeof entry.previousDigest !== "string") {
606
+ throw new Error(`Lifecycle transaction journal contains an unsafe entry: ${entry?.id ?? "unknown"}`);
607
+ }
608
+ if (strict && !sameKeys(entry, ["id", "phase", "path", "type", "backup", "existed", "previousDigest", "expectedNewDigest", "operation", "status"])) {
609
+ throw new Error(`Lifecycle transaction journal entry has unknown fields: ${entry.id}`);
610
+ }
611
+ if (ids.has(entry.id) || paths.has(entry.path)) throw new Error(`Lifecycle transaction journal contains duplicate targets: ${entry.id}`);
612
+ if (entry.operation === "remove" && entry.expectedNewDigest !== "missing") throw new Error(`Lifecycle remove entry has an invalid new digest: ${entry.id}`);
613
+ ids.add(entry.id);
614
+ paths.add(entry.path);
615
+ }
616
+ }
617
+
618
+ function previousRuntimeTarget(path) {
619
+ const parent = join(marketplaceRoot, pluginName);
620
+ const relation = relative(parent, resolve(path));
621
+ if (!relation || relation.includes(sep) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(relation) || relation === releaseVersion) return null;
622
+ return { phase: "cleanup", path: join(parent, relation), type: "tree" };
139
623
  }
140
624
 
141
- async function installClaudeFiles() {
142
- await mkdir(join(claudeHome, "commands"), { recursive: true });
143
- await mkdir(join(claudeHome, "agents"), { recursive: true });
144
- await writeRuntimeLocator(claudeRuntimeLocatorPath);
625
+ function sameKeys(value, expected) {
626
+ return Object.keys(value).sort().join("\0") === [...expected].sort().join("\0");
627
+ }
628
+
629
+ async function rollbackEntries(transaction) {
630
+ validateTransactionJournal(transaction);
631
+ for (const entry of [...transaction.entries].reverse()) {
632
+ const destinationExists = await exists(entry.path);
633
+ const backupExists = await exists(entry.backup);
634
+ if (backupExists) {
635
+ if (!entry.existed) throw new Error(`Cannot recover transaction because an unexpected backup exists: ${entry.path}`);
636
+ if (await targetDigest(entry.backup, entry.type) !== entry.previousDigest) {
637
+ throw new Error(`Cannot recover transaction because its durable backup changed: ${entry.path}`);
638
+ }
639
+ if (destinationExists) {
640
+ const current = await targetDigest(entry.path, entry.type);
641
+ if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover transaction because destination changed: ${entry.path}`);
642
+ if (entry.type === "config") {
643
+ const currentConfig = await readText(entry.path);
644
+ const previousConfig = await readText(entry.backup);
645
+ await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
646
+ await rm(entry.backup, { force: true });
647
+ continue;
648
+ }
649
+ await rm(entry.path, { recursive: true, force: true });
650
+ }
651
+ await mkdir(dirname(entry.path), { recursive: true });
652
+ await rename(entry.backup, entry.path);
653
+ } else if (!entry.existed) {
654
+ if (destinationExists) {
655
+ const current = await targetDigest(entry.path, entry.type);
656
+ if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover new transaction target because it changed: ${entry.path}`);
657
+ if (entry.type === "config") {
658
+ const preserved = removeCairnConfig(await readText(entry.path));
659
+ if (preserved.length > 0) await writeFile(entry.path, preserved);
660
+ else await rm(entry.path, { force: true });
661
+ } else await rm(entry.path, { recursive: true, force: true });
662
+ }
663
+ } else {
664
+ if (!destinationExists || await targetDigest(entry.path, entry.type) !== entry.previousDigest) {
665
+ throw new Error(`Cannot recover transaction because its durable backup is missing: ${entry.path}`);
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ function isInside(root, candidate) {
672
+ const path = resolve(candidate);
673
+ return path === root || path.startsWith(`${root}${sep}`);
674
+ }
675
+
676
+ function injectFailure(point) {
677
+ if (process.env.CAIRN_TEST_FAIL_PHASE === point) throw new Error(`Injected lifecycle failure: ${point}`);
678
+ }
679
+
680
+ async function assertTargetsReplaceable(targets, ownership) {
681
+ const records = new Map((ownership?.targets ?? []).map((record) => [record.id, record]));
682
+ for (const target of targets) {
683
+ if (!(await exists(target.path))) continue;
684
+ if (target.id === "previous-codex-runtime") {
685
+ const previous = records.get("codex-runtime");
686
+ if (!previous || previous.path !== target.path || previous.type !== target.type) throw new Error("Previous runtime does not match ownership manifest.");
687
+ continue;
688
+ }
689
+ const record = records.get(target.id);
690
+ if (!record) {
691
+ if (target.type === "config" && cairnConfigProjection(await readText(target.path)).length === 0) continue;
692
+ throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
693
+ }
694
+ if (target.id === "codex-runtime" && ownership?.version !== releaseVersion) {
695
+ throw new Error(`Refusing to overwrite unmanaged current-version runtime: ${target.path}`);
696
+ }
697
+ if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
698
+ const current = await targetDigest(target.path, target.type);
699
+ if (current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
700
+ }
701
+ }
702
+
703
+ async function assertOwnershipDigests(ownership) {
704
+ for (const record of ownership.targets) {
705
+ if (await targetDigest(record.path, record.type) !== record.installedDigest) {
706
+ throw new Error(`Managed artifact was modified: ${record.path}`);
707
+ }
708
+ }
709
+ }
710
+
711
+ async function targetRecord(target) {
712
+ return { id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null };
713
+ }
714
+
715
+ async function readOwnership({ required = false } = {}) {
716
+ if (!(await exists(ownershipPath))) {
717
+ if (required) throw new Error(`Ownership manifest is missing: ${ownershipPath}`);
718
+ return null;
719
+ }
720
+ try {
721
+ return JSON.parse(await readFile(ownershipPath, "utf8"));
722
+ } catch {
723
+ throw new Error(`Ownership manifest is invalid: ${ownershipPath}`);
724
+ }
725
+ }
726
+
727
+ function validateOwnershipShape(ownership, { required = false } = {}) {
728
+ if (!ownership) {
729
+ if (required) throw new Error("Ownership manifest is required.");
730
+ return;
731
+ }
732
+ if (!sameKeys(ownership, ["schemaVersion", "plugin", "version", "transactionId", "installedAt", "targets"])
733
+ || ownership.schemaVersion !== 1 || ownership.plugin !== pluginName
734
+ || typeof ownership.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ownership.version)
735
+ || typeof ownership.transactionId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(ownership.transactionId)
736
+ || typeof ownership.installedAt !== "string" || Number.isNaN(Date.parse(ownership.installedAt))
737
+ || !Array.isArray(ownership.targets)) {
738
+ throw new Error("Ownership manifest schema or plugin identity is invalid.");
739
+ }
740
+ const ids = new Set();
741
+ const paths = new Set();
742
+ for (const record of ownership.targets) {
743
+ if (!record || !sameKeys(record, ["id", "phase", "path", "type", "installedDigest", "previous"])
744
+ || typeof record.id !== "string" || record.id.length === 0 || ids.has(record.id)
745
+ || typeof record.path !== "string" || record.path.length === 0 || paths.has(record.path)
746
+ || typeof record.phase !== "string" || !phases.includes(record.phase)
747
+ || typeof record.type !== "string" || !["file", "tree", "config"].includes(record.type)
748
+ || typeof record.installedDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(record.installedDigest)
749
+ || record.previous !== null) {
750
+ throw new Error("Ownership manifest contains an invalid or duplicate target.");
751
+ }
752
+ const expected = expectedManagedTarget(record.id, ownership.version);
753
+ if (!expected || expected.path !== record.path || expected.phase !== record.phase || expected.type !== record.type) {
754
+ throw new Error(`Ownership manifest target is outside the Cairn allowlist: ${record.id}`);
755
+ }
756
+ ids.add(record.id);
757
+ paths.add(record.path);
758
+ }
759
+ const requiredTargets = requiredOwnershipTargets(ownership.version);
760
+ if (ids.size !== requiredTargets.size || [...requiredTargets.keys()].some((id) => !ids.has(id))) {
761
+ throw new Error("Ownership manifest does not contain the complete managed target set.");
762
+ }
763
+ }
764
+
765
+ function requiredOwnershipTargets(version = releaseVersion) {
766
+ const ids = ["codex-source", "codex-runtime", "marketplace", "claude-runtime", "ide-runtime", "cli-runtime", "codex-config"];
767
+ for (const name of commandNames()) ids.push(`claude-command-${name}`, `ide-workflow-${name}`, `cli-workflow-${name}`);
768
+ for (const name of ["explorer", "worker"]) ids.push(`claude-agent-${name}`);
769
+ for (const name of skillNames()) ids.push(`ide-skill-${name}`, `cli-skill-${name}`);
770
+ return new Map(ids.map((id) => [id, expectedManagedTarget(id, version)]));
771
+ }
772
+
773
+ function expectedManagedTarget(id, version = releaseVersion) {
774
+ const exact = {
775
+ "codex-source": { phase: "codex", path: installedPluginRoot, type: "tree" },
776
+ "codex-runtime": { phase: "codex", path: versionedRuntimeRoot(version), type: "tree" },
777
+ marketplace: { phase: "codex", path: marketplaceJsonPath, type: "file" },
778
+ "claude-runtime": { phase: "claude", path: claudeRuntimeLocatorPath, type: "file" },
779
+ "ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityHome), type: "file" },
780
+ "cli-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityCliHome), type: "file" },
781
+ "legacy-ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file" },
782
+ "codex-config": { phase: "config", path: configPath, type: "config" },
783
+ };
784
+ if (exact[id]) return exact[id];
145
785
  for (const name of commandNames()) {
146
- await renderMirrorFile(
147
- join(installedPluginRoot, ".claude", "commands", `cairn-${name}.md`),
148
- join(claudeHome, "commands", `cairn-${name}.md`),
149
- claudeRuntimeLocatorPath,
150
- );
786
+ if (id === `claude-command-${name}`) return { phase: "claude", path: join(claudeHome, "commands", `cairn-${name}.md`), type: "file" };
787
+ if (id === `ide-workflow-${name}`) return { phase: "antigravity", path: join(antigravityHome, "workflows", `cairn-${name}.md`), type: "file" };
788
+ if (id === `cli-workflow-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "workflows", `cairn-${name}.md`), type: "file" };
789
+ if (id === `legacy-ide-workflow-${name}`) return { phase: "antigravity", path: join(legacyAntigravityHome, "workflows", `cairn-${name}.md`), type: "file" };
151
790
  }
152
- for (const name of ["explorer", "worker"]) {
153
- await renderMirrorFile(
154
- join(installedPluginRoot, ".claude", "agents", `${name}.md`),
155
- join(claudeHome, "agents", `cairn-${name}.md`),
156
- claudeRuntimeLocatorPath,
157
- );
791
+ for (const name of ["explorer", "worker"]) if (id === `claude-agent-${name}`) return { phase: "claude", path: join(claudeHome, "agents", `cairn-${name}.md`), type: "file" };
792
+ for (const name of skillNames()) {
793
+ if (id === `ide-skill-${name}`) return { phase: "antigravity", path: join(antigravityHome, "skills", name), type: "tree" };
794
+ if (id === `cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", `${name}.md`), type: "file" };
795
+ if (id === `legacy-ide-skill-${name}`) return { phase: "antigravity", path: join(legacyAntigravityHome, "skills", name), type: "tree" };
796
+ if (id === `legacy-cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", name), type: "tree" };
158
797
  }
798
+ return null;
159
799
  }
160
800
 
161
- async function installAntigravityFiles() {
162
- for (const root of [antigravityHome, antigravityCliHome]) {
163
- await mkdir(join(root, "skills"), { recursive: true });
164
- await mkdir(join(root, "workflows"), { recursive: true });
165
- await writeRuntimeLocator(antigravityRuntimeLocatorPath(root));
166
- for (const name of skillNames()) {
167
- await rm(join(root, "skills", name), { recursive: true, force: true });
168
- await cp(join(installedPluginRoot, "skills", name), join(root, "skills", name), { recursive: true });
169
- await renderMirrorFile(
170
- join(installedPluginRoot, "skills", name, "SKILL.md"),
171
- join(root, "skills", name, "SKILL.md"),
172
- antigravitySkillLocatorPath(root, name),
173
- { includeLocatorNotice: true },
174
- );
175
- await writeRuntimeLocator(antigravitySkillLocatorPath(root, name));
801
+ function versionedRuntimeRoot(version) {
802
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error(`Invalid Cairn runtime version: ${version}`);
803
+ return join(marketplaceRoot, pluginName, version);
804
+ }
805
+
806
+ async function anyManagedArtifactExists(targets) {
807
+ for (const target of targets) {
808
+ if (!(await exists(target.path))) continue;
809
+ if (target.type !== "config" || cairnConfigProjection(await readText(target.path)).length > 0) return true;
810
+ }
811
+ return false;
812
+ }
813
+
814
+ async function adoptLegacy(targets, transaction) {
815
+ if (!(await exists(installedPluginRoot))) throw new Error("Existing lifecycle artifacts are unmanaged; legacy Cairn root is missing.");
816
+ await verifyLegacy022Root(installedPluginRoot);
817
+ const records = [];
818
+ for (const target of targets) {
819
+ if (!(await exists(target.path))) continue;
820
+ if (target.id === "codex-runtime") continue;
821
+ if (target.id === "marketplace") {
822
+ const marketplace = await readJson(target.path);
823
+ const entry = marketplace?.plugins?.find((plugin) => plugin.name === pluginName);
824
+ if (marketplace?.name !== marketplaceName || entry?.source?.source !== "local" || entry?.source?.path !== "./plugins/cairn") {
825
+ throw new Error(`Legacy Cairn marketplace cannot be identified: ${target.path}`);
826
+ }
176
827
  }
177
- for (const name of commandNames()) {
178
- await renderMirrorFile(
179
- join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`),
180
- join(root, "workflows", `cairn-${name}.md`),
181
- antigravityRuntimeLocatorPath(root),
182
- );
828
+ if (target.type === "config") {
829
+ const projection = cairnConfigProjection(await readText(target.path));
830
+ if (!projection.includes("[marketplaces.cairn]") || !projection.includes('[plugins."cairn@cairn"]')) {
831
+ throw new Error(`Legacy Cairn config cannot be identified: ${target.path}`);
832
+ }
833
+ } else if (target.id.endsWith("-runtime")) {
834
+ const locator = await readJson(target.path);
835
+ if (!validLegacyLocator(locator, installedPluginRoot)) throw new Error(`Legacy runtime mirror is invalid: ${target.path}`);
836
+ } else if (target.id !== "codex-source" && target.id !== "marketplace") {
837
+ await verifyLegacyMirror(target);
183
838
  }
839
+ const backup = join(transaction.backupRoot, `legacy-${records.length}`);
840
+ await mkdir(dirname(backup), { recursive: true });
841
+ await cp(target.path, backup, { recursive: true });
842
+ records.push({ id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null });
184
843
  }
844
+ return { schemaVersion: 1, plugin: pluginName, version: "0.2.2", transactionId: `legacy-${transaction.id}`, targets: records };
185
845
  }
186
846
 
187
- async function uninstallAntigravityFiles() {
188
- for (const root of [antigravityHome, antigravityCliHome]) {
189
- for (const name of skillNames()) {
190
- await rm(join(root, "skills", name), { recursive: true, force: true });
847
+ export async function verifyLegacy022Root(root) {
848
+ const integrity = JSON.parse(await readFile(join(pluginRoot, "scripts", "release-integrity-0.2.2.json"), "utf8"));
849
+ const allowedGenerated = new Set([".cairn-runtime.json", ...skillNames().map((name) => `skills/${name}/references/cairn-runtime.json`)]);
850
+ const actual = await regularFiles(root);
851
+ const expected = new Set([...Object.keys(integrity.files), ...allowedGenerated]);
852
+ if (actual.length !== expected.size || actual.some((file) => !expected.has(file))) throw new Error("Legacy Cairn 0.2.2 root file set does not match the signed release allowlist.");
853
+ for (const [path, expectedHash] of Object.entries(integrity.files)) {
854
+ const content = await readFile(join(root, path));
855
+ if (path === ".codex-plugin/plugin.json") {
856
+ const manifest = JSON.parse(content);
857
+ if (manifest.name !== "cairn" || manifest.version !== "0.2.2" || manifest.hooks !== "./hooks/hooks.json") throw new Error("Legacy Cairn manifest identity is invalid.");
858
+ delete manifest.hooks;
859
+ if (sha(`${JSON.stringify(manifest, null, 2)}\n`) !== `sha256:${expectedHash}`) throw new Error("Legacy Cairn manifest differs from release 0.2.2.");
860
+ } else if (sha(content) !== `sha256:${expectedHash}`) throw new Error(`Legacy Cairn release file was modified: ${path}`);
861
+ }
862
+ for (const path of allowedGenerated) {
863
+ const locator = JSON.parse(await readFile(join(root, path), "utf8"));
864
+ if (!validLegacyLocator(locator, root)) throw new Error(`Legacy runtime locator is invalid: ${path}`);
865
+ }
866
+ }
867
+
868
+ function validLegacyLocator(locator, root) {
869
+ const expected = createRuntimeLocator(root);
870
+ return locator?.schemaVersion === RUNTIME_LOCATOR_SCHEMA_VERSION
871
+ && locator.pluginRoot === expected.pluginRoot
872
+ && JSON.stringify(locator.entrypoints) === JSON.stringify(expected.entrypoints)
873
+ && JSON.stringify(locator.resources) === JSON.stringify(expected.resources);
874
+ }
875
+
876
+ async function verifyLegacyMirror(target) {
877
+ // Legacy mirrors are accepted only when byte-identical to a deterministic render
878
+ // from the verified release tree. Unknown or user-modified mirrors are preserved.
879
+ const temp = await mkdtemp(join(tmpdir(), "cairn-legacy-mirror-"));
880
+ try {
881
+ const expected = join(temp, "expected");
882
+ if (target.id.startsWith("claude-command-")) {
883
+ const name = target.id.slice("claude-command-".length);
884
+ await renderMirrorFile(join(installedPluginRoot, ".claude", "commands", `cairn-${name}.md`), expected, claudeRuntimeLocatorPath, {}, installedPluginRoot);
885
+ } else if (target.id.startsWith("claude-agent-")) {
886
+ const name = target.id.slice("claude-agent-".length);
887
+ await renderMirrorFile(join(installedPluginRoot, ".claude", "agents", `${name}.md`), expected, claudeRuntimeLocatorPath, {}, installedPluginRoot);
888
+ } else if (target.id.startsWith("ide-skill-")) {
889
+ const name = target.id.slice("ide-skill-".length);
890
+ await cp(join(installedPluginRoot, "skills", name), expected, { recursive: true });
891
+ const locator = antigravitySkillLocatorPath(antigravityHome, name);
892
+ await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), join(expected, "SKILL.md"), locator, { includeLocatorNotice: true }, installedPluginRoot);
893
+ await writeLocator(join(expected, "references", "cairn-runtime.json"), installedPluginRoot);
894
+ } else if (target.id.startsWith("cli-skill-")) {
895
+ const name = target.id.slice("cli-skill-".length);
896
+ await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), expected, antigravityRuntimeLocatorPath(antigravityCliHome), { includeLocatorNotice: true }, installedPluginRoot);
897
+ } else if (target.id.startsWith("legacy-ide-skill-") || target.id.startsWith("legacy-cli-skill-")) {
898
+ const isIde = target.id.startsWith("legacy-ide-skill-");
899
+ const prefix = isIde ? "legacy-ide-skill-" : "legacy-cli-skill-";
900
+ const name = target.id.slice(prefix.length);
901
+ const root = isIde ? legacyAntigravityHome : antigravityCliHome;
902
+ const locator = antigravitySkillLocatorPath(root, name);
903
+ await cp(join(installedPluginRoot, "skills", name), expected, { recursive: true });
904
+ await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), join(expected, "SKILL.md"), locator, { includeLocatorNotice: true }, installedPluginRoot);
905
+ await writeLocator(join(expected, "references", "cairn-runtime.json"), installedPluginRoot);
906
+ } else if (target.id.startsWith("legacy-ide-workflow-")) {
907
+ const name = target.id.slice("legacy-ide-workflow-".length);
908
+ await renderMirrorFile(join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`), expected, antigravityRuntimeLocatorPath(legacyAntigravityHome), {}, installedPluginRoot);
909
+ } else if (target.id.startsWith("ide-workflow-") || target.id.startsWith("cli-workflow-")) {
910
+ const isIde = target.id.startsWith("ide-workflow-");
911
+ const prefix = isIde ? "ide-workflow-" : "cli-workflow-";
912
+ const name = target.id.slice(prefix.length);
913
+ const root = isIde ? antigravityHome : antigravityCliHome;
914
+ await renderMirrorFile(join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`), expected, antigravityRuntimeLocatorPath(root), {}, installedPluginRoot);
915
+ } else {
916
+ throw new Error(`Legacy mirror target is not recognized: ${target.id}`);
191
917
  }
192
- for (const name of commandNames()) {
193
- await rm(join(root, "workflows", `cairn-${name}.md`), { force: true });
918
+ if (await targetDigest(expected, "file") !== await targetDigest(target.path, target.type)) throw new Error(`Legacy mirror was modified: ${target.path}`);
919
+ } finally {
920
+ await rm(temp, { recursive: true, force: true });
921
+ }
922
+ }
923
+
924
+ export async function targetDigest(path, type) {
925
+ if (!(await exists(path))) return "missing";
926
+ if (type === "config") return sha(cairnConfigProjection(await readText(path)));
927
+ const info = await lstat(path);
928
+ if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
929
+ if (info.isFile()) return sha(await readFile(path));
930
+ const files = await regularFiles(path);
931
+ const hash = createHash("sha256");
932
+ for (const file of files) {
933
+ hash.update(file).update("\0").update(await readFile(join(path, file))).update("\0");
934
+ }
935
+ return `sha256:${hash.digest("hex")}`;
936
+ }
937
+
938
+ async function regularFiles(root) {
939
+ const output = [];
940
+ async function walk(directory) {
941
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
942
+ const path = join(directory, entry.name);
943
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) throw new Error(`Symlink or special file is not allowed in managed tree: ${path}`);
944
+ if (entry.isDirectory()) await walk(path);
945
+ else output.push(relative(root, path).split(sep).join("/"));
194
946
  }
195
- await rm(join(root, "cairn"), { recursive: true, force: true });
196
947
  }
948
+ const info = await lstat(root);
949
+ if (info.isSymbolicLink()) throw new Error(`Symlink is not allowed: ${root}`);
950
+ if (info.isFile()) return [basename(root)];
951
+ await walk(root);
952
+ return output.sort();
953
+ }
954
+
955
+ async function assertRegularTree(path) {
956
+ await regularFiles(path);
197
957
  }
198
958
 
199
- export function updateConfig(config, hookStates) {
959
+ function sha(value) {
960
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
961
+ }
962
+
963
+ export function updateConfig(config, hookStates, { marketplacePath = marketplaceRoot } = {}) {
200
964
  let next = removeCairnConfig(config);
201
- next = ensureSetting(next, "features", "plugins", "true");
202
- next = ensureSetting(next, "features", "plugin_hooks", "true");
203
- next = ensureSetting(next, "features", "multi_agent", "true");
204
- next = ensureSetting(next, "agents", "max_depth", "2");
205
- next = append(next, `[marketplaces.cairn]\nlast_updated = ${JSON.stringify(new Date().toISOString().replace(/\.\d{3}Z$/, "Z"))}\nsource_type = "local"\nsource = ${JSON.stringify(marketplaceRoot)}\n`);
965
+ next = append(next, `[marketplaces.cairn]\nlast_updated = ${JSON.stringify(new Date().toISOString().replace(/\.\d{3}Z$/, "Z"))}\nsource_type = "local"\nsource = ${JSON.stringify(marketplacePath)}\n`);
206
966
  next = append(next, '[plugins."cairn@cairn"]\nenabled = true\n');
207
967
  for (const state of hookStates) next = append(next, `[hooks.state.${JSON.stringify(state.key)}]\ntrusted_hash = ${JSON.stringify(state.trustedHash)}\n`);
208
968
  return next;
@@ -214,7 +974,14 @@ export function removeCairnConfig(config) {
214
974
  if (section.header === "marketplaces.cairn") return false;
215
975
  if (section.header === 'plugins."cairn@cairn"') return false;
216
976
  return !(section.header.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
217
- }).map((section) => section.text).join("").replace(/\n{3,}/g, "\n\n");
977
+ }).map((section) => section.text).join("");
978
+ }
979
+
980
+ function cairnConfigProjection(config) {
981
+ return splitSections(config).filter((section) => section.header === "marketplaces.cairn"
982
+ || section.header === 'plugins."cairn@cairn"'
983
+ || (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:")))
984
+ .map((section) => section.text).join("");
218
985
  }
219
986
 
220
987
  export function ensureSetting(config, sectionName, key, value) {
@@ -237,30 +1004,37 @@ export function hasSetting(config, sectionName, key, value) {
237
1004
  export function splitSections(config) {
238
1005
  const result = [];
239
1006
  let current = { header: null, text: "" };
1007
+ let multiline = null;
240
1008
  for (const line of config.split(/(?<=\n)/)) {
1009
+ const delimiterCount = (delimiter) => line.split(delimiter).length - 1;
1010
+ if (multiline) {
1011
+ current.text += line;
1012
+ if (delimiterCount(multiline) % 2 === 1) multiline = null;
1013
+ continue;
1014
+ }
1015
+ const doubleCount = delimiterCount('"""');
1016
+ const singleCount = delimiterCount("'''");
241
1017
  const match = line.trim().match(/^\[([^\]]+)\]$/);
242
1018
  if (match) {
243
1019
  if (current.text.length > 0) result.push(current);
244
1020
  current = { header: match[1], text: line };
245
1021
  } else current.text += line;
1022
+ if (doubleCount % 2 === 1) multiline = '"""';
1023
+ else if (singleCount % 2 === 1) multiline = "'''";
246
1024
  }
247
1025
  if (current.text.length > 0) result.push(current);
248
1026
  return result;
249
1027
  }
250
1028
 
251
- async function trustedHookStates() {
252
- const hooksPath = join(installedPluginRoot, "hooks", "hooks.json");
253
- const parsed = await readJson(hooksPath);
1029
+ async function trustedHookStates(root) {
1030
+ const parsed = await readJson(join(root, "hooks", "hooks.json"));
254
1031
  const states = [];
255
1032
  const labels = { SessionStart: "session_start", UserPromptSubmit: "user_prompt_submit", PostToolUse: "post_tool_use", Stop: "stop", SubagentStop: "subagent_stop" };
256
1033
  for (const [eventName, groups] of Object.entries(parsed?.hooks ?? {})) {
257
1034
  const eventLabel = labels[eventName];
258
1035
  if (!eventLabel || !Array.isArray(groups)) continue;
259
- for (const [groupIndex, group] of groups.entries()) {
260
- for (const [handlerIndex, handler] of (group.hooks ?? []).entries()) {
261
- const key = `cairn@cairn:hooks/hooks.json:${eventLabel}:${groupIndex}:${handlerIndex}`;
262
- states.push({ key, trustedHash: hookHash(eventLabel, group.matcher, handler) });
263
- }
1036
+ for (const [groupIndex, group] of groups.entries()) for (const [handlerIndex, handler] of (group.hooks ?? []).entries()) {
1037
+ states.push({ key: `cairn@cairn:hooks/hooks.json:${eventLabel}:${groupIndex}:${handlerIndex}`, trustedHash: hookHash(eventLabel, group.matcher, handler) });
264
1038
  }
265
1039
  }
266
1040
  return states;
@@ -271,7 +1045,7 @@ export function hookHash(eventName, matcher, handler) {
271
1045
  if (typeof handler.statusMessage === "string") normalized.statusMessage = handler.statusMessage;
272
1046
  const identity = { event_name: eventName, hooks: [normalized] };
273
1047
  if (typeof matcher === "string") identity.matcher = matcher;
274
- return `sha256:${createHash("sha256").update(JSON.stringify(canonical(identity))).digest("hex")}`;
1048
+ return sha(JSON.stringify(canonical(identity)));
275
1049
  }
276
1050
 
277
1051
  export function canonical(value) {
@@ -280,207 +1054,110 @@ export function canonical(value) {
280
1054
  return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
281
1055
  }
282
1056
 
283
- async function backupConfig() {
284
- if (!(await exists(configPath))) return;
285
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
286
- await cp(configPath, `${configPath}.cairn-backup-${stamp}`);
287
- }
288
-
289
- async function readJson(path) {
290
- if (!(await exists(path))) return null;
291
- return JSON.parse(await readFile(path, "utf8"));
292
- }
293
-
294
- async function readText(path) {
295
- if (!(await exists(path))) return "";
296
- return readFile(path, "utf8");
297
- }
298
-
299
- async function exists(path) {
300
- try {
301
- await stat(path);
302
- return true;
303
- } catch {
304
- return false;
305
- }
306
- }
307
-
308
1057
  function append(config, block) {
309
- return `${config.trimEnd()}${config.trimEnd().length === 0 ? "" : "\n\n"}${block.trimEnd()}\n`;
310
- }
311
-
312
- export function pathHasSegment(path, segment) {
313
- return path.split(/[\\/]+/).includes(segment);
1058
+ if (config.length === 0) return `${block.trimEnd()}\n`;
1059
+ return `${config}${config.endsWith("\n") ? "" : "\n"}${block.trimEnd()}\n`;
314
1060
  }
315
1061
 
1062
+ export function pathHasSegment(path, segment) { return path.split(/[\\/]+/).includes(segment); }
316
1063
  export function shouldCopyPluginPath(source) {
317
1064
  const name = source.split(/[\\/]+/).at(-1);
318
- return name !== ".git" && name !== "node_modules";
319
- }
320
-
321
- export function samePath(left, right) {
322
- try {
323
- return realpathSync(left) === realpathSync(right);
324
- } catch {
325
- return resolve(left) === resolve(right);
326
- }
327
- }
328
-
329
- export function commandNames() {
330
- return ["install", "upgrade", "doctor", "uninstall", "memory", "plan", "work", "review", "toolcheck"];
1065
+ if ([".git", "node_modules", ".cairn"].includes(name)) return false;
1066
+ if (sep === "/" && source.includes("\\")) return true;
1067
+ const relativePath = relative(pluginRoot, source);
1068
+ // Windows fs.cp filters can surface an absolute path when source and pluginRoot
1069
+ // use different drive/callback representations. Excluded basenames remain denied.
1070
+ if (isAbsolute(relativePath)) return true;
1071
+ const path = relativePath.split(sep).join("/");
1072
+ if (path === "") return true;
1073
+ if (path.startsWith("../") || path === "..") return true;
1074
+ if (/^(package\.json|LICENSE|README(?:\.[^.]+)?\.md)$/.test(path)) return true;
1075
+ if (path === "docs") return true;
1076
+ if (path.startsWith("docs/")) return path === "docs/model-guidance" || path.startsWith("docs/model-guidance/");
1077
+ return [".agents", ".claude", ".codex-plugin", "agents", "commands", "hooks", "scripts", "skills", "templates"]
1078
+ .some((directory) => path === directory || path.startsWith(`${directory}/`));
331
1079
  }
1080
+ export function samePath(left, right) { try { return realpathSync(left) === realpathSync(right); } catch { return resolve(left) === resolve(right); } }
1081
+ export function commandNames() { return ["install", "upgrade", "doctor", "uninstall", "memory", "plan", "work", "review", "toolcheck"]; }
1082
+ export function skillNames() { return ["cairn-memory", "cairn-plan", "cairn-work", "cairn-review"]; }
332
1083
 
333
- export function skillNames() {
334
- return ["cairn-memory", "cairn-plan", "cairn-work", "cairn-review"];
335
- }
336
-
337
- export function renderInstalledMirror(content, {
338
- runtimeRoot = installedPluginRoot,
339
- locatorPath = installedRuntimeLocatorPath,
340
- platform = process.platform,
341
- } = {}) {
1084
+ export function renderInstalledMirror(content, { runtimeRoot = versionedPluginRoot, locatorPath = installedRuntimeLocatorPath, platform = process.platform } = {}) {
342
1085
  const platformJoin = platform === "win32" ? win32.join : posix.join;
343
1086
  const quotedCli = quoteShellArg(platformJoin(runtimeRoot, "scripts", "cairn.mjs"), platform);
344
- let rendered = content
345
- .replaceAll("{{CAIRN_RUNTIME_LOCATOR_JSON}}", JSON.stringify(locatorPath))
1087
+ let rendered = content.replaceAll("{{CAIRN_RUNTIME_LOCATOR_JSON}}", JSON.stringify(locatorPath))
346
1088
  .replace(/\bnode\s+scripts\/cairn\.mjs\b/g, `node ${quotedCli}`)
347
1089
  .replace(/\bnode\s+scripts\/([A-Za-z0-9._/-]+)/g, (_, path) => `node ${quoteShellArg(join(runtimeRoot, "scripts", path), platform)}`);
348
-
349
- rendered = rendered.replace(
350
- /(^|[\s`("'=])(commands|agents|templates|docs\/model-guidance)\/([A-Za-z0-9._<>/-]+)/gm,
351
- (_, prefix, directory, suffix) => `${prefix}${platformJoin(runtimeRoot, directory, suffix)}`,
352
- );
1090
+ rendered = rendered.replace(/(^|[\s`("'=])(commands|agents|templates|docs\/model-guidance)\/([A-Za-z0-9._<>/-]+)/gm,
1091
+ (_, prefix, directory, suffix) => `${prefix}${platformJoin(runtimeRoot, directory, suffix)}`);
353
1092
  return rendered;
354
1093
  }
355
1094
 
356
- export function quoteShellArg(value, platform = process.platform) {
357
- if (platform === "win32") return `"${value.replaceAll('"', '""')}"`;
358
- return `'${value.replaceAll("'", "'\\''")}'`;
359
- }
360
-
361
- function antigravityRuntimeLocatorPath(root) {
362
- return join(root, "cairn", "runtime.json");
363
- }
364
-
365
- function installedSkillLocatorPath(skillName) {
366
- return join(installedPluginRoot, "skills", skillName, "references", "cairn-runtime.json");
367
- }
368
-
369
- function antigravitySkillLocatorPath(root, skillName) {
370
- return join(root, "skills", skillName, "references", "cairn-runtime.json");
371
- }
372
-
373
- async function writeRuntimeLocator(path) {
374
- await mkdir(dirname(path), { recursive: true });
375
- await writeFile(path, `${JSON.stringify(createRuntimeLocator(installedPluginRoot), null, 2)}\n`);
376
- }
377
-
378
- async function writeInstalledSkillLocators() {
379
- await Promise.all(skillNames().map((name) => writeRuntimeLocator(installedSkillLocatorPath(name))));
380
- }
1095
+ export function quoteShellArg(value, platform = process.platform) { return platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`; }
1096
+ function antigravityRuntimeLocatorPath(root) { return join(root, "cairn", "runtime.json"); }
1097
+ function installedSkillLocatorPath(name) { return join(versionedPluginRoot, "skills", name, "references", "cairn-runtime.json"); }
1098
+ function antigravitySkillLocatorPath(root, name) { return join(root, "skills", name, "references", "cairn-runtime.json"); }
381
1099
 
1100
+ async function writeLocator(path, root) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(createRuntimeLocator(root), null, 2)}\n`); }
382
1101
  async function validRuntimeLocator(path) {
383
1102
  try {
384
1103
  const locator = await readJson(path);
385
- const expected = createRuntimeLocator(installedPluginRoot);
386
- if (locator?.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION) return false;
387
- if (resolve(locator.pluginRoot ?? ".") !== resolve(installedPluginRoot)) return false;
388
- for (const [name, expectedPath] of Object.entries(expected.entrypoints)) {
389
- if (locator.entrypoints?.[name] !== expectedPath) return false;
390
- }
391
- for (const [name, expectedPath] of Object.entries(expected.resources)) {
392
- if (locator.resources?.[name] !== expectedPath) return false;
393
- }
394
- const requiredPaths = runtimeRequiredPaths(locator);
395
- if (requiredPaths.length === 0) return false;
396
- return (await Promise.all(requiredPaths.map(exists))).every(Boolean);
397
- } catch {
398
- return false;
399
- }
1104
+ const expected = createRuntimeLocator(versionedPluginRoot);
1105
+ if (locator?.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || resolve(locator.pluginRoot ?? ".") !== resolve(versionedPluginRoot)) return false;
1106
+ for (const [name, value] of Object.entries(expected.entrypoints)) if (locator.entrypoints?.[name] !== value) return false;
1107
+ for (const [name, value] of Object.entries(expected.resources)) if (locator.resources?.[name] !== value) return false;
1108
+ return (await Promise.all(runtimeRequiredPaths(locator).map(exists))).every(Boolean);
1109
+ } catch { return false; }
400
1110
  }
401
1111
 
402
- async function renderMirrorFile(source, destination, locatorPath, { includeLocatorNotice = false } = {}) {
1112
+ async function renderMirrorFile(source, destination, locatorPath, { includeLocatorNotice = false } = {}, runtimeRoot = versionedPluginRoot) {
403
1113
  const content = await readFile(source, "utf8");
404
- const notice = includeLocatorNotice
405
- ? `\n\n## Installed Cairn runtime\n\nRead the structured runtime locator at \`${JSON.stringify(locatorPath)}\`. Resolve Cairn scripts and static resources from its absolute paths, never from the target project.\n`
406
- : "";
1114
+ const notice = includeLocatorNotice ? `\n\n## Installed Cairn runtime\n\nRead the structured runtime locator at \`${JSON.stringify(locatorPath)}\`. Resolve Cairn scripts and static resources from its absolute paths, never from the target project.\n` : "";
407
1115
  await mkdir(dirname(destination), { recursive: true });
408
- await writeFile(destination, `${renderInstalledMirror(content, { runtimeRoot: installedPluginRoot, locatorPath }).trimEnd()}${notice}`);
1116
+ await writeFile(destination, `${renderInstalledMirror(content, { runtimeRoot, locatorPath }).trimEnd()}${notice}`);
409
1117
  }
410
1118
 
411
- function help() {
412
- console.log(t("usage"));
1119
+ function hostJson(args, extraEnv = {}) {
1120
+ const result = spawnSync("codex", args, { encoding: "utf8", env: { ...process.env, ...extraEnv } });
1121
+ if (result.error || result.status !== 0) return null;
1122
+ try { return JSON.parse(result.stdout); } catch { return null; }
413
1123
  }
414
1124
 
415
- function t(key) {
416
- const messages = {
417
- en: {
418
- installComplete: "Cairn install complete",
419
- upgradeComplete: "Cairn upgrade complete",
420
- uninstallComplete: "Cairn uninstall complete",
421
- usage: "Usage: cairn install|upgrade|doctor|uninstall|toolcheck",
422
- },
423
- ko: {
424
- installComplete: "Cairn 설치 완료",
425
- upgradeComplete: "Cairn 업그레이드 완료",
426
- uninstallComplete: "Cairn 언인스톨 완료",
427
- usage: "사용법: cairn install|upgrade|doctor|uninstall|toolcheck",
428
- },
429
- ja: {
430
- installComplete: "Cairn のインストールが完了しました",
431
- upgradeComplete: "Cairn のアップグレードが完了しました",
432
- uninstallComplete: "Cairn のアンインストールが完了しました",
433
- usage: "使い方: cairn install|upgrade|doctor|uninstall|toolcheck",
434
- },
435
- zh: {
436
- installComplete: "Cairn 安装完成",
437
- upgradeComplete: "Cairn 升级完成",
438
- uninstallComplete: "Cairn 卸载完成",
439
- usage: "用法: cairn install|upgrade|doctor|uninstall|toolcheck",
440
- },
441
- es: {
442
- installComplete: "Instalación de Cairn completada",
443
- upgradeComplete: "Actualización de Cairn completada",
444
- uninstallComplete: "Desinstalación de Cairn completada",
445
- usage: "Uso: cairn install|upgrade|doctor|uninstall|toolcheck",
446
- },
447
- fr: {
448
- installComplete: "Installation de Cairn terminée",
449
- upgradeComplete: "Mise à niveau de Cairn terminée",
450
- uninstallComplete: "Désinstallation de Cairn terminée",
451
- usage: "Utilisation: cairn install|upgrade|doctor|uninstall|toolcheck",
452
- },
453
- de: {
454
- installComplete: "Cairn-Installation abgeschlossen",
455
- upgradeComplete: "Cairn-Upgrade abgeschlossen",
456
- uninstallComplete: "Cairn-Deinstallation abgeschlossen",
457
- usage: "Verwendung: cairn install|upgrade|doctor|uninstall|toolcheck",
458
- },
459
- pt: {
460
- installComplete: "Instalação do Cairn concluída",
461
- upgradeComplete: "Atualização do Cairn concluída",
462
- uninstallComplete: "Desinstalação do Cairn concluída",
463
- usage: "Uso: cairn install|upgrade|doctor|uninstall|toolcheck",
464
- },
465
- };
466
- return messages[localeFamily()]?.[key] ?? messages.en[key];
1125
+ export function findPluginStatus(value, id) {
1126
+ const entries = Array.isArray(value) ? value : value?.installed ?? value?.plugins ?? value?.data ?? [];
1127
+ return entries.find((entry) => entry.pluginId === id || entry.id === id || entry.name === id || `${entry.name}@${entry.marketplaceName ?? entry.marketplace}` === id) ?? null;
467
1128
  }
468
1129
 
469
- export function localeFamily() {
470
- const locale = [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG]
471
- .find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale;
472
- const normalized = locale.toLowerCase();
473
- for (const family of ["ko", "ja", "zh", "es", "fr", "de", "pt"]) {
474
- if (normalized.startsWith(family)) return family;
1130
+ export function parseCodexFeatureList(output) {
1131
+ if (typeof output !== "string" || output.trim().length === 0) return null;
1132
+ const features = {};
1133
+ for (const line of output.split(/\r?\n/)) {
1134
+ const match = line.trim().match(/^([A-Za-z0-9_]+)\s+.+?\s+(true|false)$/);
1135
+ if (match) features[match[1]] = match[2] === "true";
475
1136
  }
476
- return "en";
1137
+ return Object.keys(features).length > 0 ? features : null;
477
1138
  }
478
1139
 
479
- function isCliEntry() {
480
- if (!process.argv[1]) return false;
1140
+ async function validateAntigravitySkills() {
481
1141
  try {
482
- return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
1142
+ if (!(await validRuntimeLocator(antigravityRuntimeLocatorPath(antigravityCliHome)))) return false;
1143
+ for (const name of skillNames()) {
1144
+ const content = await readFile(join(antigravityCliHome, "skills", `${name}.md`), "utf8");
1145
+ if (!/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(content) || !/^name:\s*\S+/m.test(content) || !/^description:\s*\S+/m.test(content)) return false;
1146
+ if (!content.includes(JSON.stringify(antigravityRuntimeLocatorPath(antigravityCliHome)))) return false;
1147
+ }
1148
+ return true;
483
1149
  } catch {
484
- return import.meta.url === pathToFileURL(process.argv[1]).href;
1150
+ return false;
485
1151
  }
486
1152
  }
1153
+
1154
+ async function readJson(path) { if (!(await exists(path))) return null; return JSON.parse(await readFile(path, "utf8")); }
1155
+ async function readText(path) { if (!(await exists(path))) return ""; return readFile(path, "utf8"); }
1156
+ async function exists(path) { try { await lstat(path); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } }
1157
+ function help() { console.log(t("usage")); }
1158
+ function t(key) {
1159
+ const messages = { en: { installComplete: "Cairn install complete", upgradeComplete: "Cairn upgrade complete", uninstallComplete: "Cairn uninstall complete", usage: "Usage: cairn install|upgrade|doctor|uninstall|toolcheck" }, ko: { installComplete: "Cairn 설치 완료", upgradeComplete: "Cairn 업그레이드 완료", uninstallComplete: "Cairn 언인스톨 완료", usage: "사용법: cairn install|upgrade|doctor|uninstall|toolcheck" } };
1160
+ return messages[localeFamily()]?.[key] ?? messages.en[key];
1161
+ }
1162
+ export function localeFamily() { const locale = [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG].find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale; return locale.toLowerCase().startsWith("ko") ? "ko" : "en"; }
1163
+ function isCliEntry() { if (!process.argv[1]) return false; try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return import.meta.url === pathToFileURL(process.argv[1]).href; } }