@iseoane/agent-depot 0.4.1 → 0.5.1
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 +45 -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 +150 -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 +645 -0
- package/dist/src/package-host-plans.js +243 -0
- package/dist/src/package-host-state.js +473 -0
- package/dist/src/package-model.js +33 -0
- package/dist/src/package-owned-skills.js +124 -0
- package/dist/src/package-state.js +404 -0
- package/dist/src/process-runner.js +9 -1
- 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 +7 -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 +10 -11
- package/dist/src/tui/catalog-browse.js +46 -13
- package/dist/src/tui/catalog-ownership.js +12 -0
- package/dist/src/tui/catalog-panel.js +16 -4
- package/dist/src/tui/catalog-state.js +62 -6
- package/dist/src/tui/catalog-tree.js +58 -12
- package/dist/src/tui/catalog-view.js +53 -7
- 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 +91 -0
- package/dist/src/tui/profile-view.js +36 -14
- package/dist/src/tui/render.js +1 -1
- 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/tui/window.js +3 -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,473 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { canonicalizeGitSourceUrl } from "./git-source.js";
|
|
5
|
+
import { assertNoSymlinkPath } from "./path-safety.js";
|
|
6
|
+
import { runProcess } from "./process-runner.js";
|
|
7
|
+
/**
|
|
8
|
+
* Reads the `packages` setting, or a bare `packages` array, into records.
|
|
9
|
+
* Nothing here throws: an unknown entry shape becomes a drifted record so the
|
|
10
|
+
* lookup it could affect reports `unknown` instead of a guess.
|
|
11
|
+
*/
|
|
12
|
+
export function parsePiPackagesSetting(value) {
|
|
13
|
+
const entries = Array.isArray(value)
|
|
14
|
+
? value
|
|
15
|
+
: isRecord(value) && Array.isArray(value.packages)
|
|
16
|
+
? value.packages
|
|
17
|
+
: undefined;
|
|
18
|
+
if (entries === undefined) {
|
|
19
|
+
return Object.freeze([]);
|
|
20
|
+
}
|
|
21
|
+
return Object.freeze(entries.map(parsePiInstallEntry));
|
|
22
|
+
}
|
|
23
|
+
/** Reads `installed_plugins.json` in its v2 (array per plugin) and v1 (one object) shapes. */
|
|
24
|
+
export function parseClaudePluginState(value) {
|
|
25
|
+
if (!isRecord(value) || !isRecord(value.plugins)) {
|
|
26
|
+
return Object.freeze([]);
|
|
27
|
+
}
|
|
28
|
+
const records = [];
|
|
29
|
+
for (const [installId, raw] of Object.entries(value.plugins)) {
|
|
30
|
+
for (const entry of Array.isArray(raw) ? raw : [raw]) {
|
|
31
|
+
records.push(parseClaudeInstallEntry(installId, entry));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze(records);
|
|
35
|
+
}
|
|
36
|
+
/** Reads `known_marketplaces.json`, whose keys are the marketplace names. */
|
|
37
|
+
export function parseClaudeMarketplaceState(value) {
|
|
38
|
+
if (!isRecord(value)) {
|
|
39
|
+
return Object.freeze([]);
|
|
40
|
+
}
|
|
41
|
+
const records = Object.entries(value).map(([name, raw]) => parseClaudeMarketplaceEntry(name, raw));
|
|
42
|
+
return Object.freeze(records);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reads the three host state files. A missing file is an empty section; a
|
|
46
|
+
* drifted or unreadable file degrades to an `issue`, and this never throws.
|
|
47
|
+
*/
|
|
48
|
+
export async function readHostInstallView(env = {}) {
|
|
49
|
+
const home = env.homeDirectory ?? homedir();
|
|
50
|
+
const piAgentDirectory = path.resolve(env.piAgentDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent"));
|
|
51
|
+
const reader = env.readHostStateFile ?? ((filePath) => readFile(filePath, "utf8"));
|
|
52
|
+
const [pi, claudePlugins, claudeMarketplaces] = await Promise.all([
|
|
53
|
+
readSection("pi-settings", path.join(piAgentDirectory, "settings.json"), reader, parsePiSettingsFile),
|
|
54
|
+
readSection("claude-installed-plugins", path.join(home, ".claude", "plugins", "installed_plugins.json"), reader, parseClaudePluginsFile),
|
|
55
|
+
readSection("claude-known-marketplaces", path.join(home, ".claude", "plugins", "known_marketplaces.json"), reader, parseClaudeMarketplacesFile),
|
|
56
|
+
]);
|
|
57
|
+
const verifiedPi = await attachPiCheckoutEvidence(pi, piAgentDirectory, env.readPiCheckout ?? readPiCheckoutEvidence);
|
|
58
|
+
return Object.freeze({ pi: verifiedPi, claudePlugins, claudeMarketplaces });
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* What the host says about one bundle. Pi identity is the repository URL without
|
|
62
|
+
* the ref; npm-shaped records never match a git bundle. Claude identity is
|
|
63
|
+
* `<plugin>@<marketplace>`, and a record is attributed only when its marketplace
|
|
64
|
+
* is the one bound to the selection's Source.
|
|
65
|
+
*/
|
|
66
|
+
export function findInstalledBundle(view, descriptor, selection) {
|
|
67
|
+
if (descriptor.host !== selection.host) {
|
|
68
|
+
return unknownEvidence("the bundle descriptor and the selection disagree on the host");
|
|
69
|
+
}
|
|
70
|
+
if (selection.host === "pi" && (descriptor.coordinates.host !== "pi" || descriptor.coordinates.root !== selection.root)) {
|
|
71
|
+
return unknownEvidence("the Pi bundle root and the selection disagree");
|
|
72
|
+
}
|
|
73
|
+
return selection.host === "pi"
|
|
74
|
+
? findPiInstalled(view.pi, selection)
|
|
75
|
+
: findClaudeInstalled(view, descriptor, selection);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The Pi install identity of a registered Source: `git:` plus the scheme-stripped
|
|
79
|
+
* repository URL, without any ref. Built-in and local path Sources have none.
|
|
80
|
+
*/
|
|
81
|
+
export function piSourceIdentity(source) {
|
|
82
|
+
if (source.kind === "builtin" || "path" in source) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return `git:${normalizeRepository(source.url)}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The marketplace names the host has registered for a Source, resolved by
|
|
89
|
+
* matching each record's own source against the Source URL.
|
|
90
|
+
*/
|
|
91
|
+
export function claudeMarketplaceNamesForSource(section, source) {
|
|
92
|
+
const target = hostSourceReference(source);
|
|
93
|
+
if (target === undefined) {
|
|
94
|
+
return Object.freeze([]);
|
|
95
|
+
}
|
|
96
|
+
const names = section.records
|
|
97
|
+
.filter((record) => marketplaceSourceMatches(record.source, target))
|
|
98
|
+
.map((record) => record.name);
|
|
99
|
+
return Object.freeze([...new Set(names)]);
|
|
100
|
+
}
|
|
101
|
+
function hostSourceReference(source) {
|
|
102
|
+
if (source.kind === "builtin") {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if ("path" in source) {
|
|
106
|
+
return { kind: "path", reference: path.normalize(source.path) };
|
|
107
|
+
}
|
|
108
|
+
return { kind: "repository", reference: normalizeRepository(source.url) };
|
|
109
|
+
}
|
|
110
|
+
function marketplaceSourceMatches(source, target) {
|
|
111
|
+
if (target.kind === "repository") {
|
|
112
|
+
if (source.kind === "url" || source.kind === "git") {
|
|
113
|
+
return normalizeRepository(source.url) === target.reference;
|
|
114
|
+
}
|
|
115
|
+
if (source.kind === "github") {
|
|
116
|
+
const repository = source.repo.includes("://") ? source.repo : `github.com/${source.repo}`;
|
|
117
|
+
return normalizeRepository(repository) === target.reference;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (source.kind === "directory" || source.kind === "file") {
|
|
122
|
+
return path.normalize(source.path) === target.reference;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
function findPiInstalled(section, selection) {
|
|
127
|
+
if (section.issue !== undefined) {
|
|
128
|
+
return unknownEvidence(section.issue);
|
|
129
|
+
}
|
|
130
|
+
const identity = piSourceIdentity(selection.source);
|
|
131
|
+
if (identity === undefined) {
|
|
132
|
+
return unknownEvidence("the selection's Source has no Pi install identity");
|
|
133
|
+
}
|
|
134
|
+
const wanted = normalizeRepository(identity);
|
|
135
|
+
const match = section.records.find((record) => !record.drifted && normalizeRepository(record.identity) === wanted);
|
|
136
|
+
if (match !== undefined) {
|
|
137
|
+
if (selection.root !== "") {
|
|
138
|
+
return unknownEvidence("Pi project-scoped and subdirectory package checkouts are not supported for version evidence");
|
|
139
|
+
}
|
|
140
|
+
if (match.checkoutIssue !== undefined) {
|
|
141
|
+
return installedEvidence(undefined, undefined, match.checkoutIssue);
|
|
142
|
+
}
|
|
143
|
+
if (match.checkoutCommit === undefined) {
|
|
144
|
+
return installedEvidence(undefined, undefined, `Pi reports this Package installed, but its checkout could not be verified. Version and update assessment are unavailable; inspect the configured checkout before updating.`);
|
|
145
|
+
}
|
|
146
|
+
return installedEvidence(match.checkoutVersion, match.checkoutCommit);
|
|
147
|
+
}
|
|
148
|
+
if (section.records.some((record) => record.drifted)) {
|
|
149
|
+
return unknownEvidence(`${section.path} holds an entry whose shape could not be read, so the install cannot be verified`);
|
|
150
|
+
}
|
|
151
|
+
return Object.freeze({ kind: "absent" });
|
|
152
|
+
}
|
|
153
|
+
function findClaudeInstalled(view, descriptor, selection) {
|
|
154
|
+
const section = view.claudePlugins;
|
|
155
|
+
if (section.issue !== undefined) {
|
|
156
|
+
return unknownEvidence(section.issue);
|
|
157
|
+
}
|
|
158
|
+
const pluginName = selection.host === "claude" ? selection.pluginName : descriptor.name;
|
|
159
|
+
const records = section.records.filter((record) => record.pluginName === pluginName);
|
|
160
|
+
const names = claudeMarketplaceNamesForSource(view.claudeMarketplaces, selection.source);
|
|
161
|
+
if (names.length > 0) {
|
|
162
|
+
// V1 delegates user-global installs only, so a project or local record does not satisfy the selection.
|
|
163
|
+
const match = records.find((record) => record.scope === "user" && names.includes(record.marketplace));
|
|
164
|
+
return match === undefined
|
|
165
|
+
? Object.freeze({ kind: "absent" })
|
|
166
|
+
: installedEvidence(match.version, match.commit);
|
|
167
|
+
}
|
|
168
|
+
if (records.some((record) => record.drifted)) {
|
|
169
|
+
return unknownEvidence(`${section.path} holds an entry whose shape could not be read, so the install cannot be verified`);
|
|
170
|
+
}
|
|
171
|
+
if (records.length > 0) {
|
|
172
|
+
const reason = view.claudeMarketplaces.issue
|
|
173
|
+
?? `no marketplace recorded in ${view.claudeMarketplaces.path} is bound to the selection's Source`;
|
|
174
|
+
return unknownEvidence(reason);
|
|
175
|
+
}
|
|
176
|
+
return Object.freeze({ kind: "absent" });
|
|
177
|
+
}
|
|
178
|
+
function installedEvidence(version, commit, reason) {
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
kind: "installed",
|
|
181
|
+
...(version === undefined || version === "" ? {} : { version }),
|
|
182
|
+
...(commit === undefined || commit === "" ? {} : { commit }),
|
|
183
|
+
...(reason === undefined ? {} : { reason }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async function attachPiCheckoutEvidence(section, agentDirectory, reader) {
|
|
187
|
+
const records = await Promise.all(section.records.map(async (record) => {
|
|
188
|
+
if (record.drifted || !record.identity.startsWith("git:"))
|
|
189
|
+
return record;
|
|
190
|
+
try {
|
|
191
|
+
const checkoutPath = piGitInstallPath(agentDirectory, record.identity);
|
|
192
|
+
const evidence = await reader(checkoutPath, record.identity);
|
|
193
|
+
return Object.freeze({
|
|
194
|
+
...record,
|
|
195
|
+
...(evidence.commit === undefined ? {} : { checkoutCommit: evidence.commit }),
|
|
196
|
+
...(evidence.version === undefined ? {} : { checkoutVersion: evidence.version }),
|
|
197
|
+
...(evidence.issue === undefined ? {} : { checkoutIssue: evidence.issue }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
...record,
|
|
203
|
+
checkoutIssue: error instanceof Error ? error.message : "Pi Git checkout could not be verified",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
return Object.freeze({ ...section, records: Object.freeze(records) });
|
|
208
|
+
}
|
|
209
|
+
function piGitInstallPath(agentDirectory, identity) {
|
|
210
|
+
const source = identity.slice("git:".length);
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = new URL(`https://${source}`);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new Error("the configured Pi Git source has no safe checkout path");
|
|
217
|
+
}
|
|
218
|
+
const segments = parsed.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
|
|
219
|
+
const lastSegment = segments.at(-1);
|
|
220
|
+
if (lastSegment?.endsWith(".git"))
|
|
221
|
+
segments[segments.length - 1] = lastSegment.slice(0, -4);
|
|
222
|
+
if (segments.length < 2 || segments.some((segment) => segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
|
|
223
|
+
throw new Error("the configured Pi Git source has no safe checkout path");
|
|
224
|
+
}
|
|
225
|
+
return path.resolve(agentDirectory, "git", parsed.hostname, ...segments);
|
|
226
|
+
}
|
|
227
|
+
async function readPiCheckoutEvidence(checkoutPath, sourceIdentity) {
|
|
228
|
+
const unavailable = (reason) => ({
|
|
229
|
+
issue: `Pi reports this Package installed, but ${reason}. Version and update assessment are unavailable; inspect the checkout before updating.`,
|
|
230
|
+
});
|
|
231
|
+
try {
|
|
232
|
+
await assertNoSymlinkPath(checkoutPath, "Pi package checkout path");
|
|
233
|
+
const root = await lstat(checkoutPath).catch((error) => {
|
|
234
|
+
if (isNotFound(error))
|
|
235
|
+
return undefined;
|
|
236
|
+
throw error;
|
|
237
|
+
});
|
|
238
|
+
if (root === undefined)
|
|
239
|
+
return unavailable("its configured Git checkout is missing");
|
|
240
|
+
if (!root.isDirectory())
|
|
241
|
+
return unavailable("its configured Git checkout is not a directory");
|
|
242
|
+
const [topLevel, origin, commit, status, manifest] = await Promise.all([
|
|
243
|
+
runPiGit(checkoutPath, ["rev-parse", "--show-toplevel"]),
|
|
244
|
+
runPiGit(checkoutPath, ["config", "--get", "remote.origin.url"]),
|
|
245
|
+
runPiGit(checkoutPath, ["rev-parse", "--verify", "HEAD^{commit}"]),
|
|
246
|
+
runPiGit(checkoutPath, ["status", "--porcelain", "--untracked-files=all"]),
|
|
247
|
+
runPiGit(checkoutPath, ["show", "HEAD:package.json"]),
|
|
248
|
+
]);
|
|
249
|
+
const expectedRoot = path.resolve(checkoutPath);
|
|
250
|
+
if (path.resolve(topLevel) !== expectedRoot)
|
|
251
|
+
return unavailable("the package path is not the root of its Git checkout");
|
|
252
|
+
if (normalizeRepository(piSourceIdentityFromUrl(origin)) !== normalizeRepository(sourceIdentity)) {
|
|
253
|
+
return unavailable("the Git checkout origin does not match the selected Pi package Source");
|
|
254
|
+
}
|
|
255
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(commit))
|
|
256
|
+
return unavailable("the Pi checkout HEAD is not a full Git commit");
|
|
257
|
+
if (status !== "")
|
|
258
|
+
return unavailable("the Pi Git checkout has local modifications");
|
|
259
|
+
const packageJson = JSON.parse(manifest);
|
|
260
|
+
if (!isRecord(packageJson) || typeof packageJson.version !== "string" || packageJson.version.trim() === "") {
|
|
261
|
+
return { commit: commit.toLowerCase() };
|
|
262
|
+
}
|
|
263
|
+
return { commit: commit.toLowerCase(), version: packageJson.version };
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const message = error instanceof Error ? error.message : "";
|
|
267
|
+
const reason = message.includes("timed out")
|
|
268
|
+
? "Git checkout verification timed out"
|
|
269
|
+
: message.includes("symbolic link")
|
|
270
|
+
? "the configured checkout path contains a symbolic link"
|
|
271
|
+
: "the Pi Git checkout could not be read safely";
|
|
272
|
+
return unavailable(reason);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function runPiGit(checkoutPath, args) {
|
|
276
|
+
const result = await runProcess("git", ["-C", checkoutPath, ...args], {
|
|
277
|
+
captureStdout: true,
|
|
278
|
+
maxOutputBytes: 1024 * 1024,
|
|
279
|
+
maxStderrBytes: 8192,
|
|
280
|
+
timeoutMs: 3000,
|
|
281
|
+
});
|
|
282
|
+
if (result.timedOut)
|
|
283
|
+
throw new Error("Pi checkout verification timed out");
|
|
284
|
+
if (result.outputTooLarge || result.code !== 0 || result.signal !== null)
|
|
285
|
+
throw new Error("Pi checkout verification command failed");
|
|
286
|
+
return result.stdout.toString("utf8").trim();
|
|
287
|
+
}
|
|
288
|
+
function piSourceIdentityFromUrl(origin) {
|
|
289
|
+
const canonical = canonicalizeGitSourceUrl(origin);
|
|
290
|
+
return `git:${canonical.replace(/^[a-z][a-z0-9+.-]*:\/\//iu, "")}`;
|
|
291
|
+
}
|
|
292
|
+
function unknownEvidence(reason) {
|
|
293
|
+
return Object.freeze({ kind: "unknown", reason });
|
|
294
|
+
}
|
|
295
|
+
async function readSection(id, filePath, reader, parse) {
|
|
296
|
+
let raw;
|
|
297
|
+
try {
|
|
298
|
+
raw = await reader(filePath);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (isNotFound(error)) {
|
|
302
|
+
return Object.freeze({ id, path: filePath, present: false, records: Object.freeze([]) });
|
|
303
|
+
}
|
|
304
|
+
return Object.freeze({
|
|
305
|
+
id,
|
|
306
|
+
path: filePath,
|
|
307
|
+
present: true,
|
|
308
|
+
records: Object.freeze([]),
|
|
309
|
+
issue: `${filePath} could not be read (${errorReason(error)})`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
let value;
|
|
313
|
+
try {
|
|
314
|
+
value = JSON.parse(raw);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return Object.freeze({
|
|
318
|
+
id,
|
|
319
|
+
path: filePath,
|
|
320
|
+
present: true,
|
|
321
|
+
records: Object.freeze([]),
|
|
322
|
+
issue: `${filePath} is not valid JSON (${errorReason(error)})`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const parsed = parse(value);
|
|
326
|
+
if (parsed.issue !== undefined) {
|
|
327
|
+
return Object.freeze({ id, path: filePath, present: true, records: Object.freeze([]), issue: parsed.issue });
|
|
328
|
+
}
|
|
329
|
+
return Object.freeze({ id, path: filePath, present: true, records: parsed.records ?? Object.freeze([]) });
|
|
330
|
+
}
|
|
331
|
+
function parsePiSettingsFile(value) {
|
|
332
|
+
if (!isRecord(value)) {
|
|
333
|
+
return { issue: "the settings file is not a JSON object" };
|
|
334
|
+
}
|
|
335
|
+
if (value.packages === undefined) {
|
|
336
|
+
return { records: Object.freeze([]) };
|
|
337
|
+
}
|
|
338
|
+
if (!Array.isArray(value.packages)) {
|
|
339
|
+
return { issue: "the packages setting is not an array" };
|
|
340
|
+
}
|
|
341
|
+
return { records: parsePiPackagesSetting(value.packages) };
|
|
342
|
+
}
|
|
343
|
+
function parseClaudePluginsFile(value) {
|
|
344
|
+
if (!isRecord(value)) {
|
|
345
|
+
return { issue: "the installed plugins file is not a JSON object" };
|
|
346
|
+
}
|
|
347
|
+
if (value.plugins === undefined) {
|
|
348
|
+
return { records: Object.freeze([]) };
|
|
349
|
+
}
|
|
350
|
+
if (!isRecord(value.plugins)) {
|
|
351
|
+
return { issue: "the plugins record is not a JSON object" };
|
|
352
|
+
}
|
|
353
|
+
return { records: parseClaudePluginState(value) };
|
|
354
|
+
}
|
|
355
|
+
function parseClaudeMarketplacesFile(value) {
|
|
356
|
+
if (!isRecord(value)) {
|
|
357
|
+
return { issue: "the known marketplaces file is not a JSON object" };
|
|
358
|
+
}
|
|
359
|
+
return { records: parseClaudeMarketplaceState(value) };
|
|
360
|
+
}
|
|
361
|
+
function parsePiInstallEntry(entry) {
|
|
362
|
+
const source = typeof entry === "string"
|
|
363
|
+
? entry.trim()
|
|
364
|
+
: isRecord(entry) && typeof entry.source === "string"
|
|
365
|
+
? entry.source.trim()
|
|
366
|
+
: "";
|
|
367
|
+
if (source === "") {
|
|
368
|
+
return Object.freeze({ source: "", identity: "", objectForm: isRecord(entry), drifted: true });
|
|
369
|
+
}
|
|
370
|
+
const pinIndex = trailingPinIndex(source);
|
|
371
|
+
const pin = pinIndex === undefined ? undefined : source.slice(pinIndex + 1);
|
|
372
|
+
const identity = pinIndex === undefined ? source : source.slice(0, pinIndex);
|
|
373
|
+
const commit = pin !== undefined && /^[0-9a-f]{7,64}$/iu.test(pin) ? pin.toLowerCase() : undefined;
|
|
374
|
+
return Object.freeze({
|
|
375
|
+
source,
|
|
376
|
+
identity,
|
|
377
|
+
...(pin === undefined ? {} : { pin }),
|
|
378
|
+
...(commit === undefined ? {} : { commit }),
|
|
379
|
+
objectForm: isRecord(entry),
|
|
380
|
+
drifted: false,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
function parseClaudeInstallEntry(installId, entry) {
|
|
384
|
+
const id = parseClaudeInstallId(installId);
|
|
385
|
+
if (id === undefined || !isRecord(entry) || typeof entry.scope !== "string" || typeof entry.installPath !== "string") {
|
|
386
|
+
return Object.freeze({
|
|
387
|
+
installId,
|
|
388
|
+
pluginName: id?.pluginName ?? "",
|
|
389
|
+
marketplace: id?.marketplace ?? "",
|
|
390
|
+
scope: "",
|
|
391
|
+
installPath: "",
|
|
392
|
+
drifted: true,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return Object.freeze({
|
|
396
|
+
installId,
|
|
397
|
+
pluginName: id.pluginName,
|
|
398
|
+
marketplace: id.marketplace,
|
|
399
|
+
scope: entry.scope,
|
|
400
|
+
installPath: entry.installPath,
|
|
401
|
+
...(typeof entry.version === "string" ? { version: entry.version } : {}),
|
|
402
|
+
...(typeof entry.gitCommitSha === "string" ? { commit: entry.gitCommitSha } : {}),
|
|
403
|
+
drifted: false,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function parseClaudeMarketplaceEntry(name, raw) {
|
|
407
|
+
const record = isRecord(raw) ? raw : undefined;
|
|
408
|
+
return Object.freeze({
|
|
409
|
+
name,
|
|
410
|
+
source: parseClaudeMarketplaceSource(record?.source),
|
|
411
|
+
installLocation: record !== undefined && typeof record.installLocation === "string" ? record.installLocation : "",
|
|
412
|
+
...(record !== undefined && typeof record.lastUpdated === "string" ? { lastUpdated: record.lastUpdated } : {}),
|
|
413
|
+
...(record !== undefined && typeof record.autoUpdate === "boolean" ? { autoUpdate: record.autoUpdate } : {}),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function parseClaudeMarketplaceSource(value) {
|
|
417
|
+
if (!isRecord(value) || typeof value.source !== "string") {
|
|
418
|
+
return { kind: "unknown", raw: JSON.stringify(value ?? null) };
|
|
419
|
+
}
|
|
420
|
+
const ref = typeof value.ref === "string" && value.ref !== "" ? value.ref : undefined;
|
|
421
|
+
switch (value.source) {
|
|
422
|
+
case "github":
|
|
423
|
+
return typeof value.repo === "string" && value.repo !== ""
|
|
424
|
+
? Object.freeze({ kind: "github", repo: value.repo, ...(ref === undefined ? {} : { ref }) })
|
|
425
|
+
: { kind: "unknown", raw: JSON.stringify(value) };
|
|
426
|
+
case "url":
|
|
427
|
+
return typeof value.url === "string" && value.url !== ""
|
|
428
|
+
? Object.freeze({ kind: "url", url: value.url, ...(ref === undefined ? {} : { ref }) })
|
|
429
|
+
: { kind: "unknown", raw: JSON.stringify(value) };
|
|
430
|
+
case "git":
|
|
431
|
+
return typeof value.url === "string" && value.url !== ""
|
|
432
|
+
? Object.freeze({ kind: "git", url: value.url, ...(ref === undefined ? {} : { ref }) })
|
|
433
|
+
: { kind: "unknown", raw: JSON.stringify(value) };
|
|
434
|
+
case "directory":
|
|
435
|
+
return typeof value.path === "string" && value.path !== ""
|
|
436
|
+
? Object.freeze({ kind: "directory", path: value.path })
|
|
437
|
+
: { kind: "unknown", raw: JSON.stringify(value) };
|
|
438
|
+
case "file":
|
|
439
|
+
return typeof value.path === "string" && value.path !== ""
|
|
440
|
+
? Object.freeze({ kind: "file", path: value.path })
|
|
441
|
+
: { kind: "unknown", raw: JSON.stringify(value) };
|
|
442
|
+
default:
|
|
443
|
+
return { kind: "unknown", raw: JSON.stringify(value) };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function parseClaudeInstallId(installId) {
|
|
447
|
+
const at = installId.lastIndexOf("@");
|
|
448
|
+
if (at <= 0 || at === installId.length - 1) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
return { pluginName: installId.slice(0, at), marketplace: installId.slice(at + 1) };
|
|
452
|
+
}
|
|
453
|
+
/** The `@<pin>` of a Pi source string, only when it follows the last path segment. */
|
|
454
|
+
function trailingPinIndex(source) {
|
|
455
|
+
const at = source.lastIndexOf("@");
|
|
456
|
+
return at > 0 && at > source.lastIndexOf("/") ? at : undefined;
|
|
457
|
+
}
|
|
458
|
+
function normalizeRepository(value) {
|
|
459
|
+
const scheme = value.indexOf("://");
|
|
460
|
+
return (scheme >= 0 ? value.slice(scheme + 3) : value)
|
|
461
|
+
.replace(/^git:/u, "")
|
|
462
|
+
.replace(/\.git$/u, "")
|
|
463
|
+
.replace(/\/+$/u, "");
|
|
464
|
+
}
|
|
465
|
+
function isNotFound(error) {
|
|
466
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
467
|
+
}
|
|
468
|
+
function errorReason(error) {
|
|
469
|
+
return error instanceof Error ? error.message : "the host file could not be read";
|
|
470
|
+
}
|
|
471
|
+
function isRecord(value) {
|
|
472
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
473
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { bundleOwnedSkillMatcher, bundleOwnedSkillNameMatcher, discoverBundlesInSnapshot, } from "./package-bundles.js";
|
|
3
|
+
import { findInstalledBundle, readHostInstallView } from "./package-host-state.js";
|
|
4
|
+
import { packageSelectionFor, selectionKey, } from "./package-model.js";
|
|
5
|
+
import { sourceBundleScope } from "./package-content.js";
|
|
6
|
+
import { defaultPackageStateDirectory, PackageStateStore } from "./package-state.js";
|
|
7
|
+
import { createSourceContentAccess } from "./skill-discovery.js";
|
|
8
|
+
import { createSourceOperations, projectSourceOf } from "./sources.js";
|
|
9
|
+
/**
|
|
10
|
+
* The bundles that own their Skills. A bundle owns them only when it is active:
|
|
11
|
+
* a recorded selection names the user's choice, or a host state file proves the
|
|
12
|
+
* bundle installed. Source, host, and coordinates must all agree, and a bundle
|
|
13
|
+
* that is not installable never owns. An unreadable host state is `unknown`, not
|
|
14
|
+
* `installed`, so it never claims; the user's own selection still does.
|
|
15
|
+
*/
|
|
16
|
+
export function activeBundles(bundles, selections, hostView) {
|
|
17
|
+
const selectedKeys = new Set(selections.map(selectionKey));
|
|
18
|
+
const active = [];
|
|
19
|
+
for (const { source, descriptor } of bundles) {
|
|
20
|
+
if (!descriptor.installable) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const selection = packageSelectionFor(descriptor, source);
|
|
24
|
+
const selected = selectedKeys.has(selectionKey(selection));
|
|
25
|
+
const installed = findInstalledBundle(hostView, descriptor, selection).kind === "installed";
|
|
26
|
+
if (selected || installed) {
|
|
27
|
+
active.push({
|
|
28
|
+
descriptor,
|
|
29
|
+
selection,
|
|
30
|
+
reason: selected ? (installed ? "selected-and-installed" : "selected") : "installed",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze(active);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Composes ownership for descriptors a caller already derived from the snapshot
|
|
38
|
+
* pass that fed skill discovery, so no second walk or hand-synced list is
|
|
39
|
+
* needed. A Skill path matches a `skill-category` component; a bare directory
|
|
40
|
+
* name matches a Skill name that component declares.
|
|
41
|
+
*/
|
|
42
|
+
export function packageOwnedSkillMatcher(descriptors) {
|
|
43
|
+
const byPath = bundleOwnedSkillMatcher(descriptors);
|
|
44
|
+
const byName = bundleOwnedSkillNameMatcher(descriptors);
|
|
45
|
+
return (skillPath) => byPath(skillPath) ?? (skillPath.includes("/") ? undefined : byName(skillPath));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Reads one snapshot per Source and derives bundle ownership from the same
|
|
49
|
+
* pass discovery uses, then keeps only the bundles the user selected or a host
|
|
50
|
+
* proves installed. A Source or Package state that cannot be read warns and
|
|
51
|
+
* contributes no exclusions, so an unresolvable input never hides a Skill and a
|
|
52
|
+
* missing record never falsely claims one.
|
|
53
|
+
*/
|
|
54
|
+
export async function loadPackageOwnedSkillFilter(options = {}) {
|
|
55
|
+
const warn = options.reportWarning ?? (warning => process.emitWarning(warning));
|
|
56
|
+
let sources;
|
|
57
|
+
try {
|
|
58
|
+
sources = options.sources ?? await createSourceOperations({
|
|
59
|
+
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
60
|
+
}).listSources();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
warn(`WARNING: Package-owned Skill exclusions unavailable; no exclusions applied: ${errorMessage(error)}`);
|
|
64
|
+
return () => undefined;
|
|
65
|
+
}
|
|
66
|
+
const contentAccess = options.sourceContentAccess ?? createSourceContentAccess();
|
|
67
|
+
const bundles = [];
|
|
68
|
+
for (const source of sources) {
|
|
69
|
+
try {
|
|
70
|
+
const projectSource = projectSourceOf(source);
|
|
71
|
+
for (const descriptor of discoverBundlesInSnapshot(await contentAccess.readSnapshot(source), sourceBundleScope(source))) {
|
|
72
|
+
bundles.push({ source: projectSource, descriptor });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
warn(`WARNING: Skipping Package-owned Skill exclusions for Source ${source.id}: ${errorMessage(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return packageOwnedSkillMatcher(activeBundles(bundles, await loadSelections(options, warn), await loadHostView(options)).map(bundle => bundle.descriptor));
|
|
80
|
+
}
|
|
81
|
+
async function loadSelections(options, warn) {
|
|
82
|
+
if (options.selections !== undefined) {
|
|
83
|
+
return options.selections;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const records = await new PackageStateStore(options.stateDirectory ?? defaultPackageStateDirectory(process.env, process.platform, options.homeDirectory ?? homedir())).list();
|
|
87
|
+
return records.map(record => record.selection);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
warn(`WARNING: Package selection state unavailable; no Package-owned Skill exclusions applied: ${errorMessage(error)}`);
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function loadHostView(options) {
|
|
95
|
+
return options.hostView ?? await readHostInstallView({
|
|
96
|
+
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The recorded portable user-global installations a bundle's own
|
|
101
|
+
* `skill-category` components would duplicate, matched by Source and by
|
|
102
|
+
* Source-relative Skill path or bare Skill name. Empty when nothing overlaps.
|
|
103
|
+
* A caller refuses the Package lifecycle on any conflict instead of silently
|
|
104
|
+
* replacing or removing the portable copy.
|
|
105
|
+
*/
|
|
106
|
+
export function portableSkillConflicts(descriptor, source, installations) {
|
|
107
|
+
const owned = packageOwnedSkillMatcher([descriptor]);
|
|
108
|
+
return Object.freeze(installations.filter((installation) => sameSource(installation.source, source) && owned(installation.path) !== undefined));
|
|
109
|
+
}
|
|
110
|
+
function sameSource(left, right) {
|
|
111
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
112
|
+
}
|
|
113
|
+
/** Refuses a bundle-owned Skill path or name, naming the Package and its Host. */
|
|
114
|
+
export function assertSkillNotPackageOwned(skillPath, owned) {
|
|
115
|
+
const owner = owned(skillPath);
|
|
116
|
+
if (owner === undefined) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`Package-owned Skill ${JSON.stringify(skillPath)} is provided by the ${owner.host} Package ${JSON.stringify(owner.name)}; ` +
|
|
120
|
+
"it cannot be installed, adopted, or removed as a loose Skill, and no path was changed");
|
|
121
|
+
}
|
|
122
|
+
function errorMessage(error) {
|
|
123
|
+
return error instanceof Error ? error.message : String(error);
|
|
124
|
+
}
|