@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.
- package/CHANGELOG.md +29 -0
- package/README.es.md +969 -0
- package/README.md +244 -23
- package/dist/src/app-flow.js +52 -51
- package/dist/src/app-recipe-removal.js +63 -0
- package/dist/src/cli.js +122 -11
- package/dist/src/executable-resolution.js +54 -0
- package/dist/src/git-source.js +129 -6
- package/dist/src/package-bundles.js +614 -0
- package/dist/src/package-cli.js +412 -0
- package/dist/src/package-content.js +158 -0
- package/dist/src/package-flow.js +636 -0
- package/dist/src/package-host-plans.js +240 -0
- package/dist/src/package-host-state.js +349 -0
- package/dist/src/package-model.js +33 -0
- package/dist/src/package-owned-skills.js +122 -0
- package/dist/src/package-state.js +404 -0
- package/dist/src/profile-cli.js +15 -9
- package/dist/src/profile-export.js +44 -9
- package/dist/src/profile-import.js +80 -17
- package/dist/src/profile.js +78 -3
- package/dist/src/skill-discovery.js +2 -2
- package/dist/src/skill-install.js +16 -0
- package/dist/src/sources.js +11 -0
- package/dist/src/tui/app-actions.js +1 -1
- package/dist/src/tui/app.js +5 -6
- package/dist/src/tui/catalog-browse.js +10 -3
- package/dist/src/tui/catalog-panel.js +7 -3
- package/dist/src/tui/catalog-state.js +57 -6
- package/dist/src/tui/catalog-tree.js +52 -10
- package/dist/src/tui/catalog-view.js +27 -4
- package/dist/src/tui/environment.js +8 -1
- package/dist/src/tui/installations-browse.js +20 -2
- package/dist/src/tui/installations-rows.js +2 -1
- package/dist/src/tui/installations-tree.js +14 -1
- package/dist/src/tui/installations-view.js +8 -2
- package/dist/src/tui/installations.js +12 -2
- package/dist/src/tui/manage-actions.js +2 -0
- package/dist/src/tui/manage-panel.js +5 -0
- package/dist/src/tui/package-actions.js +165 -0
- package/dist/src/tui/package-rows.js +87 -0
- package/dist/src/tui/profile-view.js +36 -14
- package/dist/src/tui/sources-mode.js +2 -0
- package/dist/src/tui/sources-recipes.js +116 -53
- package/dist/src/tui/sources-view.js +57 -19
- package/dist/src/tui/tree.js +3 -4
- package/dist/src/tui/updates-flow.js +4 -1
- package/dist/src/tui/updates-panel.js +2 -2
- package/dist/src/tui/updates.js +72 -4
- package/dist/src/user-global-skill-inventory.js +7 -2
- package/package.json +2 -1
- 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
|
+
}
|