@iseoane/agent-depot 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.es.md +969 -0
  3. package/README.md +244 -23
  4. package/dist/src/app-flow.js +52 -51
  5. package/dist/src/app-recipe-removal.js +63 -0
  6. package/dist/src/cli.js +122 -11
  7. package/dist/src/executable-resolution.js +54 -0
  8. package/dist/src/git-source.js +129 -6
  9. package/dist/src/package-bundles.js +614 -0
  10. package/dist/src/package-cli.js +412 -0
  11. package/dist/src/package-content.js +158 -0
  12. package/dist/src/package-flow.js +636 -0
  13. package/dist/src/package-host-plans.js +240 -0
  14. package/dist/src/package-host-state.js +349 -0
  15. package/dist/src/package-model.js +33 -0
  16. package/dist/src/package-owned-skills.js +122 -0
  17. package/dist/src/package-state.js +404 -0
  18. package/dist/src/profile-cli.js +15 -9
  19. package/dist/src/profile-export.js +44 -9
  20. package/dist/src/profile-import.js +80 -17
  21. package/dist/src/profile.js +78 -3
  22. package/dist/src/skill-discovery.js +2 -2
  23. package/dist/src/skill-install.js +16 -0
  24. package/dist/src/sources.js +11 -0
  25. package/dist/src/tui/app-actions.js +1 -1
  26. package/dist/src/tui/app.js +5 -6
  27. package/dist/src/tui/catalog-browse.js +10 -3
  28. package/dist/src/tui/catalog-panel.js +7 -3
  29. package/dist/src/tui/catalog-state.js +57 -6
  30. package/dist/src/tui/catalog-tree.js +52 -10
  31. package/dist/src/tui/catalog-view.js +27 -4
  32. package/dist/src/tui/environment.js +8 -1
  33. package/dist/src/tui/installations-browse.js +20 -2
  34. package/dist/src/tui/installations-rows.js +2 -1
  35. package/dist/src/tui/installations-tree.js +14 -1
  36. package/dist/src/tui/installations-view.js +8 -2
  37. package/dist/src/tui/installations.js +12 -2
  38. package/dist/src/tui/manage-actions.js +2 -0
  39. package/dist/src/tui/manage-panel.js +5 -0
  40. package/dist/src/tui/package-actions.js +165 -0
  41. package/dist/src/tui/package-rows.js +87 -0
  42. package/dist/src/tui/profile-view.js +36 -14
  43. package/dist/src/tui/sources-mode.js +2 -0
  44. package/dist/src/tui/sources-recipes.js +116 -53
  45. package/dist/src/tui/sources-view.js +57 -19
  46. package/dist/src/tui/tree.js +3 -4
  47. package/dist/src/tui/updates-flow.js +4 -1
  48. package/dist/src/tui/updates-panel.js +2 -2
  49. package/dist/src/tui/updates.js +72 -4
  50. package/dist/src/user-global-skill-inventory.js +7 -2
  51. package/package.json +2 -1
  52. package/skills/agent-depot-apprecipe/SKILL.md +29 -5
