@catalyst-cloud/cli 0.16.33 → 0.16.35

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,64 +1,131 @@
1
- // update-plugin.ts — the Claude plugin form of the Cloud skills pack moves with the release check
2
- // (CTC-6791). Claude Code installs the plugin from its own marketplace checkout of this repository,
3
- // which serves the repository's head, so the release check moves it only when that checkout carries
4
- // exactly the approved version: it refreshes the marketplace, reads the plugin version the checkout
5
- // now serves, and asks Claude Code to update the plugin only when that version is the approved one.
6
- // Claude Code has no way to install a plugin at a pinned version, so a marketplace ahead of the
7
- // approval leaves the plugin where it is. The 15-minute schedule finds `claude` at the absolute path
8
- // recorded when it was set up, then on PATH, then in Claude Code's usual install locations; only when
9
- // none has it is nothing changed, and the report names the command.
10
- import { accessSync, constants, readFileSync } from "node:fs";
11
- import { delimiter, join } from "node:path";
1
+ // Select the verified approved Cloud checkout through Claude's supported marketplace commands.
2
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
3
+ import { delimiter, dirname, join, resolve } from "node:path";
12
4
  import { exactVersion } from "./approved-release.js";
13
5
  import { spawnCliCommand } from "./update-cli.js";
6
+ import { contentDigest, record, repositoryFor, skillReleaseDir, stageSkillSource, verifiedSkillSource } from "./update-skill-source.js";
7
+ import { plainDirectory } from "./update-skill-state.js";
8
+ import { withSkillUpdateLock } from "./update-skills.js";
14
9
  export const CLOUD_MARKETPLACE = "catalyst-cloud";
15
10
  export const CLOUD_PLUGIN_ID = `catalyst@${CLOUD_MARKETPLACE}`;
