@iseoane/agent-depot 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.es.md +969 -0
  3. package/README.md +254 -25
  4. package/dist/src/app-flow.js +52 -51
  5. package/dist/src/app-recipe-removal.js +63 -0
  6. package/dist/src/cli.js +122 -11
  7. package/dist/src/executable-resolution.js +54 -0
  8. package/dist/src/git-source.js +129 -6
  9. package/dist/src/package-bundles.js +614 -0
  10. package/dist/src/package-cli.js +412 -0
  11. package/dist/src/package-content.js +158 -0
  12. package/dist/src/package-flow.js +636 -0
  13. package/dist/src/package-host-plans.js +240 -0
  14. package/dist/src/package-host-state.js +349 -0
  15. package/dist/src/package-model.js +33 -0
  16. package/dist/src/package-owned-skills.js +122 -0
  17. package/dist/src/package-state.js +404 -0
  18. package/dist/src/profile-cli.js +15 -9
  19. package/dist/src/profile-export.js +44 -9
  20. package/dist/src/profile-import.js +80 -17
  21. package/dist/src/profile.js +78 -3
  22. package/dist/src/skill-discovery.js +2 -2
  23. package/dist/src/skill-install.js +16 -0
  24. package/dist/src/sources.js +11 -0
  25. package/dist/src/tui/app-actions.js +1 -1
  26. package/dist/src/tui/app.js +5 -6
  27. package/dist/src/tui/catalog-browse.js +10 -3
  28. package/dist/src/tui/catalog-panel.js +7 -3
  29. package/dist/src/tui/catalog-state.js +57 -6
  30. package/dist/src/tui/catalog-tree.js +52 -10
  31. package/dist/src/tui/catalog-view.js +27 -4
  32. package/dist/src/tui/environment.js +8 -1
  33. package/dist/src/tui/installations-browse.js +20 -2
  34. package/dist/src/tui/installations-rows.js +2 -1
  35. package/dist/src/tui/installations-tree.js +14 -1
  36. package/dist/src/tui/installations-view.js +8 -2
  37. package/dist/src/tui/installations.js +12 -2
  38. package/dist/src/tui/manage-actions.js +2 -0
  39. package/dist/src/tui/manage-panel.js +5 -0
  40. package/dist/src/tui/package-actions.js +165 -0
  41. package/dist/src/tui/package-rows.js +87 -0
  42. package/dist/src/tui/profile-view.js +36 -14
  43. package/dist/src/tui/sources-mode.js +2 -0
  44. package/dist/src/tui/sources-recipes.js +116 -53
  45. package/dist/src/tui/sources-view.js +57 -19
  46. package/dist/src/tui/tree.js +3 -4
  47. package/dist/src/tui/updates-flow.js +4 -1
  48. package/dist/src/tui/updates-panel.js +2 -2
  49. package/dist/src/tui/updates.js +72 -4
  50. package/dist/src/user-global-skill-inventory.js +7 -2
  51. package/package.json +2 -1
  52. package/skills/agent-depot-apprecipe/SKILL.md +29 -5