@@ -0,0 +1,240 @@
1
+ import { createHash } from "node:crypto";
2
+ import { compareAppVersions, sameAppVersion } from "./app-version.js";
3
+ import { claudeMarketplaceNamesForSource, piSourceIdentity, } from "./package-host-state.js";
4
+ import { parsePortableInstallationMethod } from "./project-manifest.js";
5
+ /** A plan that cannot be derived safely fails closed with its reason. */
6
+ export class PackagePlanError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "PackagePlanError";
10
+ }
11
+ }
12
+ const NO_STEPS = Object.freeze([]);
13
+ /**
14
+ * The one place that knows each host CLI's grammar, verified against the
15
+ * installed CLIs in `.scratch/packages/arena/host-cli-verbs.md`. V1 uses the
16
+ * scoped verb for every action. Select and forget run nothing.
17
+ */
18
+ export const HOST_STEP_TEMPLATES = Object.freeze({
19
+ pi: Object.freeze({
20
+ install: (input) => Object.freeze([
21
+ Object.freeze(["pi", "install", input.installSpec]),
22
+ ]),
23
+ update: (input) => Object.freeze([
24
+ Object.freeze(["pi", "update", input.source]),
25
+ ]),
26
+ uninstall: (input) => Object.freeze([
27
+ Object.freeze(["pi", "remove", input.source]),
28
+ ]),
29
+ select: () => NO_STEPS,
30
+ forget: () => NO_STEPS,
31
+ }),
32
+ claude: Object.freeze({
33
+ install: (input) => Object.freeze([
34
+ ...(input.marketplaceKnown
35
+ ? []
36
+ : [Object.freeze(["claude", "plugin", "marketplace", "add", input.source])]),
37
+ Object.freeze(["claude", "plugin", "install", claudeInstallId(input)]),
38
+ ]),
39
+ update: (input) => Object.freeze([
40
+ Object.freeze(["claude", "plugin", "marketplace", "update", claudeMarketplaceName(input)]),
41
+ Object.freeze(["claude", "plugin", "update", claudeInstallId(input)]),
42
+ ]),
43
+ uninstall: (input) => Object.freeze([
44
+ Object.freeze(["claude", "plugin", "uninstall", claudeInstallId(input)]),
45
+ ]),
46
+ select: () => NO_STEPS,
47
+ forget: () => NO_STEPS,
48
+ }),
49
+ });
50
+ /**
51
+ * The exact host argv for one action. Pure. Every produced argv is re-validated
52
+ * through `parsePortableInstallationMethod`, so the recipe safety rules apply to
53
+ * host commands too.
54
+ *
55
+ * The synthesized design's four arguments cannot carry the Claude marketplace
56
+ * name, which lives in the Source snapshot's `marketplace.json` and never in the
57
+ * host state before the first `marketplace add`. `resolution` supplies it; a
58
+ * Claude plan that must name the marketplace and has no resolution fails closed.
59
+ */
60
+ export function planHostSteps(action, descriptor, selection, state, resolution = {}) {
61
+ assertHostCommand(action, descriptor, selection);
62
+ const input = stepTemplateInput(descriptor, selection, state, resolution);
63
+ const steps = HOST_STEP_TEMPLATES[descriptor.host][action](input);
64
+ return Object.freeze(steps.map((argv, index) => assertValidHostArgv(argv, `${descriptor.host} ${action} step ${index + 1}`)));
65
+ }
66
+ /**
67
+ * The host's own identity for one selection: `<plugin>@<marketplace>` for Claude
68
+ * and the Pi Source identity for Pi. The same value the argv templates carry, so
69
+ * a record can never name an identity the host was not given.
70
+ */
71
+ export function hostInstallId(descriptor, selection, state, resolution = {}) {
72
+ const input = stepTemplateInput(descriptor, selection, state, resolution);
73
+ return descriptor.host === "claude" ? claudeInstallId(input) : input.source;
74
+ }
75
+ function assertHostCommand(action, descriptor, selection) {
76
+ if (descriptor.host !== selection.host) {
77
+ throw new PackagePlanError(`The bundle descriptor host ${JSON.stringify(descriptor.host)} does not match the selection host ${JSON.stringify(selection.host)}`);
78
+ }
79
+ if (!runsHostCommand(action)) {
80
+ return;
81
+ }
82
+ if (!isInstallable(descriptor)) {
83
+ throw new PackagePlanError(`The bundle ${JSON.stringify(descriptor.name)} is not installable (${descriptor.warnings.join("; ")})`);
84
+ }
85
+ if (descriptor.host === "claude" && action !== "uninstall" && selection.version.policy === "fixed") {
86
+ throw new PackagePlanError(`A fixed-version Claude selection cannot be delegated: claude plugin install takes <plugin>@<marketplace> and cannot carry the pin ${JSON.stringify(selection.version.version)}`);
87
+ }
88
+ }
89
+ function stepTemplateInput(descriptor, selection, state, resolution) {
90
+ const source = hostSource(selection);
91
+ const marketplace = descriptor.host === "claude" ? resolveClaudeMarketplace(state, selection) : undefined;
92
+ const marketplaceName = resolution.marketplaceName ?? marketplace?.name;
93
+ return Object.freeze({
94
+ descriptor,
95
+ selection,
96
+ source,
97
+ installSpec: installSpec(source, selection),
98
+ ...(marketplaceName === undefined ? {} : { marketplaceName }),
99
+ marketplaceKnown: marketplace?.known === true,
100
+ });
101
+ }
102
+ /**
103
+ * Every recorded coordinate an action may change. V1 uses the scoped verb for
104
+ * every action, verified in `.scratch/packages/arena/host-cli-verbs.md`, so that
105
+ * is the selection's own coordinates. Flip `HOST_WIDE_UPDATE` for a host when a
106
+ * host-wide verb like `pi update --extensions` is chosen; this function then
107
+ * returns every recorded coordinate of that host.
108
+ */
109
+ export function affectedBy(action, selection, recorded) {
110
+ if (action === "update" && HOST_WIDE_UPDATE[selection.host]) {
111
+ return Object.freeze(recorded
112
+ .filter((candidate) => candidate.host === selection.host)
113
+ .map((candidate) => coordinatesOf(candidate)));
114
+ }
115
+ return Object.freeze([coordinatesOf(selection)]);
116
+ }
117
+ const HOST_WIDE_UPDATE = Object.freeze({
118
+ pi: false,
119
+ claude: false,
120
+ });
121
+ /** sha256 over the canonical serialization of the declaration. Exact argv order included. */
122
+ export function packageApprovalDigest(declaration) {
123
+ return createHash("sha256").update(canonicalJson(declaration), "utf8").digest("hex");
124
+ }
125
+ /**
126
+ * Compares installed evidence with available evidence. Manifest versions compare
127
+ * through `sameAppVersion` and `compareAppVersions`; commits compare by equality.
128
+ * A mixed pair, missing evidence, or an opaque version is `unknown`, never `current`.
129
+ */
130
+ export function classifyUpdate(installed, available) {
131
+ if (installed.kind !== "installed" || available === undefined) {
132
+ return "unknown";
133
+ }
134
+ if (installed.version !== undefined && available.kind === "manifest-version") {
135
+ if (sameAppVersion(installed.version, available.version)) {
136
+ return "current";
137
+ }
138
+ const order = compareAppVersions(installed.version, available.version);
139
+ if (order === undefined) {
140
+ return "unknown";
141
+ }
142
+ return order < 0 ? "update available" : "current";
143
+ }
144
+ if (installed.commit !== undefined && available.kind === "git-commit") {
145
+ return normalizeCommit(installed.commit) === normalizeCommit(available.commit)
146
+ ? "current"
147
+ : "update available";
148
+ }
149
+ return "unknown";
150
+ }
151
+ /** Validates one host argv with the shared portable-command rules. */
152
+ export function assertValidHostArgv(argv, label) {
153
+ try {
154
+ return parsePortableInstallationMethod({ kind: "command", argv }, { label }).argv;
155
+ }
156
+ catch (error) {
157
+ const reason = error instanceof Error ? error.message : "the argv is not a portable no-shell command";
158
+ throw new PackagePlanError(`Refused host command (${label}): ${reason}`);
159
+ }
160
+ }
161
+ function claudeInstallId(input) {
162
+ return `${claudePluginName(input)}@${claudeMarketplaceName(input)}`;
163
+ }
164
+ function claudePluginName(input) {
165
+ return input.selection.host === "claude" ? input.selection.pluginName : input.descriptor.name;
166
+ }
167
+ function claudeMarketplaceName(input) {
168
+ if (input.marketplaceName === undefined || input.marketplaceName === "") {
169
+ throw new PackagePlanError(`Naming the marketplace for ${JSON.stringify(claudePluginName(input))} requires the name declared in the Source's .claude-plugin/marketplace.json`);
170
+ }
171
+ return input.marketplaceName;
172
+ }
173
+ function resolveClaudeMarketplace(state, selection) {
174
+ if (selection.host !== "claude") {
175
+ return undefined;
176
+ }
177
+ const known = claudeMarketplaceNamesForSource(state.claudeMarketplaces, selection.source)[0];
178
+ if (known !== undefined) {
179
+ return { name: known, known: true };
180
+ }
181
+ const installed = state.claudePlugins.records.find((record) => !record.drifted && record.scope === "user" && record.pluginName === selection.pluginName && record.marketplace !== "");
182
+ return installed === undefined ? undefined : { name: installed.marketplace, known: false };
183
+ }
184
+ function isInstallable(descriptor) {
185
+ return descriptor.installable;
186
+ }
187
+ function runsHostCommand(action) {
188
+ return action === "install" || action === "update" || action === "uninstall";
189
+ }
190
+ function hostSource(selection) {
191
+ if (selection.host === "pi") {
192
+ const identity = piSourceIdentity(selection.source);
193
+ if (identity === undefined) {
194
+ throw new PackagePlanError("Pi cannot install a built-in or local path Source");
195
+ }
196
+ return identity;
197
+ }
198
+ if (selection.source.kind === "builtin" || "path" in selection.source) {
199
+ throw new PackagePlanError("Claude Code cannot install a built-in or local path Source");
200
+ }
201
+ return selection.source.url;
202
+ }
203
+ function installSpec(source, selection) {
204
+ return selection.version.policy === "fixed" && selection.version.version !== ""
205
+ ? `${source}@${selection.version.version}`
206
+ : source;
207
+ }
208
+ function normalizeCommit(commit) {
209
+ return commit.trim().toLowerCase();
210
+ }
211
+ function coordinatesOf(selection) {
212
+ return selection.host === "pi"
213
+ ? Object.freeze({ host: selection.host, root: selection.root })
214
+ : Object.freeze({ host: selection.host, marketplaceRoot: selection.marketplaceRoot, pluginName: selection.pluginName });
215
+ }
216
+ function canonicalJson(value) {
217
+ if (value === null) {
218
+ return "null";
219
+ }
220
+ if (typeof value === "string") {
221
+ return JSON.stringify(value);
222
+ }
223
+ if (typeof value === "boolean") {
224
+ return value ? "true" : "false";
225
+ }
226
+ if (typeof value === "number") {
227
+ return Number.isFinite(value) ? String(value) : "null";
228
+ }
229
+ if (Array.isArray(value)) {
230
+ return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
231
+ }
232
+ if (isRecord(value)) {
233
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
234
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
235
+ }
236
+ throw new PackagePlanError("The package declaration contains a value that cannot be canonicalized");
237
+ }
238
+ function isRecord(value) {
239
+ return typeof value === "object" && value !== null && !Array.isArray(value);
240
+ }
@@ -0,0 +1,349 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Reads the `packages` setting, or a bare `packages` array, into records.
6
+ * Nothing here throws: an unknown entry shape becomes a drifted record so the
7
+ * lookup it could affect reports `unknown` instead of a guess.
8
+ */
9
+ export function parsePiPackagesSetting(value) {
10
+ const entries = Array.isArray(value)
11
+ ? value
12
+ : isRecord(value) && Array.isArray(value.packages)
13
+ ? value.packages
14
+ : undefined;
15
+ if (entries === undefined) {
16
+ return Object.freeze([]);
17
+ }
18
+ return Object.freeze(entries.map(parsePiInstallEntry));
19
+ }
20
+ /** Reads `installed_plugins.json` in its v2 (array per plugin) and v1 (one object) shapes. */
21
+ export function parseClaudePluginState(value) {
22
+ if (!isRecord(value) || !isRecord(value.plugins)) {
23
+ return Object.freeze([]);
24
+ }
25
+ const records = [];
26
+ for (const [installId, raw] of Object.entries(value.plugins)) {
27
+ for (const entry of Array.isArray(raw) ? raw : [raw]) {
28
+ records.push(parseClaudeInstallEntry(installId, entry));
29
+ }
30
+ }
31
+ return Object.freeze(records);
32
+ }
33
+ /** Reads `known_marketplaces.json`, whose keys are the marketplace names. */
34
+ export function parseClaudeMarketplaceState(value) {
35
+ if (!isRecord(value)) {
36
+ return Object.freeze([]);
37
+ }
38
+ const records = Object.entries(value).map(([name, raw]) => parseClaudeMarketplaceEntry(name, raw));
39
+ return Object.freeze(records);
40
+ }
41
+ /**
42
+ * Reads the three host state files. A missing file is an empty section; a
43
+ * drifted or unreadable file degrades to an `issue`, and this never throws.
44
+ */
45
+ export async function readHostInstallView(env = {}) {
46
+ const home = env.homeDirectory ?? homedir();
47
+ const reader = env.readHostStateFile ?? ((filePath) => readFile(filePath, "utf8"));
48
+ const [pi, claudePlugins, claudeMarketplaces] = await Promise.all([
49
+ readSection("pi-settings", path.join(home, ".pi", "agent", "settings.json"), reader, parsePiSettingsFile),
50
+ readSection("claude-installed-plugins", path.join(home, ".claude", "plugins", "installed_plugins.json"), reader, parseClaudePluginsFile),
51
+ readSection("claude-known-marketplaces", path.join(home, ".claude", "plugins", "known_marketplaces.json"), reader, parseClaudeMarketplacesFile),
52
+ ]);
53
+ return Object.freeze({ pi, claudePlugins, claudeMarketplaces });
54
+ }
55
+ /**
56
+ * What the host says about one bundle. Pi identity is the repository URL without
57
+ * the ref; npm-shaped records never match a git bundle. Claude identity is
58
+ * `<plugin>@<marketplace>`, and a record is attributed only when its marketplace
59
+ * is the one bound to the selection's Source.
60
+ */
61
+ export function findInstalledBundle(view, descriptor, selection) {
62
+ if (descriptor.host !== selection.host) {
63
+ return unknownEvidence("the bundle descriptor and the selection disagree on the host");
64
+ }
65
+ return selection.host === "pi"
66
+ ? findPiInstalled(view.pi, selection)
67
+ : findClaudeInstalled(view, descriptor, selection);
68
+ }
69
+ /**
70
+ * The Pi install identity of a registered Source: `git:` plus the scheme-stripped
71
+ * repository URL, without any ref. Built-in and local path Sources have none.
72
+ */
73
+ export function piSourceIdentity(source) {
74
+ if (source.kind === "builtin" || "path" in source) {
75
+ return undefined;
76
+ }
77
+ return `git:${normalizeRepository(source.url)}`;
78
+ }
79
+ /**
80
+ * The marketplace names the host has registered for a Source, resolved by
81
+ * matching each record's own source against the Source URL.
82
+ */
83
+ export function claudeMarketplaceNamesForSource(section, source) {
84
+ const target = hostSourceReference(source);
85
+ if (target === undefined) {
86
+ return Object.freeze([]);
87
+ }
88
+ const names = section.records
89
+ .filter((record) => marketplaceSourceMatches(record.source, target))
90
+ .map((record) => record.name);
91
+ return Object.freeze([...new Set(names)]);
92
+ }
93
+ function hostSourceReference(source) {
94
+ if (source.kind === "builtin") {
95
+ return undefined;
96
+ }
97
+ if ("path" in source) {
98
+ return { kind: "path", reference: path.normalize(source.path) };
99
+ }
100
+ return { kind: "repository", reference: normalizeRepository(source.url) };
101
+ }
102
+ function marketplaceSourceMatches(source, target) {
103
+ if (target.kind === "repository") {
104
+ if (source.kind === "url" || source.kind === "git") {
105
+ return normalizeRepository(source.url) === target.reference;
106
+ }
107
+ if (source.kind === "github") {
108
+ const repository = source.repo.includes("://") ? source.repo : `github.com/${source.repo}`;
109
+ return normalizeRepository(repository) === target.reference;
110
+ }
111
+ return false;
112
+ }
113
+ if (source.kind === "directory" || source.kind === "file") {
114
+ return path.normalize(source.path) === target.reference;
115
+ }
116
+ return false;
117
+ }
118
+ function findPiInstalled(section, selection) {
119
+ if (section.issue !== undefined) {
120
+ return unknownEvidence(section.issue);
121
+ }
122
+ const identity = piSourceIdentity(selection.source);
123
+ if (identity === undefined) {
124
+ return unknownEvidence("the selection's Source has no Pi install identity");
125
+ }
126
+ const wanted = normalizeRepository(identity);
127
+ const match = section.records.find((record) => !record.drifted && normalizeRepository(record.identity) === wanted);
128
+ if (match !== undefined) {
129
+ return installedEvidence(undefined, match.commit);
130
+ }
131
+ if (section.records.some((record) => record.drifted)) {
132
+ return unknownEvidence(`${section.path} holds an entry whose shape could not be read, so the install cannot be verified`);
133
+ }
134
+ return Object.freeze({ kind: "absent" });
135
+ }
136
+ function findClaudeInstalled(view, descriptor, selection) {
137
+ const section = view.claudePlugins;
138
+ if (section.issue !== undefined) {
139
+ return unknownEvidence(section.issue);
140
+ }
141
+ const pluginName = selection.host === "claude" ? selection.pluginName : descriptor.name;
142
+ const records = section.records.filter((record) => record.pluginName === pluginName);
143
+ const names = claudeMarketplaceNamesForSource(view.claudeMarketplaces, selection.source);
144
+ if (names.length > 0) {
145
+ // V1 delegates user-global installs only, so a project or local record does not satisfy the selection.
146
+ const match = records.find((record) => record.scope === "user" && names.includes(record.marketplace));
147
+ return match === undefined
148
+ ? Object.freeze({ kind: "absent" })
149
+ : installedEvidence(match.version, match.commit);
150
+ }
151
+ if (records.some((record) => record.drifted)) {
152
+ return unknownEvidence(`${section.path} holds an entry whose shape could not be read, so the install cannot be verified`);
153
+ }
154
+ if (records.length > 0) {
155
+ const reason = view.claudeMarketplaces.issue
156
+ ?? `no marketplace recorded in ${view.claudeMarketplaces.path} is bound to the selection's Source`;
157
+ return unknownEvidence(reason);
158
+ }
159
+ return Object.freeze({ kind: "absent" });
160
+ }
161
+ function installedEvidence(version, commit) {
162
+ return Object.freeze({
163
+ kind: "installed",
164
+ ...(version === undefined || version === "" ? {} : { version }),
165
+ ...(commit === undefined || commit === "" ? {} : { commit }),
166
+ });
167
+ }
168
+ function unknownEvidence(reason) {
169
+ return Object.freeze({ kind: "unknown", reason });
170
+ }
171
+ async function readSection(id, filePath, reader, parse) {
172
+ let raw;
173
+ try {
174
+ raw = await reader(filePath);
175
+ }
176
+ catch (error) {
177
+ if (isNotFound(error)) {
178
+ return Object.freeze({ id, path: filePath, present: false, records: Object.freeze([]) });
179
+ }
180
+ return Object.freeze({
181
+ id,
182
+ path: filePath,
183
+ present: true,
184
+ records: Object.freeze([]),
185
+ issue: `${filePath} could not be read (${errorReason(error)})`,
186
+ });
187
+ }
188
+ let value;
189
+ try {
190
+ value = JSON.parse(raw);
191
+ }
192
+ catch (error) {
193
+ return Object.freeze({
194
+ id,
195
+ path: filePath,
196
+ present: true,
197
+ records: Object.freeze([]),
198
+ issue: `${filePath} is not valid JSON (${errorReason(error)})`,
199
+ });
200
+ }
201
+ const parsed = parse(value);
202
+ if (parsed.issue !== undefined) {
203
+ return Object.freeze({ id, path: filePath, present: true, records: Object.freeze([]), issue: parsed.issue });
204
+ }
205
+ return Object.freeze({ id, path: filePath, present: true, records: parsed.records ?? Object.freeze([]) });
206
+ }
207
+ function parsePiSettingsFile(value) {
208
+ if (!isRecord(value)) {
209
+ return { issue: "the settings file is not a JSON object" };
210
+ }
211
+ if (value.packages === undefined) {
212
+ return { records: Object.freeze([]) };
213
+ }
214
+ if (!Array.isArray(value.packages)) {
215
+ return { issue: "the packages setting is not an array" };
216
+ }
217
+ return { records: parsePiPackagesSetting(value.packages) };
218
+ }
219
+ function parseClaudePluginsFile(value) {
220
+ if (!isRecord(value)) {
221
+ return { issue: "the installed plugins file is not a JSON object" };
222
+ }
223
+ if (value.plugins === undefined) {
224
+ return { records: Object.freeze([]) };
225
+ }
226
+ if (!isRecord(value.plugins)) {
227
+ return { issue: "the plugins record is not a JSON object" };
228
+ }
229
+ return { records: parseClaudePluginState(value) };
230
+ }
231
+ function parseClaudeMarketplacesFile(value) {
232
+ if (!isRecord(value)) {
233
+ return { issue: "the known marketplaces file is not a JSON object" };
234
+ }
235
+ return { records: parseClaudeMarketplaceState(value) };
236
+ }
237
+ function parsePiInstallEntry(entry) {
238
+ const source = typeof entry === "string"
239
+ ? entry.trim()
240
+ : isRecord(entry) && typeof entry.source === "string"
241
+ ? entry.source.trim()
242
+ : "";
243
+ if (source === "") {
244
+ return Object.freeze({ source: "", identity: "", objectForm: isRecord(entry), drifted: true });
245
+ }
246
+ const pinIndex = trailingPinIndex(source);
247
+ const pin = pinIndex === undefined ? undefined : source.slice(pinIndex + 1);
248
+ const identity = pinIndex === undefined ? source : source.slice(0, pinIndex);
249
+ const commit = pin !== undefined && /^[0-9a-f]{7,64}$/iu.test(pin) ? pin.toLowerCase() : undefined;
250
+ return Object.freeze({
251
+ source,
252
+ identity,
253
+ ...(pin === undefined ? {} : { pin }),
254
+ ...(commit === undefined ? {} : { commit }),
255
+ objectForm: isRecord(entry),
256
+ drifted: false,
257
+ });
258
+ }
259
+ function parseClaudeInstallEntry(installId, entry) {
260
+ const id = parseClaudeInstallId(installId);
261
+ if (id === undefined || !isRecord(entry) || typeof entry.scope !== "string" || typeof entry.installPath !== "string") {
262
+ return Object.freeze({
263
+ installId,
264
+ pluginName: id?.pluginName ?? "",
265
+ marketplace: id?.marketplace ?? "",
266
+ scope: "",
267
+ installPath: "",
268
+ drifted: true,
269
+ });
270
+ }
271
+ return Object.freeze({
272
+ installId,
273
+ pluginName: id.pluginName,
274
+ marketplace: id.marketplace,
275
+ scope: entry.scope,
276
+ installPath: entry.installPath,
277
+ ...(typeof entry.version === "string" ? { version: entry.version } : {}),
278
+ ...(typeof entry.gitCommitSha === "string" ? { commit: entry.gitCommitSha } : {}),
279
+ drifted: false,
280
+ });
281
+ }
282
+ function parseClaudeMarketplaceEntry(name, raw) {
283
+ const record = isRecord(raw) ? raw : undefined;
284
+ return Object.freeze({
285
+ name,
286
+ source: parseClaudeMarketplaceSource(record?.source),
287
+ installLocation: record !== undefined && typeof record.installLocation === "string" ? record.installLocation : "",
288
+ ...(record !== undefined && typeof record.lastUpdated === "string" ? { lastUpdated: record.lastUpdated } : {}),
289
+ ...(record !== undefined && typeof record.autoUpdate === "boolean" ? { autoUpdate: record.autoUpdate } : {}),
290
+ });
291
+ }
292
+ function parseClaudeMarketplaceSource(value) {
293
+ if (!isRecord(value) || typeof value.source !== "string") {
294
+ return { kind: "unknown", raw: JSON.stringify(value ?? null) };
295
+ }
296
+ const ref = typeof value.ref === "string" && value.ref !== "" ? value.ref : undefined;
297
+ switch (value.source) {
298
+ case "github":
299
+ return typeof value.repo === "string" && value.repo !== ""
300
+ ? Object.freeze({ kind: "github", repo: value.repo, ...(ref === undefined ? {} : { ref }) })
301
+ : { kind: "unknown", raw: JSON.stringify(value) };
302
+ case "url":
303
+ return typeof value.url === "string" && value.url !== ""
304
+ ? Object.freeze({ kind: "url", url: value.url, ...(ref === undefined ? {} : { ref }) })
305
+ : { kind: "unknown", raw: JSON.stringify(value) };
306
+ case "git":
307
+ return typeof value.url === "string" && value.url !== ""
308
+ ? Object.freeze({ kind: "git", url: value.url, ...(ref === undefined ? {} : { ref }) })
309
+ : { kind: "unknown", raw: JSON.stringify(value) };
310
+ case "directory":
311
+ return typeof value.path === "string" && value.path !== ""
312
+ ? Object.freeze({ kind: "directory", path: value.path })
313
+ : { kind: "unknown", raw: JSON.stringify(value) };
314
+ case "file":
315
+ return typeof value.path === "string" && value.path !== ""
316
+ ? Object.freeze({ kind: "file", path: value.path })
317
+ : { kind: "unknown", raw: JSON.stringify(value) };
318
+ default:
319
+ return { kind: "unknown", raw: JSON.stringify(value) };
320
+ }
321
+ }
322
+ function parseClaudeInstallId(installId) {
323
+ const at = installId.lastIndexOf("@");
324
+ if (at <= 0 || at === installId.length - 1) {
325
+ return undefined;
326
+ }
327
+ return { pluginName: installId.slice(0, at), marketplace: installId.slice(at + 1) };
328
+ }
329
+ /** The `@<pin>` of a Pi source string, only when it follows the last path segment. */
330
+ function trailingPinIndex(source) {
331
+ const at = source.lastIndexOf("@");
332
+ return at > 0 && at > source.lastIndexOf("/") ? at : undefined;
333
+ }
334
+ function normalizeRepository(value) {
335
+ const scheme = value.indexOf("://");
336
+ return (scheme >= 0 ? value.slice(scheme + 3) : value)
337
+ .replace(/^git:/u, "")
338
+ .replace(/\.git$/u, "")
339
+ .replace(/\/+$/u, "");
340
+ }
341
+ function isNotFound(error) {
342
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
343
+ }
344
+ function errorReason(error) {
345
+ return error instanceof Error ? error.message : "the host file could not be read";
346
+ }
347
+ function isRecord(value) {
348
+ return typeof value === "object" && value !== null && !Array.isArray(value);
349
+ }
@@ -0,0 +1,33 @@
1
+ /** V1 bundle hosts. Codex and OpenCode packaging stays recognized but deferred. */
2
+ export const PACKAGE_HOSTS = Object.freeze(["pi", "claude"]);
3
+ /** The single list of categories Agent Depot reports but never manages. */
4
+ export const DEFERRED_COMPONENT_KINDS = Object.freeze(["agents", "mcp-servers"]);
5
+ /** The portable selection a discovered bundle stands for: its coordinates, `latest`, and the Source it came from. */
6
+ export function packageSelectionFor(descriptor, source) {
7
+ const coordinates = descriptor.coordinates;
8
+ return coordinates.host === "pi"
9
+ ? { host: "pi", root: coordinates.root, source, version: { policy: "latest" } }
10
+ : { host: "claude", marketplaceRoot: coordinates.marketplaceRoot, pluginName: coordinates.pluginName, source, version: { policy: "latest" } };
11
+ }
12
+ /** The POSIX directory identifying where a bundle lives within its Source. */
13
+ export function bundleRoot(coordinates) {
14
+ return coordinates.host === "pi" ? coordinates.root : coordinates.marketplaceRoot;
15
+ }
16
+ /** A human-readable one-line label for a bundle's coordinates. */
17
+ export function describeCoordinates(coordinates) {
18
+ const at = (pathValue) => (pathValue === "" ? "." : pathValue);
19
+ return coordinates.host === "pi"
20
+ ? `pi ${at(coordinates.root)}`
21
+ : `claude ${coordinates.pluginName}@${at(coordinates.marketplaceRoot)}`;
22
+ }
23
+ /**
24
+ * The stable selection identity. Derived from the Source and the coordinates
25
+ * only, so a bundle rename (its manifest name or version) does not move the
26
+ * key, while a Source or coordinate change does.
27
+ */
28
+ export function selectionKey(selection) {
29
+ const coordinates = selection.host === "pi"
30
+ ? { host: selection.host, root: selection.root }
31
+ : { host: selection.host, marketplaceRoot: selection.marketplaceRoot, pluginName: selection.pluginName };
32
+ return JSON.stringify([selection.source, coordinates]);
33
+ }