@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,122 @@
|
|
|
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
|
+
if (selectedKeys.has(selectionKey(selection))) {
|
|
25
|
+
active.push({ descriptor, reason: "selected" });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (findInstalledBundle(hostView, descriptor, selection).kind === "installed") {
|
|
29
|
+
active.push({ descriptor, reason: "installed" });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze(active);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Composes ownership for descriptors a caller already derived from the snapshot
|
|
36
|
+
* pass that fed skill discovery, so no second walk or hand-synced list is
|
|
37
|
+
* needed. A Skill path matches a `skill-category` component; a bare directory
|
|
38
|
+
* name matches a Skill name that component declares.
|
|
39
|
+
*/
|
|
40
|
+
export function packageOwnedSkillMatcher(descriptors) {
|
|
41
|
+
const byPath = bundleOwnedSkillMatcher(descriptors);
|
|
42
|
+
const byName = bundleOwnedSkillNameMatcher(descriptors);
|
|
43
|
+
return (skillPath) => byPath(skillPath) ?? (skillPath.includes("/") ? undefined : byName(skillPath));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads one snapshot per Source and derives bundle ownership from the same
|
|
47
|
+
* pass discovery uses, then keeps only the bundles the user selected or a host
|
|
48
|
+
* proves installed. A Source or Package state that cannot be read warns and
|
|
49
|
+
* contributes no exclusions, so an unresolvable input never hides a Skill and a
|
|
50
|
+
* missing record never falsely claims one.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadPackageOwnedSkillFilter(options = {}) {
|
|
53
|
+
const warn = options.reportWarning ?? (warning => process.emitWarning(warning));
|
|
54
|
+
let sources;
|
|
55
|
+
try {
|
|
56
|
+
sources = options.sources ?? await createSourceOperations({
|
|
57
|
+
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
58
|
+
}).listSources();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
warn(`WARNING: Package-owned Skill exclusions unavailable; no exclusions applied: ${errorMessage(error)}`);
|
|
62
|
+
return () => undefined;
|
|
63
|
+
}
|
|
64
|
+
const contentAccess = options.sourceContentAccess ?? createSourceContentAccess();
|
|
65
|
+
const bundles = [];
|
|
66
|
+
for (const source of sources) {
|
|
67
|
+
try {
|
|
68
|
+
const projectSource = projectSourceOf(source);
|
|
69
|
+
for (const descriptor of discoverBundlesInSnapshot(await contentAccess.readSnapshot(source), sourceBundleScope(source))) {
|
|
70
|
+
bundles.push({ source: projectSource, descriptor });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
warn(`WARNING: Skipping Package-owned Skill exclusions for Source ${source.id}: ${errorMessage(error)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return packageOwnedSkillMatcher(activeBundles(bundles, await loadSelections(options, warn), await loadHostView(options)).map(bundle => bundle.descriptor));
|
|
78
|
+
}
|
|
79
|
+
async function loadSelections(options, warn) {
|
|
80
|
+
if (options.selections !== undefined) {
|
|
81
|
+
return options.selections;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const records = await new PackageStateStore(options.stateDirectory ?? defaultPackageStateDirectory(process.env, process.platform, options.homeDirectory ?? homedir())).list();
|
|
85
|
+
return records.map(record => record.selection);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
warn(`WARNING: Package selection state unavailable; no Package-owned Skill exclusions applied: ${errorMessage(error)}`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function loadHostView(options) {
|
|
93
|
+
return options.hostView ?? await readHostInstallView({
|
|
94
|
+
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The recorded portable user-global installations a bundle's own
|
|
99
|
+
* `skill-category` components would duplicate, matched by Source and by
|
|
100
|
+
* Source-relative Skill path or bare Skill name. Empty when nothing overlaps.
|
|
101
|
+
* A caller refuses the Package lifecycle on any conflict instead of silently
|
|
102
|
+
* replacing or removing the portable copy.
|
|
103
|
+
*/
|
|
104
|
+
export function portableSkillConflicts(descriptor, source, installations) {
|
|
105
|
+
const owned = packageOwnedSkillMatcher([descriptor]);
|
|
106
|
+
return Object.freeze(installations.filter((installation) => sameSource(installation.source, source) && owned(installation.path) !== undefined));
|
|
107
|
+
}
|
|
108
|
+
function sameSource(left, right) {
|
|
109
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
110
|
+
}
|
|
111
|
+
/** Refuses a bundle-owned Skill path or name, naming the Package and its Host. */
|
|
112
|
+
export function assertSkillNotPackageOwned(skillPath, owned) {
|
|
113
|
+
const owner = owned(skillPath);
|
|
114
|
+
if (owner === undefined) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Package-owned Skill ${JSON.stringify(skillPath)} is provided by the ${owner.host} Package ${JSON.stringify(owner.name)}; ` +
|
|
118
|
+
"it cannot be installed, adopted, or removed as a loose Skill, and no path was changed");
|
|
119
|
+
}
|
|
120
|
+
function errorMessage(error) {
|
|
121
|
+
return error instanceof Error ? error.message : String(error);
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|
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) {
|