@iseoane/agent-depot 0.6.9 → 0.6.11

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 (44) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +25 -3
  3. package/dist/src/app-flow.js +3 -3
  4. package/dist/src/app-recipe-removal.js +3 -1
  5. package/dist/src/atomic-file.js +24 -1
  6. package/dist/src/cli.js +49 -15
  7. package/dist/src/package-cli.js +1 -18
  8. package/dist/src/package-flow.js +8 -8
  9. package/dist/src/package-model.js +51 -0
  10. package/dist/src/path-safety.js +4 -1
  11. package/dist/src/profile-export.js +4 -4
  12. package/dist/src/profile-import.js +94 -18
  13. package/dist/src/project-installation.js +2 -1
  14. package/dist/src/project-manifest.js +7 -0
  15. package/dist/src/project-target.js +58 -0
  16. package/dist/src/skill-discovery.js +86 -6
  17. package/dist/src/skill-install.js +20 -8
  18. package/dist/src/tui/app-actions.js +18 -7
  19. package/dist/src/tui/app.js +20 -5
  20. package/dist/src/tui/batch.js +2 -1
  21. package/dist/src/tui/bulk-actions.js +21 -8
  22. package/dist/src/tui/catalog-installs.js +3 -2
  23. package/dist/src/tui/catalog-panel.js +3 -2
  24. package/dist/src/tui/catalog-state.js +47 -21
  25. package/dist/src/tui/catalog-tree.js +47 -10
  26. package/dist/src/tui/catalog-view.js +11 -5
  27. package/dist/src/tui/installations-view.js +19 -6
  28. package/dist/src/tui/installations.js +1 -1
  29. package/dist/src/tui/manage-panel.js +1 -1
  30. package/dist/src/tui/package-actions.js +2 -2
  31. package/dist/src/tui/package-rows.js +4 -4
  32. package/dist/src/tui/profile-view.js +68 -31
  33. package/dist/src/tui/sources-recipes.js +44 -8
  34. package/dist/src/tui/sources-view.js +12 -5
  35. package/dist/src/tui/updates-keys.js +30 -11
  36. package/dist/src/tui/updates-model.js +15 -3
  37. package/dist/src/tui/updates-panel.js +7 -1
  38. package/dist/src/tui/updates-tree.js +51 -0
  39. package/dist/src/tui/updates-view.js +8 -4
  40. package/dist/src/tui/updates.js +4 -3
  41. package/dist/src/tui/view-retention.js +17 -0
  42. package/dist/src/update-flow.js +3 -1
  43. package/dist/src/update-groups.js +29 -0
  44. package/package.json +1 -1
@@ -2,7 +2,7 @@ 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
+ import { bundleRoot, describeBundleInSource, describeCoordinates } from "./package-model.js";
6
6
  import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
7
7
  import { parseProfile, PROFILE_FORMAT, PROFILE_FORMAT_V2, serializeProfile } from "./profile.js";
8
8
  import { formatVersionPolicy } from "./skill-install.js";
@@ -33,7 +33,7 @@ export function packageFilterNames(selection) {
33
33
  }
34
34
  /** One Package line, shared by the export preview and the import plan label. */
35
35
  export function describeProfilePackage(selection) {
36
- return `Package ${describeCoordinates(selection)} from ${profileSourceIdentity(selection.source)} (${formatVersionPolicy(selection.version)}; saved selection)`;
36
+ return `Package ${describeBundleInSource(selection)} from ${profileSourceIdentity(selection.source)} (${formatVersionPolicy(selection.version)}; saved selection)`;
37
37
  }