16
- /** The one command that moves the plugin, for an owner whose release check cannot run Claude Code. */
17
- export const CLOUD_PLUGIN_COMMAND = `claude plugin marketplace update ${CLOUD_MARKETPLACE} && claude plugin update ${CLOUD_PLUGIN_ID}`;
18
- export function claudeConfigDir(home, env) {
19
- return env.CLAUDE_CONFIG_DIR || join(home, ".claude");
11
+ export const CLOUD_PLUGIN_COMMAND = "catalyst update";
12
+ export function claudeConfigDir(home, env) { return env.CLAUDE_CONFIG_DIR || join(home, ".claude"); }
13
+ const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
14
+ const cloudGitSource = (source) => {
15
+ if (source.source === "github" && source.repo === repositoryFor("cloud"))
16
+ return `https://github.com/${repositoryFor("cloud")}.git`;
17
+ if (source.source === "git" && (source.url === `https://github.com/${repositoryFor("cloud")}.git` || source.url === `https://github.com/${repositoryFor("cloud")}`))
18
+ return String(source.url);
19
+ return null;
20
+ };
21
+ /** A project or managed declaration must not be redirected by a home-directory updater. */
22
+ function hasOverride(home, env, entries) {
23
+ if (entries.some((entry) => !record(entry) || entry.scope !== "user"))
24
+ return true;
25
+ const config = claudeConfigDir(home, env);
26
+ const files = [join(config, "managed-settings.json"), join(config, "settings.local.json")];
27
+ if (process.platform === "linux")
28
+ files.push("/etc/claude-code/managed-settings.json");
29
+ if (process.platform === "darwin")
30
+ files.push("/Library/Application Support/ClaudeCode/managed-settings.json");
31
+ if (process.platform === "win32" && env.ProgramFiles)
32
+ files.push(join(env.ProgramFiles, "ClaudeCode/managed-settings.json"));
33
+ // Claude records project identity even when another plugin owns that scope.
34
+ const registry = readJson(join(config, "plugins/installed_plugins.json"));
35
+ if (record(registry) && record(registry.plugins))
36
+ for (const values of Object.values(registry.plugins)) {
37
+ if (!Array.isArray(values))
38
+ continue;
39
+ for (const value of values)
40
+ if (record(value) && typeof value.projectPath === "string") {
41
+ files.push(join(value.projectPath, ".claude/settings.json"), join(value.projectPath, ".claude/settings.local.json"));
42
+ }
43
+ }
44
+ let cwd = resolve(env.PWD ?? home);
45
+ while (true) {
46
+ files.push(join(cwd, ".claude/settings.json"), join(cwd, ".claude/settings.local.json"));
47
+ const parent = dirname(cwd);
48
+ if (parent === cwd)
49
+ break;
50
+ cwd = parent;
51
+ }
52
+ for (const path of new Set(files)) {
53
+ if (path === join(config, "settings.json") || !existsSync(path))
54
+ continue;
55
+ const value = readJson(path);
56
+ if (record(value) && record(value.extraKnownMarketplaces) && value.extraKnownMarketplaces[CLOUD_MARKETPLACE] !== undefined)
57
+ return true;
58
+ }
59
+ return false;
20
60
  }
21
- function readJson(path) {
22
- return JSON.parse(readFileSync(path, "utf8"));
61
+ function pluginManifest(dir, version) {
62
+ try {
63
+ const marketplace = readJson(join(dir, ".claude-plugin/marketplace.json"));
64
+ const plugin = readJson(join(dir, ".claude-plugin/plugin.json"));
65
+ if (!record(marketplace) || marketplace.name !== CLOUD_MARKETPLACE || !Array.isArray(marketplace.plugins))
66
+ return false;
67
+ const entries = marketplace.plugins.filter((entry) => record(entry) && entry.name === "catalyst");
68
+ if (entries.length !== 1 || !record(entries[0]) || entries[0].source !== "./")
69
+ return false;
70
+ if (!record(plugin) || plugin.name !== "catalyst" || plugin.version !== version)
71
+ return false;
72
+ const paths = typeof plugin.skills === "string" ? [plugin.skills] : plugin.skills;
73
+ return Array.isArray(paths) && paths.length > 0 && paths.every((path) => typeof path === "string" && /^\.\/skills\/(?:[a-zA-Z0-9_-]+\/?)?$/.test(path) && plainDirectory(join(dir, path)));
74
+ }
75
+ catch {
76
+ return false;
77
+ }
23
78
  }
24
- const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
25
- /** The Cloud plugin as Claude Code records it: its user-scope install when there is one, else the first. */
26
79
  export function cloudPluginInventory(home, env) {
80
+ const config = claudeConfigDir(home, env);
27
81
  let registry;
28
82
  try {
29
- registry = readJson(join(claudeConfigDir(home, env), "plugins", "installed_plugins.json"));
83
+ registry = readJson(join(config, "plugins/installed_plugins.json"));
30
84
  }
31
- catch (err) {
32
- return err.code === "ENOENT" ? { state: "not_installed" } : { state: "unknown", reason: "plugin_registry_unreadable" };
85
+ catch (error) {
86
+ return error instanceof Error && "code" in error && error.code === "ENOENT" ? { state: "not_installed" } : { state: "unknown", reason: "plugin_registry_unreadable" };
33
87
  }
34
- const entries = isRecord(registry) && isRecord(registry.plugins) ? registry.plugins[CLOUD_PLUGIN_ID] : undefined;
88
+ const entries = record(registry) && record(registry.plugins) ? registry.plugins[CLOUD_PLUGIN_ID] : undefined;
35
89
  if (!Array.isArray(entries) || entries.length === 0)
36
90
  return { state: "not_installed" };
37
- const entry = entries.find((e) => isRecord(e) && e.scope === "user") ?? entries[0];
38
- const version = isRecord(entry) ? exactVersion(entry.version) : null;
39
- if (version === null)
40
- return { state: "unknown", reason: "version_unreadable" };
41
- return { state: "installed", version, scope: isRecord(entry) && typeof entry.scope === "string" ? entry.scope : "user", claude: resolveClaude(home, env) };
42
- }
43
- /** The plugin version the marketplace checkout serves now, or null when it cannot be read. */
44
- function servedPluginVersion(home, env) {
45
- const plugins = join(claudeConfigDir(home, env), "plugins");
46
- let dir = join(plugins, "marketplaces", CLOUD_MARKETPLACE);
47
- try {
48
- const known = readJson(join(plugins, "known_marketplaces.json"));
49
- const entry = isRecord(known) ? known[CLOUD_MARKETPLACE] : undefined;
50
- if (isRecord(entry) && typeof entry.installLocation === "string")
51
- dir = entry.installLocation;
52
- }
53
- catch {
54
- // No record of where it lives: Claude Code's default checkout directory.
55
- }
56
91
  try {
57
- const manifest = readJson(join(dir, ".claude-plugin", "plugin.json"));
58
- return isRecord(manifest) ? exactVersion(manifest.version) : null;
92
+ if (hasOverride(home, env, entries))
93
+ return { state: "unknown", reason: "plugin_scope_unsupported" };
94
+ const entry = entries[0];
95
+ const version = record(entry) ? exactVersion(entry.version) : null;
96
+ if (version === null)
97
+ return { state: "unknown", reason: "version_unreadable" };
98
+ const known = readJson(join(config, "plugins/known_marketplaces.json"));
99
+ const marketplace = record(known) ? known[CLOUD_MARKETPLACE] : null;
100
+ const settings = readJson(join(config, "settings.json"));
101
+ const declared = record(settings) && record(settings.extraKnownMarketplaces) ? settings.extraKnownMarketplaces[CLOUD_MARKETPLACE] : null;
102
+ if (!record(marketplace) || !record(marketplace.source) || typeof marketplace.installLocation !== "string" || !record(declared) || !record(declared.source) || marketplace.scope !== undefined && marketplace.scope !== "user")
103
+ return { state: "unknown", reason: "installation_owner_unknown" };
104
+ const source = marketplace.source;
105
+ const dir = marketplace.installLocation;
106
+ if (!plainDirectory(dir))
107
+ return { state: "unknown", reason: "installation_owner_unknown" };
108
+ const git = cloudGitSource(source);
109
+ if (git) {
110
+ if (cloudGitSource(declared.source) !== git || source.ref !== declared.source.ref || source.ref !== undefined && (typeof source.ref !== "string" || source.ref.length === 0 || /[#\r\n]/.test(source.ref)))
111
+ return { state: "unknown", reason: "source_mismatch" };
112
+ return { state: "installed", version, scope: "user", claude: resolveClaude(home, env), commit: null, source: { kind: "git", argument: `${source.source === "github" ? repositoryFor("cloud") : git.replace(/\.git$/, "") + ".git"}${typeof source.ref === "string" ? `#${source.ref}` : ""}`, gitUrl: git, dir, commit: null } };
113
+ }
114
+ if (source.source !== "directory" || source.path !== dir || declared.source.source !== "directory" || declared.source.path !== dir)
115
+ return { state: "unknown", reason: "source_mismatch" };
116
+ const manifest = readJson(join(dir, "package.json"));
117
+ const commit = dir.slice(dir.lastIndexOf("/") + 1);
118
+ if (dir !== skillReleaseDir(home, "cloud", commit) || !/^[a-f0-9]{40}$/.test(commit) || !record(manifest) || exactVersion(manifest.version) === null)
119
+ return { state: "unknown", reason: "installation_owner_unknown" };
120
+ const target = { repository: repositoryFor("cloud"), version: String(manifest.version), commit };
121
+ if (!verifiedSkillSource(home, "cloud", target))
122
+ return { state: "unknown", reason: "local_changes" };
123
+ if (!pluginManifest(dir, version))
124
+ return { state: "unknown", reason: "source_mismatch" };
125
+ return { state: "installed", version, scope: "user", claude: resolveClaude(home, env), commit, source: { kind: "directory", argument: dir, dir, commit } };
59
126
  }
60
127
  catch {
61
- return null;
128
+ return { state: "unknown", reason: "installation_owner_unknown" };
62
129
  }
63
130
  }
64
131
  /** The schedule's record of the `claude` command found when it was set up. */
@@ -80,20 +147,76 @@ export function resolveClaude(home, env) {
80
147
  const usual = [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
81
148
  return [...(recorded?.startsWith("/") ? [recorded] : []), ...onPath, ...usual].find(executable) ?? null;
82
149
  }
83
- /** Move the Cloud plugin to exact version `target` through Claude Code, when its marketplace serves it. */
84
- export function applyCloudPluginUpdate(home, env, target, scope, deps = {}) {
150
+ export function applyCloudPluginUpdate(home, env, target, pluginVersion, scope, deps = {}) {
151
+ const failed = (detail) => ({ outcome: "failed", detail, version: null, commit: null });
152
+ if (scope !== "user")
153
+ return failed("plugin_scope_unsupported");
85
154
  const claude = deps.claudePath === undefined ? resolveClaude(home, env) : deps.claudePath;
86
155
  if (claude === null)
87
156
  return { outcome: "no_claude", detail: "claude_not_found", version: null };
88
- const exec = deps.exec ?? spawnCliCommand;
89
- if (exec([claude, "plugin", "marketplace", "update", CLOUD_MARKETPLACE], home, env).status !== 0)
90
- return { outcome: "failed", detail: "marketplace_update_failed", version: null };
91
- if (servedPluginVersion(home, env) !== target)
92
- return { outcome: "waiting", detail: "marketplace_not_approved", version: null };
93
- const updated = exec([claude, "plugin", "update", CLOUD_PLUGIN_ID, "--scope", scope], home, env).status === 0;
94
- const now = cloudPluginInventory(home, env);
95
- const version = now.state === "installed" ? now.version : null;
96
- if (!updated)
97
- return { outcome: "failed", detail: "update_failed", version };
98
- return version === target ? { outcome: "updated", detail: "updated", version } : { outcome: "failed", detail: "verify_failed", version };
157
+ return withSkillUpdateLock(home, failed("update_in_progress"), () => {
158
+ const before = cloudPluginInventory(home, env);
159
+ if (before.state !== "installed")
160
+ return failed(before.state === "unknown" ? before.reason : "not_installed");
161
+ const exec = deps.exec ?? spawnCliCommand;
162
+ const childEnv = { ...env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir(home, env), PWD: home };
163
+ if (before.source.kind === "git") {
164
+ // Only an unmodified, correctly sourced Git checkout can be adopted.
165
+ const origin = spawnCliCommand(["git", "-c", "core.hooksPath=/dev/null", "remote", "get-url", "origin"], before.source.dir, childEnv);
166
+ const clean = spawnCliCommand(["git", "-c", "core.hooksPath=/dev/null", "status", "--porcelain", "--untracked-files=all", "--ignored"], before.source.dir, childEnv);
167
+ if (origin.status !== 0 || origin.stdout.trim().replace(/\.git$/, "") !== before.source.gitUrl?.replace(/\.git$/, ""))
168
+ return failed("source_mismatch");
169
+ if (clean.status !== 0 || clean.stdout.trim() !== "")
170
+ return failed("local_changes");
171
+ }
172
+ let beforeDigest;
173
+ try {
174
+ beforeDigest = contentDigest(before.source.dir, true);
175
+ }
176
+ catch {
177
+ return failed("local_changes");
178
+ }
179
+ const source = stageSkillSource(home, "cloud", target, { ...deps, env });
180
+ if (!source.ok)
181
+ return failed(source.detail);
182
+ if (!pluginManifest(source.dir, pluginVersion))
183
+ return failed("source_mismatch");
184
+ const unchanged = cloudPluginInventory(home, env);
185
+ if (unchanged.state !== "installed" || unchanged.source.dir !== before.source.dir || unchanged.version !== before.version || contentDigest(before.source.dir, true) !== beforeDigest)
186
+ return failed("source_mismatch");
187
+ const select = (argument) => exec([claude, "plugin", "marketplace", "add", argument, "--scope", "user"], home, childEnv).status === 0;
188
+ const update = () => exec([claude, "plugin", "update", CLOUD_PLUGIN_ID, "--scope", "user"], home, childEnv).status === 0;
189
+ let failure;
190
+ try {
191
+ if (!select(source.dir))
192
+ failure = "marketplace_update_failed";
193
+ else if (!update())
194
+ failure = "update_failed";
195
+ else {
196
+ const after = cloudPluginInventory(home, env);
197
+ if (after.state === "installed" && after.source.dir === source.dir && after.commit === target.commit && after.version === pluginVersion && contentDigest(source.dir, true) === source.digest)
198
+ return { outcome: "updated", detail: "approved_commit_installed", version: after.version, commit: after.commit };
199
+ failure = "verify_failed";
200
+ }
201
+ }
202
+ catch {
203
+ failure = "update_failed";
204
+ }
205
+ // Never remove the marketplace or hand-edit Claude's registries. Restore through the same API.
206
+ try {
207
+ const restored = select(before.source.argument);
208
+ let now = cloudPluginInventory(home, env);
209
+ if (restored && (now.state !== "installed" || now.version !== before.version)) {
210
+ if (!update())
211
+ return failed("rollback_failed");
212
+ now = cloudPluginInventory(home, env);
213
+ }
214
+ if (!restored || now.state !== "installed" || now.source.dir !== before.source.dir || now.version !== before.version || now.source.argument !== before.source.argument || contentDigest(now.source.dir, true) !== beforeDigest)
215
+ return failed("rollback_failed");
216
+ }
217
+ catch {
218
+ return failed("rollback_failed");
219
+ }
220
+ return failed(failure);
221
+ });
99
222
  }
@@ -0,0 +1,145 @@
1
+ // Exact approved Git checkouts. No lifecycle scripts or floating branches are executed.
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { exactVersion, SKILL_PACK_REPOSITORIES } from "./approved-release.js";
7
+ import { configDirFor } from "./config.js";
8
+ import { spawnCliCommand } from "./update-cli.js";
9
+ export const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ export const safeName = (value) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value);
11
+ export const repositoryFor = (pack) => SKILL_PACK_REPOSITORIES[pack];
12
+ export const skillReleaseDir = (home, pack, commit) => join(configDirFor(home), "skill-releases", pack, commit);
13
+ /** Hash all bytes, names and executable modes, including ignored and untracked files.
14
+ * Hash checkout metadata links without following them. Skill trees and manifests
15
+ * still refuse symlinks, and special files are refused everywhere. */
16
+ export function contentDigest(dir, checkout = false) {
17
+ const hash = createHash("sha256");
18
+ const visit = (path, relative) => {
19
+ const stat = lstatSync(path);
20
+ if (stat.isSymbolicLink()) {
21
+ if (!checkout || relative === "" || ["skills", "package.json", ".claude-plugin"].some((root) => relative === root || relative.startsWith(`${root}/`)))
22
+ throw new Error("unsafe_path");
23
+ const target = readlinkSync(path);
24
+ const resolved = resolve(dirname(path), target);
25
+ if (isAbsolute(target) || resolved !== resolve(dir) && !resolved.startsWith(`${resolve(dir)}${sep}`))
26
+ throw new Error("unsafe_path");
27
+ hash.update(`symlink\0${relative}\0${target}\0`);
28
+ return;
29
+ }
30
+ if (stat.isDirectory()) {
31
+ hash.update(`directory\0${relative}\0`);
32
+ for (const name of readdirSync(path).sort()) {
33
+ if (checkout && relative === "" && name === ".git")
34
+ continue;
35
+ if (name === ".git")
36
+ throw new Error("unsafe_path");
37
+ visit(join(path, name), relative ? `${relative}/${name}` : name);
38
+ }
39
+ }
40
+ else if (stat.isFile()) {
41
+ const bytes = readFileSync(path);
42
+ hash.update(`file\0${relative}\0${stat.mode & 0o111 ? "executable" : "regular"}\0${bytes.length}\0`);
43
+ hash.update(bytes);
44
+ }
45
+ else
46
+ throw new Error("unsafe_path");
47
+ };
48
+ visit(dir, "");
49
+ return hash.digest("hex");
50
+ }
51
+ function gitEnv(env) {
52
+ const clean = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("GIT_")));
53
+ return { ...clean, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
54
+ }
55
+ /** Git's tree identity for global skills-lock v3, with a private index and forced ignored files. */
56
+ export function skillTreeHash(dir, env = process.env) {
57
+ contentDigest(dir);
58
+ const temporary = mkdtempSync(join(tmpdir(), "catalyst-skill-index-"));
59
+ const run = (args) => {
60
+ const result = spawnCliCommand(["git", "-c", "core.hooksPath=/dev/null", ...args], temporary, gitEnv(env));
61
+ if (result.status !== 0)
62
+ throw new Error("tree_hash_failed");
63
+ return result.stdout.trim();
64
+ };
65
+ try {
66
+ run(["init", "--bare", "--quiet"]);
67
+ const prefix = [`--git-dir=${temporary}`, `--work-tree=${dir}`, "-c", "core.fileMode=true", "-c", "core.autocrlf=false"];
68
+ run([...prefix, "add", "--force", "--all", "--", "."]);
69
+ return run([...prefix, "write-tree"]);
70
+ }
71
+ finally {
72
+ rmSync(temporary, { recursive: true, force: true });
73
+ }
74
+ }
75
+ function verifyManifest(dir, target) {
76
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
77
+ if (!record(manifest) || manifest.version !== target.version)
78
+ throw new Error("source_mismatch");
79
+ if (!readdirSync(join(dir, "skills")).some((name) => safeName(name) && existsSync(join(dir, "skills", name, "SKILL.md"))))
80
+ throw new Error("source_mismatch");
81
+ for (const name of readdirSync(join(dir, "skills")))
82
+ if (!safeName(name))
83
+ throw new Error("unsafe_path");
84
+ const plugin = join(dir, ".claude-plugin", "plugin.json");
85
+ if (existsSync(plugin)) {
86
+ const value = JSON.parse(readFileSync(plugin, "utf8"));
87
+ if (!record(value) || value.version !== target.version)
88
+ throw new Error("source_mismatch");
89
+ }
90
+ }
91
+ /** Read-only cache verification also used by plugin inventory. */
92
+ export function verifiedSkillSource(home, pack, target) {
93
+ try {
94
+ if (target.repository !== repositoryFor(pack) || !/^[a-f0-9]{40}$/.test(target.commit) || exactVersion(target.version) === null)
95
+ return null;
96
+ const dir = skillReleaseDir(home, pack, target.commit);
97
+ const saved = JSON.parse(readFileSync(`${dir}.json`, "utf8"));
98
+ if (!record(saved) || saved.commit !== target.commit || saved.version !== target.version || saved.repository !== target.repository)
99
+ return null;
100
+ verifyManifest(dir, target);
101
+ const digest = contentDigest(dir, true);
102
+ return digest === saved.digest ? { dir, digest } : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ export function stageSkillSource(home, pack, target, deps = {}) {
109
+ if (target.repository !== repositoryFor(pack) || !/^[a-f0-9]{40}$/.test(target.commit) || exactVersion(target.version) === null)
110
+ return { ok: false, detail: "source_mismatch" };
111
+ const final = skillReleaseDir(home, pack, target.commit);
112
+ if (existsSync(final)) {
113
+ const verified = verifiedSkillSource(home, pack, target);
114
+ return verified ? { ok: true, ...verified } : { ok: false, detail: "local_changes" };
115
+ }
116
+ const parent = join(configDirFor(home), "skill-releases", pack);
117
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
118
+ const staging = mkdtempSync(join(parent, ".staging-"));
119
+ const exec = deps.exec ?? spawnCliCommand;
120
+ const env = gitEnv(deps.env ?? process.env);
121
+ const run = (...args) => {
122
+ const result = exec(["git", "-c", "core.hooksPath=/dev/null", ...args], staging, env);
123
+ if (result.status !== 0)
124
+ throw new Error("fetch_failed");
125
+ return result.stdout.trim();
126
+ };
127
+ try {
128
+ run("init", "--quiet");
129
+ run("fetch", "--depth=1", "--no-tags", "--", deps.repositoryUrl?.(target.repository) ?? `https://github.com/${target.repository}.git`, target.commit);
130
+ run("-c", "advice.detachedHead=false", "checkout", "--detach", "FETCH_HEAD");
131
+ if (run("rev-parse", "HEAD") !== target.commit)
132
+ throw new Error("source_mismatch");
133
+ const digest = contentDigest(staging, true);
134
+ verifyManifest(staging, target);
135
+ writeFileSync(`${final}.json`, JSON.stringify({ ...target, digest }), { mode: 0o600 });
136
+ renameSync(staging, final);
137
+ return { ok: true, dir: final, digest };
138
+ }
139
+ catch (error) {
140
+ return { ok: false, detail: error instanceof Error ? error.message : "fetch_failed" };
141
+ }
142
+ finally {
143
+ rmSync(staging, { recursive: true, force: true });
144
+ }
145
+ }
@@ -0,0 +1,175 @@
1
+ // Read-only ownership discovery. A stamp or npx lock is an adoption candidate, not proof.
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { exactVersion } from "./approved-release.js";
5
+ import { configDirFor } from "./config.js";
6
+ import { installedBundleVersion } from "./skills.js";
7
+ import { contentDigest, record, repositoryFor, safeName } from "./update-skill-source.js";
8
+ const receiptsPath = (home) => join(configDirFor(home), "skill-installations.json");
9
+ export const skillLockPath = (home, env) => env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "skills/.skill-lock.json") : join(home, ".agents/.skill-lock.json");
10
+ const canonicalDir = (home) => join(home, ".agents/skills");
11
+ const unknown = (present, reason = "installation_owner_unknown") => ({ present, candidate: false, commit: null, version: null, kind: "unknown", reason, destinations: [] });
12
+ // The global npx lock records the SKILL.md path; receipts use its containing tree.
13
+ const lockSkillFolder = (path) => typeof path === "string" ? path.replace(/\/SKILL\.md$/, "") : null;
14
+ /** Reject linked ancestors too, so a receipt cannot authorize writes through a changed root. */
15
+ export function plainDirectory(path) {
16
+ try {
17
+ return isAbsolute(path) && realpathSync(path) === resolve(path) && lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ export function destinationMatches(destination) {
24
+ try {
25
+ return plainDirectory(destination.path) && contentDigest(destination.path) === destination.digest && destination.links.every((link) => lstatSync(link).isSymbolicLink() && realpathSync(link) === destination.path);
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function parseReceipt(value, home, pack, dir, env) {
32
+ if (!record(value) || value.pack !== pack || value.repository !== repositoryFor(pack) || typeof value.commit !== "string" || !/^[a-f0-9]{40}$/.test(value.commit) || exactVersion(value.version) === null || value.dir !== dir || (value.kind !== "legacy" && value.kind !== "npx") || !Array.isArray(value.destinations))
33
+ return null;
34
+ if (value.kind === "npx" && value.lockPath !== skillLockPath(home, env))
35
+ return null;
36
+ const destinations = [];
37
+ for (const d of value.destinations) {
38
+ if (!record(d) || typeof d.name !== "string" || !safeName(d.name) || d.path !== join(value.kind === "npx" ? canonicalDir(home) : dir, d.name) || typeof d.digest !== "string" || !/^[a-f0-9]{64}$/.test(d.digest) || d.skillPath !== `skills/${d.name}` || !Array.isArray(d.links) || !d.links.every((link) => typeof link === "string" && agentDirs(home, dir).some((root) => link === join(root, String(d.name)))))
39
+ return null;
40
+ if (destinations.some((old) => old.name === d.name))
41
+ return null;
42
+ if (value.kind === "npx" && (typeof d.lockHash !== "string" || !/^[a-f0-9]{40}$/.test(d.lockHash)))
43
+ return null;
44
+ destinations.push({ name: d.name, path: String(d.path), digest: d.digest, skillPath: String(d.skillPath), links: d.links, ...(typeof d.lockHash === "string" ? { lockHash: d.lockHash } : {}) });
45
+ }
46
+ return { pack, repository: repositoryFor(pack), commit: value.commit, version: String(value.version), kind: value.kind, dir, destinations, ...(value.kind === "npx" ? { lockPath: String(value.lockPath) } : {}) };
47
+ }
48
+ function agentDirs(home, selected) {
49
+ return [...new Set([selected, ...[".claude", ".codex", ".cursor", ".opencode", ".config/opencode"].map((agent) => join(home, agent, "skills"))])];
50
+ }
51
+ export function readSkillLock(path) {
52
+ try {
53
+ const raw = JSON.parse(readFileSync(path, "utf8"));
54
+ return record(raw) && raw.version === 3 && record(raw.skills) ? { ...raw, version: 3, skills: raw.skills } : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function inspectSkillInstallation(home, env, pack, dir) {
61
+ const transaction = join(configDirFor(home), ".skill-transaction.json");
62
+ if (existsSync(transaction)) {
63
+ try {
64
+ const pending = JSON.parse(readFileSync(transaction, "utf8"));
65
+ if (record(pending) && pending.pack === pack && pending.dir === dir)
66
+ return { ...unknown(true, "update_interrupted"), candidate: true };
67
+ }
68
+ catch {
69
+ return unknown(true, "recovery_failed");
70
+ }
71
+ }
72
+ const receiptPath = receiptsPath(home);
73
+ if (existsSync(receiptPath)) {
74
+ try {
75
+ const raw = JSON.parse(readFileSync(receiptPath, "utf8"));
76
+ if (!record(raw) || raw.version !== 1 || !record(raw.packs))
77
+ return unknown(true, "source_mismatch");
78
+ if (raw.packs[pack] !== undefined) {
79
+ const receipt = parseReceipt(raw.packs[pack], home, pack, dir, env);
80
+ if (!receipt)
81
+ return unknown(true, "source_mismatch");
82
+ if (receipt.lockPath) {
83
+ const lock = readSkillLock(receipt.lockPath);
84
+ if (!lock || !receipt.destinations.every((destination) => {
85
+ const entry = lock.skills[destination.name];
86
+ return record(entry) && entry.source === receipt.repository && entry.sourceType === "github" && lockSkillFolder(entry.skillPath) === destination.skillPath && entry.ref === receipt.commit && entry.skillFolderHash === destination.lockHash;
87
+ }))
88
+ return { ...unknown(true, "source_mismatch"), receipt, destinations: receipt.destinations, lockPath: receipt.lockPath };
89
+ }
90
+ const valid = receipt.destinations.every(destinationMatches);
91
+ // An empty receipt records removal of the last owned, retired name.
92
+ return { present: receipt.destinations.length > 0, candidate: valid, commit: valid ? receipt.commit : null, version: receipt.version, kind: receipt.kind, reason: valid ? "verified" : "local_changes", destinations: receipt.destinations, receipt, lockPath: receipt.lockPath };
93
+ }
94
+ }
95
+ catch {
96
+ return unknown(true, "source_mismatch");
97
+ }
98
+ }
99
+ const lockPath = skillLockPath(home, env);
100
+ const lock = readSkillLock(lockPath);
101
+ const owned = Object.entries(lock?.skills ?? {}).filter(([, entry]) => record(entry) && entry.source === repositoryFor(pack));
102
+ if (owned.length > 0) {
103
+ const destinations = [];
104
+ for (const [name, entry] of owned) {
105
+ if (!safeName(name) || !record(entry) || entry.sourceType !== "github" || lockSkillFolder(entry.skillPath) !== `skills/${name}` || typeof entry.skillFolderHash !== "string" || !/^[a-f0-9]{40}$/.test(entry.skillFolderHash))
106
+ return unknown(true);
107
+ const path = join(canonicalDir(home), name);
108
+ if (!existsSync(path))
109
+ continue;
110
+ if (!plainDirectory(path))
111
+ return unknown(true);
112
+ try {
113
+ const links = agentDirs(home, dir).map((root) => join(root, name)).filter((link) => {
114
+ try {
115
+ return lstatSync(link).isSymbolicLink() && realpathSync(link) === path;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ });
121
+ destinations.push({ name, path, links, digest: contentDigest(path), skillPath: `skills/${name}`, lockHash: entry.skillFolderHash });
122
+ }
123
+ catch {
124
+ return unknown(true, "local_changes");
125
+ }
126
+ }
127
+ return { present: destinations.length > 0, candidate: true, commit: null, version: null, kind: "npx", reason: "commit_unrecorded", destinations, lockPath };
128
+ }
129
+ let names = [];
130
+ try {
131
+ names = readdirSync(dir).filter(safeName);
132
+ }
133
+ catch {
134
+ return unknown(false);
135
+ }
136
+ const legacy = [];
137
+ let version = null;
138
+ if (pack === "cloud") {
139
+ for (const name of names) {
140
+ const found = installedBundleVersion(dir, [name]);
141
+ if (found.version === null && found.unstamped.length === 0)
142
+ continue;
143
+ // Contradictory global evidence must not fall back to a provenance stamp.
144
+ if (lock?.skills[name] !== undefined || !plainDirectory(join(dir, name)) || found.version === null)
145
+ return unknown(true);
146
+ if (version !== null && version !== found.version)
147
+ return unknown(true);
148
+ version = found.version;
149
+ try {
150
+ legacy.push({ name, path: join(dir, name), links: [], digest: contentDigest(join(dir, name)), skillPath: `skills/${name}` });
151
+ }
152
+ catch {
153
+ return unknown(true, "local_changes");
154
+ }
155
+ }
156
+ }
157
+ if (legacy.length > 0)
158
+ return { present: true, candidate: true, commit: null, version, kind: "legacy", reason: "commit_unrecorded", destinations: legacy };
159
+ return unknown(pack === "dev" && existsSync(join(dir, "research-codebase/SKILL.md")));
160
+ }
161
+ /** Caller holds the installation lock. Publish last, preserving the other pack's receipt. */
162
+ export function writeSkillReceipt(home, receipt) {
163
+ const path = receiptsPath(home);
164
+ let packs = {};
165
+ if (existsSync(path)) {
166
+ const raw = JSON.parse(readFileSync(path, "utf8"));
167
+ if (!record(raw) || raw.version !== 1 || !record(raw.packs))
168
+ throw new Error("receipt_invalid");
169
+ packs = raw.packs;
170
+ }
171
+ mkdirSync(configDirFor(home), { recursive: true, mode: 0o700 });
172
+ const temporary = `${path}.${process.pid}.tmp`;
173
+ writeFileSync(temporary, JSON.stringify({ version: 1, packs: { ...packs, [receipt.pack]: receipt } }), { mode: 0o600 });
174
+ renameSync(temporary, path);
175
+ }