@@ -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
- return profile.skills.flatMap(skill => {
102
- const identity = skill.source;
103
- if (identity.kind === "external" && "url" in identity && !profile.sources.some(source => source.url === identity.url)) {
104
- return [{ kind: "source", message: `WARNING: Skill ${skill.path} references Source ${identity.url} not included in profile` }];
105
- }
106
- return [];
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
  }
@@ -4,7 +4,8 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { compareAppVersions } from "./app-version.js";
6
6
  import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
7
- import { selectProfileItems } from "./profile-export.js";
7
+ import { selectionKey } from "./package-model.js";
8
+ import { describeProfilePackage, packageFilterNames, selectProfileItems } from "./profile-export.js";
8
9
  import { parseProfile, PROFILE_FORMAT } from "./profile.js";
9
10
  import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
10
11
  import { defaultProjectSkillTreeAccess, executeSingleInstall, formatVersionPolicy, outputSingleInstallPreview, planSingleInstall } from "./skill-install.js";
@@ -75,6 +76,12 @@ function classifySkill(value, skills) {
75
76
  const existing = skills.find(skill => skillIdentity(skill) === skillIdentity(value));
76
77
  return { block: "skills", value, label: `Skill ${value.path}`, ...comparison(existing && skillChoices(existing), skillChoices(value)) };
77
78
  }
79
+ /** Identity is the Source and coordinates; only the version policy is a portable choice. */
80
+ function classifyPackage(value, recorded) {
81
+ const existing = recorded.find(record => selectionKey(record.selection) === selectionKey(value));
82
+ return { block: "packages", value, label: describeProfilePackage(value),
83
+ ...comparison(existing === undefined ? undefined : { version: existing.selection.version }, { version: value.version }) };
84
+ }
78
85
  function classifyRecipe(value, recipes) {
79
86
  const existing = recipes.filter(entry => entry.name === value.name &&
80
87
  (entry.platform === undefined || value.platform === undefined || entry.platform === value.platform));
@@ -87,8 +94,18 @@ function classifyRecipe(value, recipes) {
87
94
  status: existing.length ? "conflict" : "add",
88
95
  ...(existing.length ? { difference: existing.map(entry => `${entry.file}: name ${JSON.stringify(value.name)} already present with overlapping platforms; ${fieldDifferences(entry.content ?? {}, value).join("; ") || "content identical"}`).join("; ") } : {}) };
89
96
  }
97
+ /** An external Source that no plan item registers is fetched but never added to Sources. */
98
+ function fetchedSourceNote(source, sources, entries) {
99
+ if (source.kind !== "external" || !("url" in source))
100
+ return undefined;
101
+ const url = canonicalizeGitSourceUrl(source.url);
102
+ const registered = sources.some(registered => registered.kind === "git" && canonicalizeGitSourceUrl(registered.url) === url);
103
+ const planned = entries.some(candidate => candidate.block === "sources" && candidate.status === "add"
104
+ && canonicalizeGitSourceUrl(candidate.value.url) === url);
105
+ return registered || planned ? undefined : ` Source ${source.url} will be fetched but not registered`;
106
+ }
90
107
  /** Load only this block; retained across skips and invalidated after every attempted write. */
91
- async function loadImportClassifier(block, operations, apps) {
108
+ async function loadImportClassifier(block, operations, apps, packages) {
92
109
  if (block === "sources") {
93
110
  const sources = await operations.listSources();
94
111
  return (item) => item.block === "sources" ? classifySource(item.value, sources) : item;
@@ -99,17 +116,28 @@ async function loadImportClassifier(block, operations, apps) {
99
116
  const skills = await operations.listUserGlobalInstallations();
100
117
  return (item) => item.block === "skills" ? classifySkill(item.value, skills) : item;
101
118
  }
119
+ if (block === "packages") {
120
+ if (packages === undefined)
121
+ throw new Error("Profile import requires Package operations to replay Package selections");
122
+ const recorded = await packages.list();
123
+ return (item) => item.block === "packages" ? classifyPackage(item.value, recorded) : item;
124
+ }
102
125
  const recipes = await loadImportRecipes(apps);
103
126
  return (item) => item.block === "apps" ? classifyRecipe(item.value, recipes) : item;
104
127
  }
105
128
  /** Read-only classification. Inclusion is a transient frontend choice, not persisted Source state. */
106
- export async function buildProfileImport(input, operations, apps, filters = {}) {
129
+ export async function buildProfileImport(input, operations, apps, filters = {}, packageOperations) {
107
130
  const profile = parseProfile(input);
108
131
  if (!operations.listUserGlobalInstallations)
109
132
  throw new Error("Profile import requires user-global installation listing");
110
133
  const sources = await operations.listSources();
111
134
  const skills = await operations.listUserGlobalInstallations();
112
135
  const recipes = await loadImportRecipes(apps);
136
+ const packageValues = selectProfileItems(profile.packages, filters.packages, filters.noPackages, selection => packageFilterNames(selection), "packages");
137
+ if (packageValues.length > 0 && packageOperations === undefined) {
138
+ throw new Error("Profile import requires Package operations to replay Package selections");
139
+ }
140
+ const recordedPackages = packageOperations === undefined ? [] : await packageOperations.list();
113
141
  const items = [];
114
142
  for (const value of selectProfileItems(profile.sources, filters.sources, filters.noSources, source => [source.url, sourceIdForUrl(source.url)], "sources"))
115
143
  items.push(classifySource(value, sources));
@@ -118,7 +146,9 @@ export async function buildProfileImport(input, operations, apps, filters = {})
118
146
  for (const value of selectProfileItems(profile.apps, filters.apps, filters.noApps, recipe => [recipe.name], "apps")) {
119
147
  items.push(classifyRecipe(value, recipes));
120
148
  }
121
- const preview = ["Preview: import portable user-global profile", ...items.flatMap(item => {
149
+ const packages = packageValues.map(value => classifyPackage(value, recordedPackages));
150
+ const entries = [...items, ...packages];
151
+ const preview = ["Preview: import portable user-global profile", ...entries.flatMap(item => {
122
152
  const status = item.block === "sources" && item.status === "same" && !item.value.included
123
153
  ? "same (discovery inclusion is not imported)" : item.status;
124
154
  const lines = [`${status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`];
@@ -128,12 +158,16 @@ export async function buildProfileImport(input, operations, apps, filters = {})
128
158
  for (const [action, method] of Object.entries(skill.methods ?? {})) {
129
159
  lines.push(` ${action}: argv=${JSON.stringify(method.argv)} (user-provided method from the profile, runs only after --yes)`);
130
160
  }
131
- const source = skill.source;
132
- if (source.kind === "external" && "url" in source &&
133
- !sources.some(registered => registered.kind === "git" && canonicalizeGitSourceUrl(registered.url) === canonicalizeGitSourceUrl(source.url)) &&
134
- !items.some(candidate => candidate.block === "sources" && canonicalizeGitSourceUrl(candidate.value.url) === canonicalizeGitSourceUrl(source.url) && candidate.status === "add")) {
135
- lines.push(` Source ${source.url} will be fetched but not registered`);
136
- }
161
+ const note = fetchedSourceNote(skill.source, sources, entries);
162
+ if (note !== undefined)
163
+ lines.push(note);
164
+ }
165
+ if (item.block === "packages" && item.status === "add") {
166
+ const selection = item.value;
167
+ lines.push(` Source ${canonical(selection.source)}`, ` host ${selection.host}: select records the choice; it runs no host command and installs nothing`);
168
+ const note = fetchedSourceNote(selection.source, sources, entries);
169
+ if (note !== undefined)
170
+ lines.push(note);
137
171
  }
138
172
  return lines;
139
173
  })];
@@ -142,7 +176,7 @@ export async function buildProfileImport(input, operations, apps, filters = {})
142
176
  preview.push(`WARNING: profile Agent Depot ${profile.agentDepotVersion} is newer than running ${AGENT_DEPOT_PACKAGE_VERSION}`);
143
177
  }
144
178
  preview.push("Existing choices are never replaced. Source inclusion is a transient discovery choice.", "Same-name App recipes conflict only when platforms overlap; disjoint platform recipes can both be added.");
145
- return { items, preview };
179
+ return { items, packages, preview };
146
180
  }
147
181
  /** Publish complete JSON exclusively: rename would silently overwrite an existing recipe. */
148
182
  async function writeRecipe(directory, recipe) {
@@ -163,17 +197,24 @@ async function writeRecipe(directory, recipe) {
163
197
  /** Apply only confirmed additions, rechecking destination choices per item. Failures remain independent. */
164
198
  export async function applyProfileImport(plan, operations, apps, confirmed, output, environment = {}) {
165
199
  const results = [];
166
- for (const block of ["sources", "skills", "apps"]) {
200
+ const packageOperations = environment.packageOperations;
201
+ const groups = [
202
+ { block: "sources", entries: plan.items.filter(item => item.block === "sources") },
203
+ { block: "skills", entries: plan.items.filter(item => item.block === "skills") },
204
+ { block: "apps", entries: plan.items.filter(item => item.block === "apps") },
205
+ { block: "packages", entries: plan.packages },
206
+ ];
207
+ for (const group of groups) {
167
208
  let classify;
168
- for (const item of plan.items.filter(item => item.block === block)) {
209
+ for (const item of group.entries) {
169
210
  if (!confirmed || item.status !== "add") {
170
211
  results.push({ item, status: "skipped" });
171
212
  continue;
172
213
  }
173
214
  try {
174
215
  parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
175
- sources: [], skills: [], apps: [], [block]: [item.value] });
176
- classify ??= await loadImportClassifier(block, operations, apps);
216
+ sources: [], skills: [], apps: [], [group.block]: [item.value] });
217
+ classify ??= await loadImportClassifier(group.block, operations, apps, packageOperations);
177
218
  const recheck = classify(item);
178
219
  if (recheck.status !== "add") {
179
220
  results.push({ item, status: "skipped", detail: `${recheck.status}: ${recheck.label}${recheck.difference ? `; ${recheck.difference}` : ""}` });
@@ -185,9 +226,10 @@ export async function applyProfileImport(plan, operations, apps, confirmed, outp
185
226
  await operations.addGitSource(item.value.url);
186
227
  else if (item.block === "apps")
187
228
  await writeRecipe(apps.directory, item.value);
188
- else {
229
+ else if (item.block === "packages")
230
+ await selectImportedPackage(item.value, packageOperations, output);
231
+ else
189
232
  await installImportedSkill(item.value, operations, apps, output, environment);
190
- }
191
233
  results.push({ item, status: "added" });
192
234
  }
193
235
  catch (error) {
@@ -197,6 +239,27 @@ export async function applyProfileImport(plan, operations, apps, confirmed, outp
197
239
  }
198
240
  return results;
199
241
  }
242
+ /**
243
+ * Replays one selection through the flow's select action. Approval is written for
244
+ * that action alone, whose declaration carries no argv, so no install or update
245
+ * authorization is created and no host command can run.
246
+ */
247
+ async function selectImportedPackage(selection, packages, output) {
248
+ if (packages === undefined)
249
+ throw new Error("Package operations are required to replay a Package selection");
250
+ const plan = await packages.planLifecycle(selection, "select");
251
+ if (plan.commands.length > 0) {
252
+ throw new Error(`${describeProfilePackage(selection)} plan carries ${plan.commands.length} host steps; import runs no host command`);
253
+ }
254
+ await packages.approve(selection, "select");
255
+ const result = await packages.executeLifecycle(plan, true);
256
+ if (result.status !== "selected") {
257
+ const reason = result.status === "failed" || result.status === "stale plan" || result.status === "manual required"
258
+ ? `: ${result.reason}` : "";
259
+ throw new Error(`${describeProfilePackage(selection)} was not selected (${result.status}${reason})`);
260
+ }
261
+ output(` selected ${describeProfilePackage(selection)}: no host command ran`);
262
+ }
200
263
  async function installImportedSkill(selection, operations, apps, output, environment) {
201
264
  if (!operations.addUserGlobalInstallation || !operations.listUserGlobalInstallations)
202
265
  throw new Error("Cannot persist user-global Skills");
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { parseAppRecipe } from "./app-recipes.js";
3
3
  import { canonicalizeGitSourceUrl } from "./git-source.js";
4
+ import { selectionKey } from "./package-model.js";
4
5
  import { containsCredentialArgument, parseProjectManifest, ProjectManifestError } from "./project-manifest.js";
5
6
  export const PROFILE_FORMAT = "agent-depot-profile/v1";
6
7
  function object(value, keys, field) {
@@ -64,9 +65,69 @@ function rejectDuplicates(items, identity, field) {
64
65
  seen.add(key);
65
66
  });
66
67
  }
68
+ /** A Source-relative bundle root in the one canonical form discovery produces. */
69
+ function bundleDirectory(value, field) {
70
+ if (typeof value !== "string")
71
+ throw new Error(`${field}: expected a string`);
72
+ if (value === "")
73
+ return value;
74
+ const noncanonical = value.startsWith("/") || value === "." || value.startsWith("./") || value.endsWith("/") ||
75
+ value.includes("//") || value.includes("\\") || value === ".." || value.startsWith("../") ||
76
+ path.posix.normalize(value) !== value;
77
+ if (noncanonical)
78
+ throw new Error(`${field}: expected a canonical Source-relative directory`);
79
+ return value;
80
+ }
81
+ /** The single segment a Claude selection names, matching the host's own plugin key. */
82
+ function pluginName(value, field) {
83
+ if (typeof value !== "string" || value.length === 0)
84
+ throw new Error(`${field}: expected a plugin name`);
85
+ const unsafe = [...value].some(character => (character.codePointAt(0) ?? 0) <= 0x1f || character.codePointAt(0) === 0x7f);
86
+ if (value === "." || value === ".." || value.includes("/") || value.includes("\\") || /\s/u.test(value) || unsafe) {
87
+ throw new Error(`${field}: expected one plugin name segment`);
88
+ }
89
+ return value;
90
+ }
91
+ /**
92
+ * A Package declaration carries its Source identity, host coordinates and version
93
+ * policy. The host's own install identity, verified version and receipt stay out,
94
+ * so they cannot travel to another machine.
95
+ */
96
+ function parseProfilePackage(value, field) {
97
+ if (!value || typeof value !== "object" || Array.isArray(value))
98
+ throw new Error(`${field}: expected an object`);
99
+ const host = value.host;
100
+ if (host !== "pi" && host !== "claude")
101
+ throw new Error(`${field}.host: expected "pi" or "claude"`);
102
+ const record = object(value, host === "pi"
103
+ ? ["source", "version", "host", "root"]
104
+ : ["source", "version", "host", "marketplaceRoot", "pluginName"], field);
105
+ const coordinates = host === "pi"
106
+ ? { host, root: bundleDirectory(record.root, `${field}.root`) }
107
+ : { host, marketplaceRoot: bundleDirectory(record.marketplaceRoot, `${field}.marketplaceRoot`),
108
+ pluginName: pluginName(record.pluginName, `${field}.pluginName`) };
109
+ const at = (root, name) => root === "" ? name : `${root}/${name}`;
110
+ const manifestPath = coordinates.host === "pi"
111
+ ? at(coordinates.root, "package.json")
112
+ : at(coordinates.marketplaceRoot, ".claude-plugin/marketplace.json");
113
+ try {
114
+ const [parsed] = parseProjectManifest({ version: 1, skills: [
115
+ { source: record.source, path: manifestPath, version: record.version, hosts: [host] },
116
+ ] }, "profile").skills;
117
+ return Object.freeze({ source: parsed.source, version: parsed.version, ...coordinates });
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof ProjectManifestError))
121
+ throw error;
122
+ const rootField = coordinates.host === "pi" ? "root" : "marketplaceRoot";
123
+ const reason = error.reason.replace(/^skills\[0\]\.path/u, `${field}.${rootField}`).replace(/^skills\[0\]\./u, `${field}.`);
124
+ const [path0, ...rest] = reason.split(" ");
125
+ throw new Error(`${path0}: ${rest.join(" ")}`);
126
+ }
127
+ }
67
128
  /** Portable profile parsing shares the manifest and recipe safety contracts. */
68
129
  export function parseProfile(value) {
69
- const root = object(value, ["format", "agentDepotVersion", "sources", "skills", "apps"], "profile");
130
+ const root = object(value, ["format", "agentDepotVersion", "sources", "skills", "apps", "packages"], "profile");
70
131
  if (root.format !== PROFILE_FORMAT)
71
132
  throw new Error("profile.format: unsupported profile version");
72
133
  if (typeof root.agentDepotVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/u.test(root.agentDepotVersion)) {
@@ -112,7 +173,14 @@ export function parseProfile(value) {
112
173
  throw new Error(reason.startsWith(`apps[${index}]`) ? reason : `apps[${index}].${reason}`);
113
174
  }
114
175
  });
176
+ const packages = (root.packages === undefined ? [] : list(root.packages, "packages")).map((value, index) => {
177
+ const field = `packages[${index}]`;
178
+ const selection = parseProfilePackage(value, field);
179
+ assertPortableText(selection, field);
180
+ return selection;
181
+ });
115
182
  rejectDuplicates(sources, source => source.url, "sources");
183
+ rejectDuplicates(packages, selectionKey, "packages");
116
184
  apps.forEach((app, index) => {
117
185
  if (apps.slice(0, index).some(previous => previous.name === app.name &&
118
186
  (previous.platform === undefined || app.platform === undefined || previous.platform === app.platform))) {
@@ -123,8 +191,15 @@ export function parseProfile(value) {
123
191
  sources.sort((a, b) => compare(a.url, b.url));
124
192
  const sortedSkills = [...parsedSkills].sort((a, b) => compare(JSON.stringify([a.source, a.path]), JSON.stringify([b.source, b.path])));
125
193
  apps.sort((a, b) => compare(a.name, b.name) || compare(a.platform ?? "", b.platform ?? ""));
126
- return { format: PROFILE_FORMAT, agentDepotVersion: root.agentDepotVersion, sources, skills: sortedSkills, apps };
194
+ packages.sort((a, b) => compare(selectionKey(a), selectionKey(b)));
195
+ return { format: PROFILE_FORMAT, agentDepotVersion: root.agentDepotVersion, sources, skills: sortedSkills, apps, packages };
127
196
  }
197
+ /**
198
+ * An empty Package block is omitted, so an export with no Packages stays readable
199
+ * by an Agent Depot version that predates the block.
200
+ */
128
201
  export function serializeProfile(profile) {
129
- return `${JSON.stringify(parseProfile(profile), null, 2)}\n`;
202
+ const parsed = parseProfile(profile);
203
+ const { packages, ...rest } = parsed;
204
+ return `${JSON.stringify(packages.length === 0 ? rest : parsed, null, 2)}\n`;
130
205
  }
@@ -4,7 +4,7 @@ import { lstat, readdir, readFile } from "node:fs/promises";
4
4
  import { assertNoSymlinkPath } from "./path-safety.js";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import path from "node:path";
7
- import { defaultGitSourceCachePath, GitSourceSnapshotAccess, SKILL_TREE_LIMITS, validateSkillDirectoryPath, } from "./git-source.js";
7
+ import { defaultGitSourceCachePath, GitSourceSnapshotAccess, isSourceSnapshotFile, SKILL_TREE_LIMITS, validateSkillDirectoryPath, } from "./git-source.js";
8
8
  import { AGENT_DEPOT_PACKAGE_VERSION, } from "./project-manifest.js";
9
9
  export const EXCLUDED_LIFECYCLE_SEGMENTS = Object.freeze([
10
10
  "in-progress",
@@ -325,7 +325,7 @@ async function walkDirectory(root, relativeDirectory, files, readTextFile) {
325
325
  await walkDirectory(root, relativePath, files, readTextFile);
326
326
  continue;
327
327
  }
328
- if (!entry.isFile() || entry.name !== "SKILL.md" || (await safePathKind(absolutePath)) !== "file") {
328
+ if (!entry.isFile() || !isSourceSnapshotFile(relativePath) || (await safePathKind(absolutePath)) !== "file") {
329
329
  continue;
330
330
  }
331
331
  files.push({
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { assertSkillNotAppOwned } from "./app-owned-skills.js";
3
+ import { assertSkillNotPackageOwned, loadPackageOwnedSkillFilter, } from "./package-owned-skills.js";
3
4
  import { pathsOverlap } from "./path-safety.js";
4
5
  import { inspectProjectSkillInstallation, installProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
5
6
  import { AGENT_DEPOT_PACKAGE_VERSION, parseProjectManifest, } from "./project-manifest.js";
@@ -313,6 +314,7 @@ export async function planSingleInstall(request, context) {
313
314
  }
314
315
  const { resolvedSource, resolved, installationInspection } = await resolveInstallInspection(selection, source, projectSource, context.root, request, context.operations, context.environment, context.sourceAccess);
315
316
  await assertInstallNotAppOwned(request, context, installationInspection);
317
+ await assertInstallNotPackageOwned(selection, resolvedSource, context);
316
318
  return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection };
317
319
  }
318
320
  /** Prints the preview the user confirms before {@link executeSingleInstall}. */
@@ -327,6 +329,7 @@ export function outputSingleInstallPreview(plan, output) {
327
329
  export async function executeSingleInstall(plan, persist, output) {
328
330
  const { request, context, selection, resolvedSource, resolved, inspection } = plan;
329
331
  await assertInstallNotAppOwned(request, context, inspection);
332
+ await assertInstallNotPackageOwned(selection, resolvedSource, context);
330
333
  const protectedPaths = assertNoManagedInstallationOverlap(context.existing, selection, inspection, context.root, request.overwrite);
331
334
  rejectInstallationCollision(inspection, request.overwrite);
332
335
  const transaction = await installOne(selection, resolvedSource, context.sourceAccess, context.root, context.environment, resolved.method, request.portableV1, resolved.files, resolved.resolvedVersion, request.confirmAdditionalHostExposure, request.overwrite, inspection, protectedPaths);
@@ -372,3 +375,16 @@ async function assertInstallNotAppOwned(request, context, inspection) {
372
375
  await assertSkillNotAppOwned(path.basename(location.path), { ...context.environment, homeDirectory: context.root });
373
376
  }
374
377
  }
378
+ /** Reloads bundle ownership from the selected Source and refuses its own Skill paths. */
379
+ async function assertInstallNotPackageOwned(selection, source, context) {
380
+ const owned = await loadPackageOwnedSkillFilter({
381
+ sources: [source],
382
+ homeDirectory: context.root,
383
+ ...(context.environment.sourceContentAccess === undefined ? {} : { sourceContentAccess: context.environment.sourceContentAccess }),
384
+ ...(context.environment.selections === undefined ? {} : { selections: context.environment.selections }),
385
+ ...(context.environment.hostView === undefined ? {} : { hostView: context.environment.hostView }),
386
+ ...(context.environment.stateDirectory === undefined ? {} : { stateDirectory: context.environment.stateDirectory }),
387
+ ...(context.environment.reportWarning === undefined ? {} : { reportWarning: context.environment.reportWarning }),
388
+ });
389
+ assertSkillNotPackageOwned(selection.path, owned);
390
+ }
@@ -286,6 +286,17 @@ export function createSourceOperations(options = {}) {
286
286
  },
287
287
  };
288
288
  }
289
+ /** Converts a registered Source back into the self-contained identity a selection carries. */
290
+ export function projectSourceOf(source) {
291
+ if (source.kind === "builtin") {
292
+ return Object.freeze({ kind: "builtin", id: source.id });
293
+ }
294
+ return Object.freeze({
295
+ kind: "external",
296
+ url: source.url,
297
+ ...(source.ref === undefined ? {} : { ref: source.ref }),
298
+ });
299
+ }
289
300
  /** Converts persisted project identity into a read-only Source identity. */
290
301
  export function resolveProjectSource(projectSource) {
291
302
  if (projectSource.kind === "builtin") {
@@ -13,7 +13,7 @@ export function isAppMode(mode) {
13
13
  export const appName = (entry) => entry.recipe?.name ?? entry.file;
14
14
  export function appStepLines(step) {
15
15
  return [
16
- ...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? "unavailable"}`] : []),
16
+ ...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? `${JSON.stringify(step.argv[0])} not found in PATH`}`] : []),
17
17
  ...(step.manual ? [`Manual: ${step.manual}`] : []),
18
18
  `Working directory: ${step.cwd}`,
19
19
  ...(step.warning ? [step.warning] : []),
@@ -6,7 +6,7 @@ import { CatalogView } from "./catalog-view.js";
6
6
  import { InstallationsView } from "./installations-view.js";
7
7
  import { useKeys } from "./keys.js";
8
8
  import { ProfileView } from "./profile-view.js";
9
- import { SourcesView } from "./sources-view.js";
9
+ import { SourcesView, sourceRowHints } from "./sources-view.js";
10
10
  import { separatorLine, theme } from "./theme.js";
11
11
  import { UpdatesView } from "./updates-view.js";
12
12
  const TABS = [
@@ -16,11 +16,9 @@ const TABS = [
16
16
  ["updates", "4 Updates"],
17
17
  ["profile", "5 Import/Export"],
18
18
  ];
19
- /** Compact keys of the current view; the tab bar already shows how to switch views. */
19
+ /** Sources derives its own hints from the selected row, so it is excluded here. */
20
20
  function hintsOf(view, catalogFocusedOnSource) {
21
21
  switch (view) {
22
- case "sources":
23
- return "Tab section · j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
24
22
  case "catalog":
25
23
  return `j/k move · Enter expand · space mark · a all · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
26
24
  case "installations":
@@ -43,6 +41,7 @@ export function App({ operations, environment, onExit }) {
43
41
  }, []);
44
42
  const [view, setView] = useState("sources");
45
43
  const [catalogSourceId, setCatalogSourceId] = useState();
44
+ const [sourcesHints, setSourcesHints] = useState(sourceRowHints(undefined));
46
45
  const showView = (next) => setView(next);
47
46
  useKeys((input) => {
48
47
  if (capturingRef.current)
@@ -62,8 +61,8 @@ export function App({ operations, environment, onExit }) {
62
61
  else if (input === "5")
63
62
  showView("profile");
64
63
  });
65
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
64
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
66
65
  setCatalogSourceId(source.id);
67
66
  setView("catalog");
68
- } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
67
+ } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
69
68
  }
@@ -22,15 +22,22 @@ function markAllListed(context) {
22
22
  context.setMessage(undefined);
23
23
  setMarked(leaves.every((skill) => markedRef.current.has(skillKey(skill))) ? new Set() : new Set(leaves.map(skillKey)));
24
24
  }
25
- /** `i`: installs the marked Skills that are listed, or the highlighted one. */
25
+ /** `i`: installs the highlighted bundle, or the marked Skills that are listed, or the highlighted one. */
26
26
  function chooseHosts(context, row) {
27
+ const data = row.node.data;
28
+ // A bundle row is not markable, so it acts on itself and never picks up a Skill mark.
29
+ if (data.kind === "bundle") {
30
+ context.setMessage(undefined);
31
+ context.startPackage({ selection: data.entry.selection, name: data.entry.descriptor.name });
32
+ return;
33
+ }
27
34
  // Only what is listed installs: marks on Skills hidden by the filter are ignored.
28
35
  const marked = leavesOf(context.navigation.latestRoots())
29
36
  .filter((skill) => context.marks.markedRef.current.has(skillKey(skill)));
30
- const highlighted = row.node.data.kind === "skill" ? [row.node.data.skill] : [];
37
+ const highlighted = data.kind === "skill" ? [data.skill] : [];
31
38
  const chosen = marked.length > 0 ? marked : highlighted;
32
39
  if (chosen.length === 0) {
33
- context.setMessage({ kind: "error", text: "Install is not available for source rows; highlight a skill or mark some with space" });
40
+ context.setMessage({ kind: "error", text: "Install is not available for source rows; highlight a skill or a Package bundle, or mark some skills with space" });
34
41
  return;
35
42
  }
36
43
  context.setMessage(undefined);
@@ -4,6 +4,7 @@ import { describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.j
4
4
  import { skillKey } from "./catalog-tree.js";
5
5
  import { hasUnmanagedCopy } from "./catalog-installs.js";
6
6
  import { describeRow, UNMANAGED_MARKER } from "./catalog-tree.js";
7
+ import { isPackageMode, PackagePanel } from "./package-actions.js";
7
8
  import { HostChecklist, PreviewLines } from "./panel-parts.js";
8
9
  import { rowStyle } from "./theme.js";
9
10
  import { theme } from "./theme.js";
@@ -11,16 +12,19 @@ import { theme } from "./theme.js";
11
12
  export function CatalogHeader({ scope, listed, installable, total, filter }) {
12
13
  return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Scope: ", scope, " ", listed, " of ", installable] }), filter.editing || filter.query !== "" ? (_jsxs(Text, { children: ["Filter: ", filter.query, filter.editing ? "_" : ""] })) : null, total === 0 ? _jsx(Text, { children: "No skills" }) : null, total > 0 && installable === 0 ? _jsx(Text, { children: "All skills are installed" }) : null, installable > 0 && listed === 0 ? _jsx(Text, { children: "No matching skills" }) : null] }));
13
14
  }
14
- /** One line of the Catalog tree: cursor, indent, mark box, label and the unmanaged-copy marker. */
15
+ /** One line of the Catalog tree: cursor, indent, mark box, label and the ownership markers. */
15
16
  export function CatalogRowLine({ row, selected, marked, installed }) {
16
17
  const { data } = row.node;
17
- const isSource = data.kind === "source";
18
- return (_jsxs(Text, { ...rowStyle(selected), color: isSource ? theme.group : undefined, children: [selected ? "> " : " ", " ".repeat(row.depth), isSource ? "" : marked ? "[x] " : "[ ] ", describeRow(row), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
18
+ const isGroup = data.kind === "source" || data.kind === "group";
19
+ const ownedBy = data.kind === "skill" ? data.ownedBy : undefined;
20
+ return (_jsxs(Text, { ...rowStyle(selected), color: isGroup ? theme.group : ownedBy === undefined ? undefined : theme.inactive, children: [selected ? "> " : " ", " ".repeat(row.depth), data.kind === "skill" ? marked ? "[x] " : "[ ] " : "", describeRow(row), ownedBy === undefined ? null : _jsxs(Text, { color: theme.inactive, children: [" ", "provided by the ", ownedBy.host, " Package ", ownedBy.name] }), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
19
21
  ? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
20
22
  : null] }));
21
23
  }
22
24
  /** The prompt of the install flow; browsing shows nothing. */
23
25
  export function ActionPanel({ mode }) {
26
+ if (isPackageMode(mode))
27
+ return _jsx(PackagePanel, { mode: mode });
24
28
  switch (mode.kind) {
25
29
  case "browse":
26
30
  return null;