38
38
  export function describeProfileSkill(skill) {
39
39
  return `Skill ${skill.path} from ${profileSourceIdentity(skill.source)} (${skill.version.policy})`;
@@ -42,10 +42,10 @@ export function describeProfileReplica(replica) {
42
42
  switch (replica.kind) {
43
43
  case "skill": return `Installed Skill ${replica.path} from ${profileSourceIdentity(replica.source)}`;
44
44
  case "app": return `Installed App ${replica.name} (${replica.platform ?? "all platforms"})`;
45
- case "package": return `Installed Package ${describeCoordinates(replica.selection)} from ${profileSourceIdentity(replica.selection.source)}`;
45
+ case "package": return `Installed Package ${describeBundleInSource(replica.selection)} from ${profileSourceIdentity(replica.selection.source)}`;
46
46
  }
47
47
  }
48
- function profileSourceIdentity(source) {
48
+ export function profileSourceIdentity(source) {
49
49
  if (source.kind === "builtin")
50
50
  return "Agent Depot (built-in Source)";
51
51
  if ("url" in source)
@@ -4,8 +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 { selectionKey } from "./package-model.js";
8
- import { describeProfilePackage, describeProfileReplica, packageFilterNames, selectProfileItems } from "./profile-export.js";
7
+ import { describeBundleInSource, selectionKey } from "./package-model.js";
8
+ import { describeProfilePackage, describeProfileReplica, packageFilterNames, profileSourceIdentity, selectProfileItems } from "./profile-export.js";
9
9
  import { parseProfile, PROFILE_FORMAT } from "./profile.js";
10
10
  import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
11
11
  import { defaultProjectSkillTreeAccess, executeSingleInstall, formatVersionPolicy, outputSingleInstallPreview, planSingleInstall } from "./skill-install.js";
@@ -30,16 +30,55 @@ function skillChoices(skill) {
30
30
  function recipeIdentity(recipe) {
31
31
  return canonical([recipe.name, recipe.platform]);
32
32
  }
33
- function fieldDifferences(existing, incoming) {
33
+ function fieldChanges(existing, incoming) {
34
34
  const left = new Map(Object.entries(existing)), right = new Map(Object.entries(incoming));
35
- return [...new Set([...left.keys(), ...right.keys()])].sort().flatMap(key => canonical(left.get(key)) === canonical(right.get(key)) ? []
36
- : [`${key}: existing ${canonical(left.get(key))} -> incoming ${canonical(right.get(key))}`]);
35
+ return [...new Set([...left.keys(), ...right.keys()])].sort().flatMap(key => canonical(left.get(key)) === canonical(right.get(key)) ? [] : [{ key, existing: left.get(key), incoming: right.get(key) }]);
36
+ }
37
+ function fieldDifferences(existing, incoming) {
38
+ return fieldChanges(existing, incoming).map(change => `${change.key}: existing ${canonical(change.existing)} -> incoming ${canonical(change.incoming)}`);
39
+ }
40
+ /** The Hosts the profile would add and the ones it lacks, instead of two raw arrays. */
41
+ function hostChange(existing, incoming) {
42
+ const before = Array.isArray(existing) ? existing.map(String) : [], after = Array.isArray(incoming) ? incoming.map(String) : [];
43
+ const added = after.filter(host => !before.includes(host)), missing = before.filter(host => !after.includes(host));
44
+ return [added.length ? `would add ${added.join(", ")}` : "", missing.length ? `profile lacks ${missing.join(", ")}` : ""]
45
+ .filter(Boolean).join("; ");
46
+ }
47
+ function isVersionPolicy(value) {
48
+ if (typeof value !== "object" || value === null || !("policy" in value))
49
+ return false;
50
+ return value.policy === "latest" || (value.policy === "fixed" && "version" in value && typeof value.version === "string");
51
+ }
52
+ function shortReason(existing, incoming) {
53
+ return fieldChanges(existing, incoming).map(change => {
54
+ if (change.key === "hosts")
55
+ return `hosts: ${hostChange(change.existing, change.incoming)}`;
56
+ if (change.key === "version") {
57
+ return isVersionPolicy(change.existing) && isVersionPolicy(change.incoming)
58
+ ? `version: existing ${formatVersionPolicy(change.existing)}, profile ${formatVersionPolicy(change.incoming)}` : "version differs";
59
+ }
60
+ return `${change.key} differ`;
61
+ }).join("; ");
37
62
  }
38
63
  function comparison(existing, incoming) {
39
64
  if (existing === undefined)
40
65
  return { status: "add" };
41
66
  const differences = fieldDifferences(existing, incoming);
42
- return differences.length ? { status: "conflict", difference: differences.join("; ") } : { status: "same" };
67
+ return differences.length
68
+ ? { status: "conflict", difference: differences.join("; "), reason: shortReason(existing, incoming) }
69
+ : { status: "same" };
70
+ }
71
+ /** The row text: the effect of importing an addition, or why an existing one is left alone. */
72
+ function summarize(status, thing, addition, reason, note) {
73
+ if (status === "add")
74
+ return addition;
75
+ if (status === "same")
76
+ return `${thing} — already identical${note ? ` (${note})` : ""}`;
77
+ return `${thing} — ${reason}`;
78
+ }
79
+ /** Skills, Packages and Sources group under one key whichever way their URL was written. */
80
+ function groupOf(source) {
81
+ return "url" in source ? canonicalizeGitSourceUrl(source.url) : profileSourceIdentity(source);
43
82
  }
44
83
  async function loadImportRecipes(apps) {
45
84
  const entries = await apps.load();
@@ -69,29 +108,58 @@ async function loadImportRecipes(apps) {
69
108
  }
70
109
  function classifySource(value, sources) {
71
110
  const exists = sources.some(source => source.kind === "git" && canonicalizeGitSourceUrl(source.url) === value.url);
72
- return { block: "sources", value, label: `Source ${value.url} (included: ${value.included})`, status: exists ? "same" : "add",
111
+ const status = exists ? "same" : "add";
112
+ const note = exists && !value.included ? "discovery inclusion is not imported" : undefined;
113
+ return { block: "sources", value, label: `Source ${value.url} (included: ${value.included})`, status, group: canonicalizeGitSourceUrl(value.url),
114
+ summary: summarize(status, `Source ${value.url}`, `Add Source ${value.url}`, undefined, note),
73
115
  ...(exists && !value.included ? { difference: "included: existing true -> incoming false (discovery inclusion is not imported)" } : {}) };
74
116
  }
75
- function classifySkill(value, skills) {
117
+ /** A Skill shows its name; `showPath` marks a name two Skills of one Source share, `showSource` one that several Sources share. */
118
+ function classifySkill(value, skills, { showPath = false, showSource = false } = {}) {
76
119
  const existing = skills.find(skill => skillIdentity(skill) === skillIdentity(value));
77
- return { block: "skills", value, label: `Skill ${value.path}`, ...comparison(existing && skillChoices(existing), skillChoices(value)) };
120
+ const result = comparison(existing && skillChoices(existing), skillChoices(value));
121
+ const name = `Skill ${showPath ? value.path : path.posix.basename(value.path)}`;
122
+ // An addition sits under its Source header; the flat Unchanged and Conflicts sections have no such context.
123
+ const thing = showSource && result.status !== "add" ? `${name} from ${groupOf(value.source)}` : name;
124
+ return { block: "skills", value, label: `Skill ${value.path}`, group: groupOf(value.source), ...result,
125
+ summary: summarize(result.status, thing, `Add ${thing}`, "reason" in result ? result.reason : undefined) };
78
126
  }
79
127
  /** Identity is the Source and coordinates; only the version policy is a portable choice. */
80
128
  function classifyPackage(value, recorded) {
81
129
  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 }) };
130
+ const result = comparison(existing === undefined ? undefined : { version: existing.selection.version }, { version: value.version });
131
+ const thing = `Package ${describeBundleInSource(value)} from ${profileSourceIdentity(value.source)} (${formatVersionPolicy(value.version)})`;
132
+ return { block: "packages", value, label: describeProfilePackage(value), group: groupOf(value.source), ...result,
133
+ summary: summarize(result.status, thing, `Select ${thing}`, "reason" in result ? result.reason : undefined) };
134
+ }
135
+ /** The plain reason for a conflict, otherwise the exact comparison. */
136
+ function differenceText(item) {
137
+ return item.reason ?? item.difference ?? "";
138
+ }
139
+ /** Why a same-name recipe file blocks the import, naming the file and what differs. */
140
+ function recipeConflictReason(existing, value) {
141
+ const files = existing.map(entry => path.basename(entry.file)).join(", ");
142
+ if (existing.some(entry => entry.error))
143
+ return `existing recipe ${files} is invalid but keeps this name`;
144
+ const keys = [...new Set(existing.flatMap(entry => fieldChanges(entry.content ?? {}, value).map(change => change.key)))];
145
+ return keys.length ? `existing recipe ${files} differs in ${keys.join(", ")}` : `existing recipe ${files} already has this name`;
84
146
  }
85
147
  function classifyRecipe(value, recipes) {
86
148
  const existing = recipes.filter(entry => entry.name === value.name &&
87
149
  (entry.platform === undefined || value.platform === undefined || entry.platform === value.platform));
150
+ const platform = value.platform ?? "all platforms";
151
+ // A recipe is written as a file on import; it is never approved and the App is never installed.
152
+ const label = `App recipe ${value.name} (${platform}; not approved, not installed)`;
153
+ const thing = `App recipe ${value.name} (${platform})`;
154
+ const addition = `Add ${thing} — not approved, not installed`;
88
155
  if (existing.length === 1 && !existing[0].error &&
89
156
  existing[0].platform === value.platform &&
90
157
  fieldDifferences(existing[0].content ?? {}, value).length === 0) {
91
- return { block: "apps", value, label: `App recipe ${value.name} (${value.platform ?? "all platforms"}; unapproved; Apps are not installed)`, status: "same" };
158
+ return { block: "apps", value, label, status: "same", summary: summarize("same", thing, addition) };
92
159
  }
93
- return { block: "apps", value, label: `App recipe ${value.name} (${value.platform ?? "all platforms"}; unapproved; Apps are not installed)`,
94
- status: existing.length ? "conflict" : "add",
160
+ const reason = existing.length ? recipeConflictReason(existing, value) : undefined;
161
+ return { block: "apps", value, label, summary: summarize(existing.length ? "conflict" : "add", thing, addition, reason),
162
+ status: existing.length ? "conflict" : "add", ...(reason === undefined ? {} : { reason }),
95
163
  ...(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("; ") } : {}) };
96
164
  }
97
165
  /** An external Source that no plan item registers is fetched but never added to Sources. */
@@ -141,8 +209,14 @@ export async function buildProfileImport(input, operations, apps, filters = {},
141
209
  const items = [];
142
210
  for (const value of selectProfileItems(profile.sources, filters.sources, filters.noSources, source => [source.url, sourceIdForUrl(source.url)], "sources"))
143
211
  items.push(classifySource(value, sources));
144
- for (const value of selectProfileItems(profile.skills, filters.skills, filters.noSkills, skill => [skill.path, path.posix.basename(skill.path)], "skills"))
145
- items.push(classifySkill(value, skills));
212
+ const chosenSkills = selectProfileItems(profile.skills, filters.skills, filters.noSkills, skill => [skill.path, path.posix.basename(skill.path)], "skills");
213
+ const baseName = (skill) => path.posix.basename(skill.path);
214
+ const nameOf = (skill) => `${groupOf(skill.source)}\n${baseName(skill)}`;
215
+ for (const value of chosenSkills) {
216
+ const sameName = chosenSkills.filter(other => baseName(other) === baseName(value));
217
+ items.push(classifySkill(value, skills, { showPath: sameName.filter(other => nameOf(other) === nameOf(value)).length > 1,
218
+ showSource: new Set(sameName.map(other => groupOf(other.source))).size > 1 }));
219
+ }
146
220
  for (const value of selectProfileItems(profile.apps, filters.apps, filters.noApps, recipe => [recipe.name], "apps")) {
147
221
  items.push(classifyRecipe(value, recipes));
148
222
  }
@@ -151,7 +225,8 @@ export async function buildProfileImport(input, operations, apps, filters = {},
151
225
  const preview = ["Preview: import portable user-global profile", ...entries.flatMap(item => {
152
226
  const status = item.block === "sources" && item.status === "same" && !item.value.included
153
227
  ? "same (discovery inclusion is not imported)" : item.status;
154
- const lines = [`${status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`];
228
+ const text = differenceText(item);
229
+ const lines = [`${status}: ${item.label}${text ? `; ${text}` : ""}`];
155
230
  if (item.block === "skills" && item.status === "add") {
156
231
  const skill = item.value;
157
232
  lines.push(` Source ${canonical(skill.source)}`, ` hosts: ${skill.hosts.join(",")}; version policy: ${formatVersionPolicy(skill.version)}`);
@@ -218,7 +293,8 @@ export async function applyProfileImport(plan, operations, apps, confirmed, outp
218
293
  classify ??= await loadImportClassifier(group.block, operations, apps, packageOperations);
219
294
  const recheck = classify(item);
220
295
  if (recheck.status !== "add") {
221
- results.push({ item, status: "skipped", detail: `${recheck.status}: ${recheck.label}${recheck.difference ? `; ${recheck.difference}` : ""}` });
296
+ const text = differenceText(recheck);
297
+ results.push({ item, status: "skipped", detail: `${recheck.status}: ${recheck.label}${text ? `; ${text}` : ""}` });
222
298
  continue;
223
299
  }
224
300
  // Even a failing install method can persist state or change recipe files.
@@ -1,6 +1,7 @@
1
1
  import { chmod, lstat, mkdir, readFile, readlink, readdir, rename, rmdir, rm, symlink, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import path from "node:path";
4
+ import { HOST_PROJECT_DIRECTORIES } from "./project-manifest.js";
4
5
  import { compareSkillTrees, readExistingSkillTree } from "./skill-adoption.js";
5
6
  import { pathsOverlap } from "./path-safety.js";
6
7
  import { parseSkillFrontmatter, skillTreeBaseline } from "./skill-discovery.js";
@@ -1433,7 +1434,7 @@ function isPortableV1Tree(files) {
1433
1434
  return files.every((file) => !portableV1ExcludedDirectory(file.relativePath));
1434
1435
  }
1435
1436
  function portableV1ExcludedDirectory(relativePath) {
1436
- const excludedTopLevelDirectories = new Set([".claude", ".codex", ".opencode", ".pi", "extensions", "plugins", "mcp"]);
1437
+ const excludedTopLevelDirectories = new Set([...Object.values(HOST_PROJECT_DIRECTORIES), "extensions", "plugins", "mcp"]);
1437
1438
  const firstSegment = relativePath.split("/", 1)[0];
1438
1439
  return excludedTopLevelDirectories.has(firstSegment ?? "");
1439
1440
  }
@@ -10,6 +10,13 @@ export const AGENT_DEPOT_PACKAGE_VERSION = readAgentDepotPackageVersion();
10
10
  /** The published package name, read from package.json so it is never hard-coded twice. */
11
11
  export const AGENT_DEPOT_PACKAGE_NAME = readAgentDepotPackageField("name");
12
12
  export const PROJECT_HOSTS = Object.freeze(["pi", "claude", "codex", "opencode"]);
13
+ /** Where each Host keeps project-level configuration, relative to the project root. */
14
+ export const HOST_PROJECT_DIRECTORIES = Object.freeze({
15
+ pi: ".pi",
16
+ claude: ".claude",
17
+ codex: ".codex",
18
+ opencode: ".opencode",
19
+ });
13
20
  const EMPTY_PROJECT_MANIFEST = Object.freeze({
14
21
  version: PROJECT_MANIFEST_VERSION,
15
22
  skills: Object.freeze([]),
@@ -0,0 +1,58 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertNoSymlinkPath, SymlinkPathError } from "./path-safety.js";
4
+ import { HOST_PROJECT_DIRECTORIES } from "./project-manifest.js";
5
+ import { CliUsageError } from "./usage-error.js";
6
+ /** `.agents` is the canonical Skill location the Hosts share, so it also marks a project that already uses Skills. */
7
+ const PROJECT_DIRECTORIES = [".agents", ...Object.values(HOST_PROJECT_DIRECTORIES)];
8
+ /** The warnings are advisory, so an entry that cannot be read counts as absent. */
9
+ async function exists(candidate) {
10
+ return stat(candidate).then(() => true, () => false);
11
+ }
12
+ /**
13
+ * Nearest ancestor-or-self holding a `.git` entry. A file marks a linked
14
+ * worktree or submodule, so any `.git` entry counts. Walking the filesystem
15
+ * avoids spawning `git` and cannot reach the network.
16
+ */
17
+ async function findGitTopLevel(start) {
18
+ for (let current = start;; current = path.dirname(current)) {
19
+ if (await exists(path.join(current, ".git")))
20
+ return current;
21
+ if (path.dirname(current) === current)
22
+ return undefined;
23
+ }
24
+ }
25
+ /**
26
+ * The lines every project-scope preview starts with: the project root the
27
+ * Skills are installed into, then non-blocking warnings about a root that is
28
+ * probably not the intended one. A brand-new project is legitimate, so nothing
29
+ * here refuses.
30
+ */
31
+ export async function describeProjectTarget(projectRoot, manifestPath) {
32
+ const lines = [`Project: ${projectRoot}`];
33
+ const gitTopLevel = await findGitTopLevel(projectRoot);
34
+ if (gitTopLevel !== undefined && path.relative(gitTopLevel, projectRoot) !== "") {
35
+ lines.push(` WARNING: this directory is inside the Git repository at ${gitTopLevel} but is not its top level; Skills will be installed here, not at the repository root`);
36
+ }
37
+ const markers = [manifestPath, ...PROJECT_DIRECTORIES.map((directory) => path.join(projectRoot, directory))];
38
+ if (!(await Promise.all(markers.map(exists))).some(Boolean)) {
39
+ lines.push(` WARNING: this directory has no project manifest (${path.basename(manifestPath)}) and none of ${PROJECT_DIRECTORIES.join(", ")}; confirm it is the intended project directory`);
40
+ }
41
+ return lines;
42
+ }
43
+ /** Resolves a `--project` value against the working directory; it must be an existing directory without symbolic links. */
44
+ export async function resolveProjectRootOption(value) {
45
+ const root = path.resolve(value);
46
+ const information = await stat(root).catch((error) => {
47
+ if (typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
48
+ throw new CliUsageError(`--project ${JSON.stringify(value)} does not exist`);
49
+ }
50
+ throw error;
51
+ });
52
+ if (!information.isDirectory())
53
+ throw new CliUsageError(`--project ${JSON.stringify(value)} is not a directory`);
54
+ await assertNoSymlinkPath(root, `--project ${JSON.stringify(value)}`).catch((error) => {
55
+ throw error instanceof SymlinkPathError ? new CliUsageError(error.message) : error;
56
+ });
57
+ return root;
58
+ }
@@ -13,6 +13,21 @@ export const EXCLUDED_LIFECYCLE_SEGMENTS = Object.freeze([
13
13
  "retired",
14
14
  ]);
15
15
  const excludedLifecycleSegments = new Set(EXCLUDED_LIFECYCLE_SEGMENTS);
16
+ /**
17
+ * Directories where other coding agents keep generated copies of a Source's
18
+ * Skills. Agent Depot manages Claude, Codex, Pi and OpenCode only, so a Skill
19
+ * found below one of these is a mirror of the real one, never a candidate.
20
+ * `.claude`, `.agents`, `.codex`, `.pi` and `.opencode` are deliberately absent.
21
+ */
22
+ const UNMANAGED_HOST_MIRROR_SEGMENTS = Object.freeze([
23
+ ".cursor", "cursor-plugin", ".windsurf", ".gemini", ".github", ".kiro", ".trae", ".trae-cn", ".devin", ".dsh",
24
+ ".hermes", ".veto", ".cline", ".roo", ".continue", ".augment", ".goose", ".amazonq", ".junie", ".kilocode",
25
+ ".qwen", ".agent", ".factory", ".crush", ".openhands", ".codebuddy", ".qoder", ".zed",
26
+ ]);
27
+ /** Test and fixture trees hold sample Skills; only ancestors count, so a Skill directory may be named `test`. */
28
+ const TEST_TREE_SEGMENTS = Object.freeze(["tests", "test", "__tests__", "fixtures", "__fixtures__"]);
29
+ const unmanagedMirrorSegments = new Set(UNMANAGED_HOST_MIRROR_SEGMENTS);
30
+ const testTreeSegments = new Set(TEST_TREE_SEGMENTS);
16
31
  /**
17
32
  * The package-owned root used for built-in skills. A missing root is an empty
18
33
  * built-in Source, which lets packages ship no built-in skills yet.
@@ -121,6 +136,7 @@ export function skillTreeBaseline(files) {
121
136
  export async function discoverSkillsFromSources(sources, contentAccess) {
122
137
  const candidates = [];
123
138
  for (const source of sources) {
139
+ const found = [];
124
140
  const files = [...await contentAccess.readSnapshot(source)].sort((left, right) => left.path.localeCompare(right.path));
125
141
  for (const file of files) {
126
142
  const normalizedPath = normalizeSourcePath(file.path);
@@ -129,23 +145,87 @@ export async function discoverSkillsFromSources(sources, contentAccess) {
129
145
  }
130
146
  const directoryPath = path.posix.dirname(normalizedPath);
131
147
  const segments = directoryPath === "." ? [] : directoryPath.split("/");
132
- if (segments.some((segment) => excludedLifecycleSegments.has(segment))) {
148
+ if (segments.some((segment) => excludedLifecycleSegments.has(segment) || unmanagedMirrorSegments.has(segment))
149
+ || segments.slice(0, -1).some((segment) => testTreeSegments.has(segment))) {
133
150
  continue;
134
151
  }
135
152
  const metadata = parseSkillFrontmatter(file.content);
136
153
  if (!metadata) {
137
154
  continue;
138
155
  }
139
- candidates.push({
140
- sourceId: source.id,
141
- path: directoryPath,
142
- name: metadata.name,
143
- description: metadata.description,
156
+ found.push({
157
+ candidate: { sourceId: source.id, path: directoryPath, name: metadata.name, description: metadata.description },
158
+ digest: createHash("sha256").update(file.content, "utf8").digest("hex"),
144
159
  });
145
160
  }
161
+ candidates.push(...await collapseIdenticalSkills(source, found, files, contentAccess));
146
162
  }
147
163
  return Object.freeze(candidates);
148
164
  }
165
+ /**
166
+ * One candidate per Skill name and whole-directory digest within a Source,
167
+ * because a repository often repeats the same Skill under several directories.
168
+ * Only candidates already sharing a name and SKILL.md digest are compared by
169
+ * their full tree. The canonical copy is the one outside dot-directories, then
170
+ * the shallowest, then the first by path, so the choice never depends on
171
+ * snapshot order. Same-named Skills with different content all stay. Survivors
172
+ * keep their path order.
173
+ */
174
+ async function collapseIdenticalSkills(source, found, snapshot, contentAccess) {
175
+ const sameManifest = new Map();
176
+ for (const { candidate, digest } of found) {
177
+ const key = `${candidate.name}|${digest}`;
178
+ sameManifest.set(key, (sameManifest.get(key) ?? 0) + 1);
179
+ }
180
+ const best = new Map();
181
+ for (const { candidate, digest } of found) {
182
+ const manifestKey = `${candidate.name}|${digest}`;
183
+ const key = sameManifest.get(manifestKey) > 1
184
+ ? `${manifestKey}|${await skillDirectoryDigest(source, candidate.path, snapshot, contentAccess)}`
185
+ : manifestKey;
186
+ const current = best.get(key);
187
+ if (current === undefined || compareCanonical(candidate.path, current.path) < 0) {
188
+ best.set(key, candidate);
189
+ }
190
+ }
191
+ const survivors = new Set(best.values());
192
+ return found.filter(({ candidate }) => survivors.has(candidate)).map(({ candidate }) => candidate);
193
+ }
194
+ /**
195
+ * Digest of every file of a Skill directory, by relative path and bytes. Reads
196
+ * the whole tree from the Source when the access supports it, otherwise uses the
197
+ * snapshot files. A tree that cannot be read digests to its own path, so it is
198
+ * never collapsed into another.
199
+ */
200
+ async function skillDirectoryDigest(source, directory, snapshot, contentAccess) {
201
+ const prefix = `${directory}/`;
202
+ try {
203
+ const files = contentAccess.readSkillTree
204
+ ? [...await contentAccess.readSkillTree(source, directory)]
205
+ : snapshot.filter((file) => file.path.startsWith(prefix)).map((file) => ({
206
+ path: file.path,
207
+ content: Buffer.from(file.content, "utf8"),
208
+ executable: false,
209
+ }));
210
+ return skillTreeBaseline(files.map((file) => ({ ...file, path: file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path }))).digest;
211
+ }
212
+ catch {
213
+ return `unreadable:${directory}`;
214
+ }
215
+ }
216
+ function compareCanonical(left, right) {
217
+ const rank = (candidate) => {
218
+ const segments = candidate.split("/");
219
+ return [segments.some((segment) => segment.startsWith(".")) ? 1 : 0, segments.length];
220
+ };
221
+ const [leftHidden, leftDepth] = rank(left);
222
+ const [rightHidden, rightDepth] = rank(right);
223
+ if (leftHidden !== rightHidden)
224
+ return leftHidden - rightHidden;
225
+ if (leftDepth !== rightDepth)
226
+ return leftDepth - rightDepth;
227
+ return left < right ? -1 : left > right ? 1 : 0;
228
+ }
149
229
  /** Collects scalar frontmatter values up to the closing delimiter; undefined when malformed or unclosed. */
150
230
  function readFrontmatterValues(lines) {
151
231
  const values = new Map();
@@ -2,8 +2,9 @@ import path from "node:path";
2
2
  import { assertSkillNotAppOwned } from "./app-owned-skills.js";
3
3
  import { assertSkillNotPackageOwned, loadPackageOwnedSkillFilter, } from "./package-owned-skills.js";
4
4
  import { pathsOverlap } from "./path-safety.js";
5
+ import { describeProjectTarget } from "./project-target.js";
5
6
  import { inspectProjectSkillInstallation, installProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
6
- import { AGENT_DEPOT_PACKAGE_VERSION, parseProjectManifest, } from "./project-manifest.js";
7
+ import { AGENT_DEPOT_PACKAGE_VERSION, defaultProjectManifestPath, parseProjectManifest, } from "./project-manifest.js";
7
8
  import { createSourceContentAccess } from "./skill-discovery.js";
8
9
  import { relativeProjectPath, selectionWithInstallation } from "./skill-update.js";
9
10
  import { executeSourceInstallationMethod, parseSourceInstallationMethod, resolveProjectSource, SourceNotFoundError, } from "./sources.js";
@@ -12,7 +13,7 @@ import { CliUsageError } from "./usage-error.js";
12
13
  * Shared install flow for one Skill selection (project or user-global):
13
14
  * resolve, inspect, preview, then execute. Used by the CLI and the TUI.
14
15
  */
15
- const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host]";
16
+ const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host] [--project <path>]";
16
17
  export async function selectInstallSource(options, operations) {
17
18
  const source = await findSource(operations, options.sourceId);
18
19
  const projectSource = projectSourceFromSource(source, options.ref, options.version);
@@ -314,12 +315,17 @@ export async function planSingleInstall(request, context) {
314
315
  }
315
316
  const { resolvedSource, resolved, installationInspection } = await resolveInstallInspection(selection, source, projectSource, context.root, request, context.operations, context.environment, context.sourceAccess);
316
317
  await assertInstallNotAppOwned(request, context, installationInspection);
317
- await assertInstallNotPackageOwned(selection, resolvedSource, context);
318
- return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection };
318
+ await assertInstallNotPackageOwned(request, selection, resolvedSource, context);
319
+ const projectTarget = request.scope === "project"
320
+ ? await describeProjectTarget(context.root, context.manifestPath ?? defaultProjectManifestPath(context.root))
321
+ : [];
322
+ return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection, projectTarget };
319
323
  }
320
324
  /** Prints the preview the user confirms before {@link executeSingleInstall}. */
321
325
  export function outputSingleInstallPreview(plan, output) {
322
- const { request, context, selection, source, resolved, inspection } = plan;
326
+ const { request, context, selection, source, resolved, inspection, projectTarget } = plan;
327
+ for (const line of projectTarget)
328
+ output(line);
323
329
  outputInstallPreview(selection, source, context.root, output, resolved.files, resolved.method, request.scope, inspection, request.overwrite, request.confirmed);
324
330
  }
325
331
  /**
@@ -329,7 +335,7 @@ export function outputSingleInstallPreview(plan, output) {
329
335
  export async function executeSingleInstall(plan, persist, output) {
330
336
  const { request, context, selection, resolvedSource, resolved, inspection } = plan;
331
337
  await assertInstallNotAppOwned(request, context, inspection);
332
- await assertInstallNotPackageOwned(selection, resolvedSource, context);
338
+ await assertInstallNotPackageOwned(request, selection, resolvedSource, context);
333
339
  const protectedPaths = assertNoManagedInstallationOverlap(context.existing, selection, inspection, context.root, request.overwrite);
334
340
  rejectInstallationCollision(inspection, request.overwrite);
335
341
  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);
@@ -375,8 +381,14 @@ async function assertInstallNotAppOwned(request, context, inspection) {
375
381
  await assertSkillNotAppOwned(path.basename(location.path), { ...context.environment, homeDirectory: context.root });
376
382
  }
377
383
  }
378
- /** Reloads bundle ownership from the selected Source and refuses its own Skill paths. */
379
- async function assertInstallNotPackageOwned(selection, source, context) {
384
+ /**
385
+ * Reloads bundle ownership from the selected Source and refuses its own Skill paths.
386
+ * Package state lives under the user's home, so a project install has nothing to
387
+ * check and must not read or lock state derived from the project root.
388
+ */
389
+ async function assertInstallNotPackageOwned(request, selection, source, context) {
390
+ if (request.scope !== "user-global")
391
+ return;
380
392
  const owned = await loadPackageOwnedSkillFilter({
381
393
  sources: [source],
382
394
  homeDirectory: context.root,
@@ -5,7 +5,10 @@ import { errorText, runBatch } from "./batch.js";
5
5
  import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
6
  import { BorderPanel, HostChecklist, PreviewConfirm, Working } from "./panel-parts.js";
7
7
  import { previewViewport } from "./preview-window.js";
8
+ import { theme } from "./theme.js";
8
9
  import { operationStart } from "./working-model.js";
10
+ /** Finished items shown under a running batch; older ones are counted. */
11
+ const MAX_DONE_LINES = 3;
9
12
  export const APP_KINDS = ["app-busy", "app-approve", "app-hosts", "app-confirm", "app-manual"];
10
13
  /** Narrows the view's mode without asserting a partial mode is an App mode. */
11
14
  export function isAppMode(mode) {
@@ -61,15 +64,21 @@ function appPreviewLines(mode) {
61
64
  ];
62
65
  }
63
66
  /** The busy mode of an App step; `previous` is the busy mode this step continues, so the clock keeps running. */
64
- function appBusy(label, previous) {
65
- return { kind: "app-busy", label, startedAt: operationStart(previous) };
67
+ function appBusy(label, previous, done) {
68
+ return { kind: "app-busy", label, startedAt: operationStart(previous), ...(done?.length ? { done } : {}) };
69
+ }
70
+ /** `Running App step: name (2/5)…`; the position is left out for a batch of one. */
71
+ function stepLabel(verb, entry, finished, total) {
72
+ return `${verb} App step: ${appName(entry)}${total > 1 ? ` (${finished + 1}/${total})` : ""}…`;
66
73
  }
67
74
  async function prepareNext(context, requests, results = [], failures = 0, previous) {
68
75
  const { manage, apps } = context;
69
- const running = appBusy("Preparing App step…", previous);
70
- manage.setMode(running);
71
76
  // Independent planning failures never stop the remaining marked Apps.
72
77
  const [request, ...remaining] = requests;
78
+ const running = appBusy(request ? stepLabel("Preparing", request.entry, results.length, results.length + requests.length) : "Finishing App steps…", previous,
79
+ // Once the batch is over its results are the Result panel, not a busy line.
80
+ request ? results : undefined);
81
+ manage.setMode(running);
73
82
  if (!request) {
74
83
  await manage.finish({ kind: failures > 0 ? "error" : "ok", text: results.join("\n") });
75
84
  return;
@@ -124,7 +133,7 @@ export async function startAppAction(context, entries, input) {
124
133
  }
125
134
  async function execute(context, mode) {
126
135
  const { manage, apps } = context;
127
- const running = appBusy("Running App step…");
136
+ const running = appBusy(stepLabel("Running", mode.plan.entry, mode.results.length, mode.results.length + 1 + mode.remaining.length), undefined, mode.results);
128
137
  manage.setMode(running);
129
138
  let manualRequired = false;
130
139
  const [outcome] = await runBatch([mode.plan], async (plan) => {
@@ -201,8 +210,10 @@ export function handleAppKey(context, mode, input, key) {
201
210
  /** Renders App prompts using the existing checklist and preview-confirm panels. */
202
211
  export function AppPanel({ mode }) {
203
212
  const { rows, columns } = useWindowSize();
204
- if (mode.kind === "app-busy")
205
- return _jsx(BorderPanel, { title: "App lifecycle", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
213
+ if (mode.kind === "app-busy") {
214
+ const done = mode.done ?? [];
215
+ return (_jsxs(BorderPanel, { title: "App lifecycle", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), done.length > MAX_DONE_LINES ? _jsxs(Text, { color: theme.muted, children: [done.length - MAX_DONE_LINES, " earlier finished"] }) : null, done.slice(-MAX_DONE_LINES).map((line, index) => _jsx(Text, { color: theme.muted, children: line }, index))] }));
216
+ }
206
217
  if (mode.kind === "app-hosts") {
207
218
  return (_jsxs(BorderPanel, { title: "App Host steps", children: [_jsx(Text, { children: "Checked = setup. Unchecked = teardown. Wiring is not tracked." }), _jsxs(Text, { children: ["Space/1-", mode.choices.length, " toggle \u00B7 j/k move \u00B7 Enter continue \u00B7 Esc cancel"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
208
219
  }
@@ -10,6 +10,7 @@ import { ProfileView } from "./profile-view.js";
10
10
  import { SourcesView, sourceRowHints } from "./sources-view.js";
11
11
  import { theme } from "./theme.js";
12
12
  import { UpdatesView } from "./updates-view.js";
13
+ import { createViewRetention, invalidateRetention } from "./view-retention.js";
13
14
  /** Sources derives its own hints from the selected row, so it is excluded here. */
14
15
  function hintsOf(view, catalogFocusedOnSource) {
15
16
  switch (view) {
@@ -20,7 +21,7 @@ function hintsOf(view, catalogFocusedOnSource) {
20
21
  case "profile":
21
22
  return "e export · i import · q quit";
22
23
  case "updates":
23
- return "j/k move · space mark · a all · Enter preview · u update · r check again · q quit";
24
+ return "j/k move · space mark · a all · Enter expand · u update · r check again · q quit";
24
25
  }
25
26
  }
26
27
  export function Header({ view, columns }) {
@@ -33,14 +34,28 @@ export function App({ operations, environment, onExit }) {
33
34
  const [capturing, setCapturing] = useState(false);
34
35
  // Updated synchronously by the views so keys in the same burst never see a stale capturing state.
35
36
  const capturingRef = useRef(false);
37
+ const [view, setView] = useState("sources");
38
+ // Catalog and Installations unmount on a tab change; this keeps what they loaded, for display only.
39
+ const [retention] = useState(createViewRetention);
40
+ // Sources, Updates and Profile may change what the retained data describes. Any mode they capture
41
+ // keys for (add, refresh, approve, confirm, import) can, so leaving after one drops the retention.
42
+ const viewRef = useRef(view);
43
+ const touched = useRef(false);
36
44
  const onCapturingChange = useCallback((value) => {
37
45
  capturingRef.current = value;
46
+ if (value && viewRef.current !== "catalog" && viewRef.current !== "installations")
47
+ touched.current = true;
38
48
  setCapturing(value);
39
49
  }, []);
40
- const [view, setView] = useState("sources");
41
50
  const [catalogSourceId, setCatalogSourceId] = useState();
42
51
  const [sourcesHints, setSourcesHints] = useState(sourceRowHints(undefined));
43
- const showView = (next) => setView(next);
52
+ const showView = (next) => {
53
+ if (touched.current)
54
+ invalidateRetention(retention);
55
+ touched.current = false;
56
+ viewRef.current = next;
57
+ setView(next);
58
+ };
44
59
  useKeys((input, key) => {
45
60
  if (capturingRef.current)
46
61
  return;
@@ -66,8 +81,8 @@ export function App({ operations, environment, onExit }) {
66
81
  });
67
82
  return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { view: view, columns: columns }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
68
83
  setCatalogSourceId(source.id);
69
- setView("catalog");
70
- } })) : 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
84
+ showView("catalog");
85
+ } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, retention: retention })) : 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, retention: retention })) }), _jsx(Text, { color: theme.muted, children: capturing
71
86
  ? columns < 64 ? "Enter submit · Esc cancel" : "Enter submit · Esc cancel · y/n confirm · space toggle"
72
87
  : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
73
88
  }
@@ -3,9 +3,10 @@ export const errorText = (error) => (error instanceof Error ? error.message : St
3
3
  * Applies the items one after another. A failure is reported for that item and
4
4
  * the rest still run, so the outcomes always cover every item in order.
5
5
  */
6
- export async function runBatch(items, run, describeFailure) {
6
+ export async function runBatch(items, run, describeFailure, onStart) {
7
7
  const outcomes = [];
8
8
  for (const item of items) {
9
+ onStart?.(item, outcomes.length + 1, items.length, outcomes);
9
10
  try {
10
11
  outcomes.push({ item, ok: true, line: await run(item) });
11
12
  }