@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,404 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rmdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
|
+
import { PACKAGE_HOSTS, selectionKey, } from "./package-model.js";
|
|
7
|
+
import { PROJECT_MANIFEST_VERSION, ProjectManifestError, parseProjectManifest } from "./project-manifest.js";
|
|
8
|
+
import { defaultSourceStatePath } from "./source-state.js";
|
|
9
|
+
export class PackageStateError extends Error {
|
|
10
|
+
constructor(statePath, reason) {
|
|
11
|
+
super(`Package state at ${JSON.stringify(statePath)} is invalid: ${reason}. The file was not modified; inspect or repair it before retrying.`);
|
|
12
|
+
this.name = "PackageStateError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class PackageStateLockError extends Error {
|
|
16
|
+
constructor(statePath) {
|
|
17
|
+
super(`Package state at ${JSON.stringify(statePath)} is busy; no changes were made. Retry after the other process finishes.`);
|
|
18
|
+
this.name = "PackageStateLockError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Raised before any filesystem call when a digest cannot name a receipt file. */
|
|
22
|
+
export class PackageApprovalDigestError extends Error {
|
|
23
|
+
constructor(digest) {
|
|
24
|
+
super(`Approval digest ${JSON.stringify(digest)} is not a SHA-256 hex digest; no receipt was read or written.`);
|
|
25
|
+
this.name = "PackageApprovalDigestError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const EMPTY_STATE = Object.freeze({ version: 1, packages: Object.freeze([]) });
|
|
29
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
30
|
+
const LOCK_RETRY_MS = 10;
|
|
31
|
+
const APPROVAL_DIGEST = /^[0-9a-fA-F]{64}$/u;
|
|
32
|
+
const SELECTION_KEYS = {
|
|
33
|
+
pi: ["host", "root", "source", "version"],
|
|
34
|
+
claude: ["host", "marketplaceRoot", "pluginName", "source", "version"],
|
|
35
|
+
};
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function containsControlCharacter(value) {
|
|
40
|
+
return Array.from(value).some((character) => (character.codePointAt(0) ?? 0) <= 0x1f || character.codePointAt(0) === 0x7f);
|
|
41
|
+
}
|
|
42
|
+
function assertKeys(value, allowed, label, statePath) {
|
|
43
|
+
const allowedKeys = new Set(allowed);
|
|
44
|
+
const unsupported = Object.keys(value).find((key) => !allowedKeys.has(key));
|
|
45
|
+
if (unsupported) {
|
|
46
|
+
throw new PackageStateError(statePath, `${label} contains unsupported field ${JSON.stringify(unsupported)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function nonEmptyString(value, label, statePath) {
|
|
50
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
|
|
51
|
+
throw new PackageStateError(statePath, `${label} must be a non-empty string without surrounding whitespace`);
|
|
52
|
+
}
|
|
53
|
+
if (/\s/u.test(value) || containsControlCharacter(value)) {
|
|
54
|
+
throw new PackageStateError(statePath, `${label} contains unsafe whitespace or control characters`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function timestamp(value, label, statePath) {
|
|
59
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) {
|
|
60
|
+
throw new PackageStateError(statePath, `${label} must be an ISO-8601 UTC timestamp`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
/** A Source-relative directory in the one canonical form discovery produces. */
|
|
65
|
+
function sourceDirectory(value, label, statePath) {
|
|
66
|
+
if (typeof value !== "string") {
|
|
67
|
+
throw new PackageStateError(statePath, `${label} must be a string`);
|
|
68
|
+
}
|
|
69
|
+
if (value !== "" && (value === "." || value.startsWith("./") || value.endsWith("/") || value.includes("//") || value.split("/").includes("."))) {
|
|
70
|
+
throw new PackageStateError(statePath, `${label} must be the canonical Source-relative directory, "" for the Source root`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function pluginName(value, label, statePath) {
|
|
75
|
+
const name = nonEmptyString(value, label, statePath);
|
|
76
|
+
if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
77
|
+
throw new PackageStateError(statePath, `${label} must be one path segment`);
|
|
78
|
+
}
|
|
79
|
+
return name;
|
|
80
|
+
}
|
|
81
|
+
function commitId(value, label, statePath) {
|
|
82
|
+
const candidate = nonEmptyString(value, label, statePath);
|
|
83
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(candidate)) {
|
|
84
|
+
throw new PackageStateError(statePath, `${label} must be a 40- or 64-character lowercase commit ID`);
|
|
85
|
+
}
|
|
86
|
+
return candidate;
|
|
87
|
+
}
|
|
88
|
+
function parseCoordinates(value, label, statePath) {
|
|
89
|
+
if (value.host !== "pi" && value.host !== "claude") {
|
|
90
|
+
const hosts = PACKAGE_HOSTS.map((host) => JSON.stringify(host)).join(" or ");
|
|
91
|
+
throw new PackageStateError(statePath, `${label}.host must be ${hosts}`);
|
|
92
|
+
}
|
|
93
|
+
assertKeys(value, SELECTION_KEYS[value.host], label, statePath);
|
|
94
|
+
if (value.host === "pi") {
|
|
95
|
+
const root = sourceDirectory(value.root, `${label}.root`, statePath);
|
|
96
|
+
return {
|
|
97
|
+
coordinates: Object.freeze({ host: "pi", root }),
|
|
98
|
+
manifestPath: root === "" ? "package.json" : `${root}/package.json`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const marketplaceRoot = sourceDirectory(value.marketplaceRoot, `${label}.marketplaceRoot`, statePath);
|
|
102
|
+
const name = pluginName(value.pluginName, `${label}.pluginName`, statePath);
|
|
103
|
+
return {
|
|
104
|
+
coordinates: Object.freeze({ host: "claude", marketplaceRoot, pluginName: name }),
|
|
105
|
+
manifestPath: marketplaceRoot === ""
|
|
106
|
+
? ".claude-plugin/marketplace.json"
|
|
107
|
+
: `${marketplaceRoot}/.claude-plugin/marketplace.json`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function parseVersionEvidence(value, label, statePath) {
|
|
111
|
+
if (!isRecord(value)) {
|
|
112
|
+
throw new PackageStateError(statePath, `${label} must be an object`);
|
|
113
|
+
}
|
|
114
|
+
if (value.kind === "manifest-version") {
|
|
115
|
+
assertKeys(value, ["kind", "version", "declaredBy"], label, statePath);
|
|
116
|
+
const version = nonEmptyString(value.version, `${label}.version`, statePath);
|
|
117
|
+
if (value.declaredBy !== "bundle" && value.declaredBy !== "marketplace-entry") {
|
|
118
|
+
throw new PackageStateError(statePath, `${label}.declaredBy must be "bundle" or "marketplace-entry"`);
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze({ kind: "manifest-version", version, declaredBy: value.declaredBy });
|
|
121
|
+
}
|
|
122
|
+
if (value.kind === "git-commit") {
|
|
123
|
+
assertKeys(value, ["kind", "commit"], label, statePath);
|
|
124
|
+
return Object.freeze({ kind: "git-commit", commit: commitId(value.commit, `${label}.commit`, statePath) });
|
|
125
|
+
}
|
|
126
|
+
throw new PackageStateError(statePath, `${label}.kind must be "manifest-version" or "git-commit"`);
|
|
127
|
+
}
|
|
128
|
+
function parseSelection(value, label, statePath) {
|
|
129
|
+
if (!isRecord(value)) {
|
|
130
|
+
throw new PackageStateError(statePath, `${label} must be an object`);
|
|
131
|
+
}
|
|
132
|
+
const { coordinates, manifestPath } = parseCoordinates(value, label, statePath);
|
|
133
|
+
try {
|
|
134
|
+
// The manifest path is a probe, discarded after parsing. It reuses the one
|
|
135
|
+
// parser that owns Source canonicalization, ref safety, and the pin rules,
|
|
136
|
+
// and its containment rule rejects a root that escapes the Source.
|
|
137
|
+
const [parsed] = parseProjectManifest({
|
|
138
|
+
version: PROJECT_MANIFEST_VERSION,
|
|
139
|
+
skills: [{ source: value.source, path: manifestPath, version: value.version, hosts: [coordinates.host] }],
|
|
140
|
+
}, statePath).skills;
|
|
141
|
+
return Object.freeze({ ...coordinates, source: parsed.source, version: parsed.version });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const field = coordinates.host === "pi" ? "root" : "marketplaceRoot";
|
|
145
|
+
const reason = error instanceof ProjectManifestError
|
|
146
|
+
? error.reason.replace(/^skills\[0\]\.path/u, `${label}.${field}`).replace(/^skills\[0\]\./u, `${label}.`)
|
|
147
|
+
: error instanceof Error ? error.message : "the selection is invalid";
|
|
148
|
+
throw new PackageStateError(statePath, `${label} is invalid (${reason})`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function parseInstalledPackage(value, index, statePath) {
|
|
152
|
+
const label = `packages[${index}]`;
|
|
153
|
+
if (!isRecord(value)) {
|
|
154
|
+
throw new PackageStateError(statePath, `${label} must be an object`);
|
|
155
|
+
}
|
|
156
|
+
assertKeys(value, ["selection", "installId", "lastDelegatedCommit", "verified", "verifiedAt"], label, statePath);
|
|
157
|
+
const selection = parseSelection(value.selection, `${label}.selection`, statePath);
|
|
158
|
+
const installId = nonEmptyString(value.installId, `${label}.installId`, statePath);
|
|
159
|
+
const lastDelegatedCommit = value.lastDelegatedCommit === undefined
|
|
160
|
+
? undefined
|
|
161
|
+
: commitId(value.lastDelegatedCommit, `${label}.lastDelegatedCommit`, statePath);
|
|
162
|
+
const verified = value.verified === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: parseVersionEvidence(value.verified, `${label}.verified`, statePath);
|
|
165
|
+
const verifiedAt = timestamp(value.verifiedAt, `${label}.verifiedAt`, statePath);
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
selection,
|
|
168
|
+
installId,
|
|
169
|
+
...(lastDelegatedCommit === undefined ? {} : { lastDelegatedCommit }),
|
|
170
|
+
...(verified === undefined ? {} : { verified }),
|
|
171
|
+
verifiedAt,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function parseState(value, statePath) {
|
|
175
|
+
if (!isRecord(value)) {
|
|
176
|
+
throw new PackageStateError(statePath, "the root value must be an object");
|
|
177
|
+
}
|
|
178
|
+
assertKeys(value, ["version", "packages"], "the state", statePath);
|
|
179
|
+
if (value.version !== 1) {
|
|
180
|
+
throw new PackageStateError(statePath, "the state version is unsupported");
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(value.packages)) {
|
|
183
|
+
throw new PackageStateError(statePath, "packages must be an array");
|
|
184
|
+
}
|
|
185
|
+
const keys = new Set();
|
|
186
|
+
const packages = value.packages.map((item, index) => {
|
|
187
|
+
const record = parseInstalledPackage(item, index, statePath);
|
|
188
|
+
const key = selectionKey(record.selection);
|
|
189
|
+
if (keys.has(key)) {
|
|
190
|
+
throw new PackageStateError(statePath, `packages[${index}] duplicates another selection`);
|
|
191
|
+
}
|
|
192
|
+
keys.add(key);
|
|
193
|
+
return record;
|
|
194
|
+
});
|
|
195
|
+
packages.sort((left, right) => compareKeys(selectionKey(left.selection), selectionKey(right.selection)));
|
|
196
|
+
return Object.freeze({ version: 1, packages: Object.freeze(packages) });
|
|
197
|
+
}
|
|
198
|
+
function compareKeys(left, right) {
|
|
199
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
200
|
+
}
|
|
201
|
+
function sameCoordinates(left, right) {
|
|
202
|
+
if (left.host === "pi" && right.host === "pi") {
|
|
203
|
+
return left.root === right.root;
|
|
204
|
+
}
|
|
205
|
+
if (left.host === "claude" && right.host === "claude") {
|
|
206
|
+
return left.marketplaceRoot === right.marketplaceRoot && left.pluginName === right.pluginName;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
function applyChanges(state, changes) {
|
|
211
|
+
const verifiedAt = new Date().toISOString();
|
|
212
|
+
const records = new Map(state.packages.map((record) => [selectionKey(record.selection), record]));
|
|
213
|
+
let changed = false;
|
|
214
|
+
for (const change of changes) {
|
|
215
|
+
if (change.kind === "upsert") {
|
|
216
|
+
records.set(selectionKey(change.record.selection), Object.freeze({ ...change.record, verifiedAt }));
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
else if (records.delete(selectionKey(change.selection))) {
|
|
220
|
+
changed = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!changed) {
|
|
224
|
+
return { state, changed: false };
|
|
225
|
+
}
|
|
226
|
+
const packages = [...records.values()];
|
|
227
|
+
packages.sort((left, right) => compareKeys(selectionKey(left.selection), selectionKey(right.selection)));
|
|
228
|
+
return { state: Object.freeze({ version: 1, packages: Object.freeze(packages) }), changed: true };
|
|
229
|
+
}
|
|
230
|
+
function normalizeApprovalDigest(digest) {
|
|
231
|
+
if (typeof digest !== "string" || !APPROVAL_DIGEST.test(digest)) {
|
|
232
|
+
throw new PackageApprovalDigestError(digest);
|
|
233
|
+
}
|
|
234
|
+
return digest.toLowerCase();
|
|
235
|
+
}
|
|
236
|
+
function isApprovedReceipt(contents, digest) {
|
|
237
|
+
let value;
|
|
238
|
+
try {
|
|
239
|
+
value = JSON.parse(contents);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (!isRecord(value)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (Object.keys(value).some((key) => key !== "version" && key !== "digest")) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
// The stored digest must agree with the filename, so a copied or renamed
|
|
251
|
+
// receipt cannot approve a declaration it never covered.
|
|
252
|
+
return value.version === 1 && value.digest === digest;
|
|
253
|
+
}
|
|
254
|
+
export function defaultPackageStateDirectory(environment = process.env, platform = process.platform, homeDirectory = homedir()) {
|
|
255
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
256
|
+
return pathApi.dirname(defaultSourceStatePath(environment, platform, homeDirectory));
|
|
257
|
+
}
|
|
258
|
+
/** The one writer for `packages.json` and the approval receipts beside it. */
|
|
259
|
+
export class PackageStateStore {
|
|
260
|
+
path;
|
|
261
|
+
approvalsDirectory;
|
|
262
|
+
constructor(stateDirectory = defaultPackageStateDirectory()) {
|
|
263
|
+
this.path = path.join(stateDirectory, "packages.json");
|
|
264
|
+
this.approvalsDirectory = path.join(stateDirectory, "package-approvals");
|
|
265
|
+
}
|
|
266
|
+
/** Recorded selections in selection-key order. A missing file is an empty list. */
|
|
267
|
+
async list() {
|
|
268
|
+
const release = await this.acquireLock();
|
|
269
|
+
try {
|
|
270
|
+
return (await this.loadUnlocked()).packages;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await release();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async find(selection) {
|
|
277
|
+
const key = selectionKey(this.normalizeSelection(selection));
|
|
278
|
+
return (await this.list()).find((record) => selectionKey(record.selection) === key);
|
|
279
|
+
}
|
|
280
|
+
/** Records whose selection carries these coordinates, for a plan's affected set. */
|
|
281
|
+
async findAffected(coordinates) {
|
|
282
|
+
return (await this.list()).filter((record) => sameCoordinates(record.selection, coordinates));
|
|
283
|
+
}
|
|
284
|
+
async upsert(record) {
|
|
285
|
+
const normalized = this.normalizeRecord(record);
|
|
286
|
+
await this.update((state) => applyChanges(state, [{ kind: "upsert", record: normalized }]));
|
|
287
|
+
}
|
|
288
|
+
async drop(selection) {
|
|
289
|
+
const normalized = this.normalizeSelection(selection);
|
|
290
|
+
await this.update((state) => applyChanges(state, [{ kind: "drop", selection: normalized }]));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Applies keyed changes in one locked window, so a multi-record outcome commits
|
|
294
|
+
* at once. The list is decided before the lock, so a concurrent writer for the
|
|
295
|
+
* same selection is last-writer-wins rather than merged.
|
|
296
|
+
*/
|
|
297
|
+
async reconcile(changes) {
|
|
298
|
+
if (changes.length === 0) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const normalized = changes.map((change) => change.kind === "upsert"
|
|
302
|
+
? { kind: "upsert", record: this.normalizeRecord(change.record) }
|
|
303
|
+
: { kind: "drop", selection: this.normalizeSelection(change.selection) });
|
|
304
|
+
await this.update((state) => applyChanges(state, normalized));
|
|
305
|
+
}
|
|
306
|
+
/** Writes the receipt for a digest that `packageApprovalDigest` already produced. */
|
|
307
|
+
async approve(digest) {
|
|
308
|
+
const approvalDigest = normalizeApprovalDigest(digest);
|
|
309
|
+
const receipt = `${JSON.stringify({ version: 1, digest: approvalDigest }, null, 2)}\n`;
|
|
310
|
+
await mkdir(this.approvalsDirectory, { recursive: true, mode: 0o700 });
|
|
311
|
+
await chmod(this.approvalsDirectory, 0o700);
|
|
312
|
+
await writeFileAtomically(path.join(this.approvalsDirectory, `${approvalDigest}.json`), receipt, { mode: 0o600 });
|
|
313
|
+
}
|
|
314
|
+
/** Reads a receipt. A missing, malformed, or mismatched receipt needs approval again. */
|
|
315
|
+
async approvalStatus(digest) {
|
|
316
|
+
const approvalDigest = normalizeApprovalDigest(digest);
|
|
317
|
+
let contents;
|
|
318
|
+
try {
|
|
319
|
+
contents = await readFile(path.join(this.approvalsDirectory, `${approvalDigest}.json`), "utf8");
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (isMissingFile(error)) {
|
|
323
|
+
return "needs approval";
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
return isApprovedReceipt(contents, approvalDigest) ? "approved" : "needs approval";
|
|
328
|
+
}
|
|
329
|
+
normalizeSelection(selection) {
|
|
330
|
+
// One canonical spelling per Source, so the key derived here matches the key
|
|
331
|
+
// derived again when the same record is parsed back off disk.
|
|
332
|
+
return parseSelection(selection, "selection", this.path);
|
|
333
|
+
}
|
|
334
|
+
normalizeRecord(record) {
|
|
335
|
+
return { ...record, selection: this.normalizeSelection(record.selection) };
|
|
336
|
+
}
|
|
337
|
+
async update(mutate) {
|
|
338
|
+
const release = await this.acquireLock();
|
|
339
|
+
try {
|
|
340
|
+
const update = mutate(await this.loadUnlocked());
|
|
341
|
+
if (!update.changed) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
await this.saveUnlocked(update.state);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await release();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async loadUnlocked() {
|
|
351
|
+
let contents;
|
|
352
|
+
try {
|
|
353
|
+
contents = await readFile(this.path, "utf8");
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (isMissingFile(error)) {
|
|
357
|
+
return EMPTY_STATE;
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
let value;
|
|
362
|
+
try {
|
|
363
|
+
value = JSON.parse(contents);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
const reason = error instanceof Error ? error.message : "the JSON could not be parsed";
|
|
367
|
+
throw new PackageStateError(this.path, `malformed JSON (${reason})`);
|
|
368
|
+
}
|
|
369
|
+
return parseState(value, this.path);
|
|
370
|
+
}
|
|
371
|
+
async saveUnlocked(state) {
|
|
372
|
+
const normalized = parseState(state, this.path);
|
|
373
|
+
await mkdir(path.dirname(this.path), { recursive: true });
|
|
374
|
+
await writeFileAtomically(this.path, `${JSON.stringify(normalized, null, 2)}\n`, { allowLockedReplaceFallback: true });
|
|
375
|
+
}
|
|
376
|
+
async acquireLock() {
|
|
377
|
+
const lockPath = `${this.path}.lock`;
|
|
378
|
+
await mkdir(path.dirname(this.path), { recursive: true });
|
|
379
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
380
|
+
while (true) {
|
|
381
|
+
try {
|
|
382
|
+
await mkdir(lockPath);
|
|
383
|
+
return async () => {
|
|
384
|
+
await rmdir(lockPath);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (!isAlreadyExists(error)) {
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
if (Date.now() >= deadline) {
|
|
392
|
+
throw new PackageStateLockError(this.path);
|
|
393
|
+
}
|
|
394
|
+
await delay(LOCK_RETRY_MS);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function isMissingFile(error) {
|
|
400
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
401
|
+
}
|
|
402
|
+
function isAlreadyExists(error) {
|
|
403
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
404
|
+
}
|
|
@@ -12,6 +12,12 @@ export function runProcess(command, args, options = {}) {
|
|
|
12
12
|
let stderr = "";
|
|
13
13
|
let stderrBytes = 0;
|
|
14
14
|
let outputTooLarge = false;
|
|
15
|
+
let timedOut = false;
|
|
16
|
+
const timeout = options.timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
17
|
+
timedOut = true;
|
|
18
|
+
child.kill();
|
|
19
|
+
}, options.timeoutMs);
|
|
20
|
+
timeout?.unref();
|
|
15
21
|
child.stdout?.on("data", (chunk) => {
|
|
16
22
|
stdoutBytes += chunk.length;
|
|
17
23
|
if (options.maxOutputBytes !== undefined && stdoutBytes > options.maxOutputBytes) {
|
|
@@ -35,7 +41,9 @@ export function runProcess(command, args, options = {}) {
|
|
|
35
41
|
});
|
|
36
42
|
child.on("error", reject);
|
|
37
43
|
child.on("close", (code, signal) => {
|
|
38
|
-
|
|
44
|
+
if (timeout !== undefined)
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, outputTooLarge, ...(timedOut ? { timedOut: true } : {}) });
|
|
39
47
|
});
|
|
40
48
|
});
|
|
41
49
|
}
|
package/dist/src/profile-cli.js
CHANGED
|
@@ -3,11 +3,15 @@ import { buildProfileExport, requireProfileExportSelection, writeProfileExport }
|
|
|
3
3
|
import { applyProfileImport, buildProfileImport } from "./profile-import.js";
|
|
4
4
|
import { serializeProfile } from "./profile.js";
|
|
5
5
|
import { CliUsageError } from "./usage-error.js";
|
|
6
|
-
export const EXPORT_USAGE = "agent-depot export [--out <file>] [--no-sources] [--no-skills] [--no-apps] [--source <id|url>...] [--skill <path|name>...] [--app <name>...]";
|
|
7
|
-
export const IMPORT_USAGE = "agent-depot import <file> [--yes] [--no-sources] [--no-skills] [--no-apps] [--source <id|url>...] [--skill <path|name>...] [--app <name>...]";
|
|
8
|
-
export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory) {
|
|
6
|
+
export const EXPORT_USAGE = "agent-depot export [--out <file>] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
|
|
7
|
+
export const IMPORT_USAGE = "agent-depot import <file> [--yes] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
|
|
8
|
+
export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory, packageOperations) {
|
|
9
9
|
const { filters, out } = parseProfileOptions(values, false);
|
|
10
|
-
const plan = await buildProfileExport(operations, apps, {
|
|
10
|
+
const plan = await buildProfileExport(operations, apps, {
|
|
11
|
+
...filters,
|
|
12
|
+
homeDirectory,
|
|
13
|
+
...(packageOperations === undefined ? {} : { packageOperations }),
|
|
14
|
+
});
|
|
11
15
|
for (const line of plan.preview)
|
|
12
16
|
diagnostic(line);
|
|
13
17
|
requireProfileExportSelection(plan.profile);
|
|
@@ -21,8 +25,8 @@ export async function runProfileExport(values, operations, apps, output, diagnos
|
|
|
21
25
|
return 0;
|
|
22
26
|
}
|
|
23
27
|
function parseProfileOptions(values, importing) {
|
|
24
|
-
const sources = [], skills = [], appNames = [];
|
|
25
|
-
const filters = { sources, skills, apps: appNames };
|
|
28
|
+
const sources = [], skills = [], appNames = [], packageNames = [];
|
|
29
|
+
const filters = { sources, skills, apps: appNames, packages: packageNames };
|
|
26
30
|
let out;
|
|
27
31
|
let confirmed = false;
|
|
28
32
|
for (let index = 0; index < values.length; index++) {
|
|
@@ -38,7 +42,9 @@ function parseProfileOptions(values, importing) {
|
|
|
38
42
|
filters.noSkills = true;
|
|
39
43
|
else if (option === "--no-apps")
|
|
40
44
|
filters.noApps = true;
|
|
41
|
-
else if (
|
|
45
|
+
else if (option === "--no-packages")
|
|
46
|
+
filters.noPackages = true;
|
|
47
|
+
else if (["--out", "--source", "--skill", "--app", "--package"].includes(option)) {
|
|
42
48
|
const value = values[++index];
|
|
43
49
|
if (!value || value.startsWith("--"))
|
|
44
50
|
throw new CliUsageError(`${option} requires a value`);
|
|
@@ -50,7 +56,7 @@ function parseProfileOptions(values, importing) {
|
|
|
50
56
|
out = value;
|
|
51
57
|
}
|
|
52
58
|
else {
|
|
53
|
-
const names = option === "--source" ? sources : option === "--skill" ? skills : appNames;
|
|
59
|
+
const names = option === "--source" ? sources : option === "--skill" ? skills : option === "--app" ? appNames : packageNames;
|
|
54
60
|
if (names.includes(value))
|
|
55
61
|
throw new CliUsageError(`Duplicate ${option} ${JSON.stringify(value)}`);
|
|
56
62
|
names.push(value);
|
|
@@ -74,7 +80,7 @@ export async function runProfileImport(values, operations, apps, output, environ
|
|
|
74
80
|
catch (error) {
|
|
75
81
|
throw new Error(`${file}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
76
82
|
}
|
|
77
|
-
const plan = await buildProfileImport(input, operations, apps, filters);
|
|
83
|
+
const plan = await buildProfileImport(input, operations, apps, filters, environment.packageOperations);
|
|
78
84
|
for (const line of plan.preview)
|
|
79
85
|
output(line);
|
|
80
86
|
if (!confirmed) {
|
|
@@ -2,8 +2,10 @@ import { writeFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { loadAppOwnedSkillFilter } from "./app-owned-skills.js";
|
|
4
4
|
import { sourceIdForUrl } from "./git-source.js";
|
|
5
|
+
import { bundleRoot, describeCoordinates } from "./package-model.js";
|
|
5
6
|
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
6
7
|
import { parseProfile, PROFILE_FORMAT, serializeProfile } from "./profile.js";
|
|
8
|
+
import { formatVersionPolicy } from "./skill-install.js";
|
|
7
9
|
import { scanUserGlobalSkillInventory } from "./user-global-skill-inventory.js";
|
|
8
10
|
export function selectProfileItems(items, requested, disabled, names, block) {
|
|
9
11
|
if (disabled && requested?.length)
|
|
@@ -14,6 +16,24 @@ export function selectProfileItems(items, requested, disabled, names, block) {
|
|
|
14
16
|
}
|
|
15
17
|
return disabled ? [] : items.filter(item => !requested?.length || names(item).some(name => requested.includes(name)));
|
|
16
18
|
}
|
|
19
|
+
/** The Package identities a `--package` filter accepts, matching `describeCoordinates`. */
|
|
20
|
+
export function packageFilterNames(selection) {
|
|
21
|
+
const root = bundleRoot(selection);
|
|
22
|
+
const names = [describeCoordinates(selection)];
|
|
23
|
+
if (root !== "") {
|
|
24
|
+
names.push(root);
|
|
25
|
+
const basename = path.posix.basename(root);
|
|
26
|
+
if (basename !== root)
|
|
27
|
+
names.push(basename);
|
|
28
|
+
}
|
|
29
|
+
if (selection.host === "claude")
|
|
30
|
+
names.push(selection.pluginName);
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
/** One Package line, shared by the export preview and the import plan label. */
|
|
34
|
+
export function describeProfilePackage(selection) {
|
|
35
|
+
return `Package ${describeCoordinates(selection)} (${formatVersionPolicy(selection.version)})`;
|
|
36
|
+
}
|
|
17
37
|
function validateOne(block, item) {
|
|
18
38
|
const profile = parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
19
39
|
sources: [], skills: [], apps: [], [block]: [item] });
|
|
@@ -34,6 +54,11 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
34
54
|
const allInstallations = await operations.listUserGlobalInstallations();
|
|
35
55
|
const installations = selectProfileItems(allInstallations, options.skills, options.noSkills, skill => [skill.path, path.posix.basename(skill.path)], "skills");
|
|
36
56
|
const entries = selectProfileItems(await apps.load(), options.apps, options.noApps, entry => [entry.recipe?.name ?? path.basename(entry.file)], "apps");
|
|
57
|
+
const packageRecords = options.packageOperations === undefined ? [] : await options.packageOperations.list();
|
|
58
|
+
if (options.packageOperations === undefined && options.packages?.length) {
|
|
59
|
+
throw new Error("Profile export requires Package operations to select Packages");
|
|
60
|
+
}
|
|
61
|
+
const selectedPackages = selectProfileItems(packageRecords, options.packages, options.noPackages, record => packageFilterNames(record.selection), "packages");
|
|
37
62
|
const owned = await loadAppOwnedSkillFilter({ appEnvironment: { recipesDirectory: apps.directory },
|
|
38
63
|
reportWarning: warn });
|
|
39
64
|
const portableSources = [];
|
|
@@ -79,6 +104,15 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
79
104
|
exclude("apps", `App recipe ${path.basename(entry.file)}`, error instanceof Error ? error.message : String(error));
|
|
80
105
|
}
|
|
81
106
|
}
|
|
107
|
+
const packages = [];
|
|
108
|
+
for (const record of selectedPackages) {
|
|
109
|
+
try {
|
|
110
|
+
packages.push(validateOne("packages", record.selection));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
exclude("packages", describeProfilePackage(record.selection), `not portable (${error instanceof Error ? error.message : String(error)})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
82
116
|
if (!options.noSkills) {
|
|
83
117
|
const inventory = await scanUserGlobalSkillInventory({
|
|
84
118
|
homeDirectory: options.homeDirectory, managedInstallations: allInstallations,
|
|
@@ -89,7 +123,7 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
89
123
|
}
|
|
90
124
|
}
|
|
91
125
|
const profile = parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
92
|
-
sources: portableSources, skills, apps: recipes });
|
|
126
|
+
sources: portableSources, skills, apps: recipes, packages });
|
|
93
127
|
return {
|
|
94
128
|
profile,
|
|
95
129
|
exclusions: diagnostics.filter((item) => "block" in item),
|
|
@@ -98,13 +132,13 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
98
132
|
};
|
|
99
133
|
}
|
|
100
134
|
function sourceWarnings(profile) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
135
|
+
const missing = (source, label) => source.kind === "external" && "url" in source && !profile.sources.some(candidate => candidate.url === source.url)
|
|
136
|
+
? [{ kind: "source", message: `WARNING: ${label} references Source ${source.url} not included in profile` }]
|
|
137
|
+
: [];
|
|
138
|
+
return [
|
|
139
|
+
...profile.skills.flatMap(skill => missing(skill.source, `Skill ${skill.path}`)),
|
|
140
|
+
...profile.packages.flatMap(selection => missing(selection.source, describeProfilePackage(selection))),
|
|
141
|
+
];
|
|
108
142
|
}
|
|
109
143
|
/** Shared preview for a frontend-selected subset; Source warnings are recomputed. */
|
|
110
144
|
export function profileExportPreview(profile, diagnostics = []) {
|
|
@@ -112,12 +146,13 @@ export function profileExportPreview(profile, diagnostics = []) {
|
|
|
112
146
|
...profile.sources.map(source => `Source ${sourceIdForUrl(source.url)} ${source.url} (included: ${source.included})`),
|
|
113
147
|
...profile.skills.map(skill => `Skill ${skill.path} [${skill.hosts.join(", ")}] (${skill.version.policy})`),
|
|
114
148
|
...profile.apps.map(recipe => `App recipe ${recipe.name} (${recipe.platform ?? "all platforms"}; no approval)`),
|
|
149
|
+
...profile.packages.map(selection => `${describeProfilePackage(selection)}; no receipt, no installed state`),
|
|
115
150
|
...diagnostics.flatMap(item => "block" in item ? [`Excluded ${item.label}: ${item.reason}`] : item.kind === "load" ? [item.message] : []),
|
|
116
151
|
...sourceWarnings(profile).map(warning => warning.message)];
|
|
117
152
|
}
|
|
118
153
|
/** Empty profiles are valid to parse, but are not useful export artifacts. */
|
|
119
154
|
export function requireProfileExportSelection(profile) {
|
|
120
|
-
if (!profile.sources.length && !profile.skills.length && !profile.apps.length) {
|
|
155
|
+
if (!profile.sources.length && !profile.skills.length && !profile.apps.length && !profile.packages.length) {
|
|
121
156
|
throw new Error("Nothing selected; nothing written.");
|
|
122
157
|
}
|
|
123
158
|
}
|