@iseoane/agent-depot 0.6.9 → 0.6.10

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 CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.10] - 2026-11-06
11
+
12
+ ### Changed
13
+
14
+ - Project-scope `install` and `update` previews start with `Project: <absolute path>`,
15
+ in the CLI and the TUI, and warn without blocking when the directory is inside a Git
16
+ repository but not its top level, or has no manifest and no Host directory. The new
17
+ `--project <path>` option (also on `agent-depot tui`) selects the project directory
18
+ instead of the current one.
19
+ - The Profile import list in the TUI is grouped: additions by Source (App recipes
20
+ in their own group), then Unchanged items (collapsed) and Conflicts. Rows say
21
+ what importing does (`Add Skill ask-matt`, `Select Package ...`) and a conflict
22
+ gives one short reason such as `hosts: would add codex, opencode` instead of raw
23
+ JSON. Selection, preview and confirmation are unchanged. The App recipe label
24
+ now says imported recipes are written but not approved and not installed.
25
+ - `agent-depot import` preview and skipped-result conflict lines show the same
26
+ short reason instead of raw existing/incoming values, and version conflicts say
27
+ `existing` rather than `installed`. Unchanged and conflicting Skills whose name
28
+ appears in several Sources name their Source, and in the TUI Enter previews the
29
+ plan even when nothing is importable.
30
+
31
+ ### Fixed
32
+
33
+ - A project-scope Skill install no longer reads or locks Package state derived from the
34
+ project directory, which created `.local/state/agent-depot` inside the project on Linux.
35
+ - A recipe for another platform can be removed even when an App with the same
36
+ name is tracked on this machine; recipes that apply here keep their
37
+ protection.
38
+ - In Sources, clashing App recipes show their own file names and the duplicate
39
+ error names the files to repair; a recipe that does not apply here shows its
40
+ platform before its status, and approved recipes read `✓ approved`.
41
+ - On Windows, concurrent writes to the same state file (for example two App
42
+ approvals at once) no longer fail with `EPERM` on rename: the atomic write
43
+ retries briefly before giving up.
44
+
10
45
  ## [0.6.9] - 2026-11-04
11
46
 
12
47
  ### Fixed
package/README.md CHANGED
@@ -342,6 +342,23 @@ the install method; update execution is not part of this lifecycle.
342
342
 
343
343
  ## Project installation
344
344
 
345
+ Project scope acts on one project directory, the current directory unless you pass
346
+ `--project <path>` (`install`, `update` and `tui`). The path must be an existing directory
347
+ without symbolic links in it, and a relative path is resolved against the current
348
+ directory. Every project-scope preview, in the CLI and the TUI, starts with
349
+ `Project: <absolute path>`, so you can see where Skills will be written before
350
+ confirming. Two non-blocking warnings follow it when the directory looks unintended:
351
+ it is inside a Git repository but is not its top level (the warning names the top
352
+ level), or it has no `agent-depot.json` and none of `.agents`, `.claude`, `.codex`,
353
+ `.opencode` or `.pi`. A brand-new project still installs after the warning. Project
354
+ removal does not exist, so this applies to install and update only.
355
+ `agent-depot tui --project <path>` opens the TUI on that directory; a TUI batch
356
+ install shows the project line and warnings once for the whole batch.
357
+
358
+ ```
359
+ agent-depot install --scope project --project ../other-repo --source builtin:agent-depot --skill agent-depot-apprecipe --host pi --version latest --portable-v1
360
+ ```
361
+
345
362
  Project installation requires an explicit `--scope project`, Source ID, Skill
346
363
  path, one or more `--host` values (`pi`, `claude`, `codex`, or `opencode`), and
347
364
  a version policy (`--version latest` or `--version fixed:<value>`). External
@@ -554,8 +571,10 @@ the application supplies another start directory.
554
571
  to the Profile view.
555
572
  2. The selected file is parsed and previewed. If it is invalid or cannot be
556
573
  read, an inline error appears and the picker stays open so you can choose
557
- another file. The import list marks entries as **add**, **same** (skipped), or
558
- **conflict** (skipped with differences). Only additions can be selected. Use
574
+ another file. The import list is grouped: additions by Source (App recipes in
575
+ their own group), then **Unchanged** entries (collapsed, skipped) and
576
+ **Conflicts** (skipped, each with a one-line reason). Each row says what
577
+ importing does, and only additions can be selected. Use
559
578
  `space` to toggle a row, `a` to select all additions or clear them, and
560
579
  `Enter` to preview the selected additions. Press `Esc` to return to the
561
580
  Profile view.
@@ -576,6 +595,9 @@ If the App is installed or tracked, the TUI offers the existing uninstall flow;
576
595
  uninstall (or when not installed), a separate preview confirms deletion of only
577
596
  the recipe file or symlink. App data and Host configuration are not implicitly
578
597
  deleted. Invalid recipes must be repaired before their App state can be checked.
598
+ A recipe for another platform stays removable even when an App with the same
599
+ name is tracked here; only a recipe that applies here, or the exact recipe file the
600
+ App was installed from, is protected by tracked state.
579
601
 
580
602
  Safety model:
581
603
 
@@ -52,11 +52,11 @@ export function createAppOperations(environment = {}) {
52
52
  const names = new Map();
53
53
  for (const entry of entries) {
54
54
  if (entry.applicable && entry.recipe) {
55
- names.set(entry.recipe.name, (names.get(entry.recipe.name) ?? 0) + 1);
55
+ names.set(entry.recipe.name, [...(names.get(entry.recipe.name) ?? []), path.basename(entry.file)]);
56
56
  }
57
57
  }
58
- return entries.map((entry) => (entry.applicable && entry.recipe && names.get(entry.recipe.name) > 1
59
- ? { ...entry, error: `Duplicate applicable App name: ${entry.recipe.name}` }
58
+ return entries.map((entry) => (entry.applicable && entry.recipe && names.get(entry.recipe.name).length > 1
59
+ ? { ...entry, error: `Duplicate applicable App name: ${entry.recipe.name} (repair ${names.get(entry.recipe.name).join(", ")})` }
60
60
  : entry));
61
61
  }
62
62
  async function load(files) {
@@ -27,7 +27,9 @@ export async function planAppRecipeRemoval(apps, entry) {
27
27
  throw new Error(`Cannot verify App state: ${current.error ?? "invalid recipe"}. Repair the recipe before removing it.`);
28
28
  if (current.hash !== entry.hash || current.canonicalFile !== entry.canonicalFile)
29
29
  throw new Error("App recipe changed; review it again");
30
- const tracked = (await apps.trackedApps()).find((record) => record.name === current.recipe.name);
30
+ // A recipe for another platform owns no installation here, so a same-named record counts only when it points at this very file.
31
+ const tracked = (await apps.trackedApps()).find((record) => record.name === current.recipe.name
32
+ && (current.applicable || record.recipeFile === current.canonicalFile));
31
33
  const inspection = await apps.inspect(current);
32
34
  if (inspection.status !== "installed" && inspection.status !== "not installed" && inspection.status !== "not applicable here") {
33
35
  throw new Error(`Cannot verify App state (${inspection.status}); approve the recipe before removing it`);
@@ -1,12 +1,35 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { rename, unlink, writeFile } from "node:fs/promises";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ export const WINDOWS_RENAME_ATTEMPTS = 20;
5
+ const WINDOWS_RENAME_RETRY_MS = 5;
6
+ /**
7
+ * Windows reports EPERM, EACCES or EBUSY when a rename targets a file another rename or reader is
8
+ * still releasing, so concurrent writers to one destination must retry briefly instead of failing.
9
+ * The dependencies are injectable only so tests can force the Windows branch on any OS.
10
+ */
11
+ export async function renameWithWindowsRetry(from, to, deps = { rename, platform: process.platform, delay }) {
12
+ for (let attempt = 1;; attempt += 1) {
13
+ try {
14
+ await deps.rename(from, to);
15
+ return;
16
+ }
17
+ catch (error) {
18
+ const code = error.code;
19
+ const transient = deps.platform === "win32" && ["EPERM", "EACCES", "EBUSY"].includes(code ?? "");
20
+ if (!transient || attempt >= WINDOWS_RENAME_ATTEMPTS)
21
+ throw error;
22
+ await deps.delay(WINDOWS_RENAME_RETRY_MS * attempt);
23
+ }
24
+ }
25
+ }
3
26
  /** Write a unique temporary sibling, then publish it with rename. */
4
27
  export async function writeFileAtomically(file, contents, options = {}) {
5
28
  const temporaryPath = `${file}.${process.pid}.${randomUUID()}.tmp`;
6
29
  try {
7
30
  await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx", mode: options.mode });
8
31
  try {
9
- await rename(temporaryPath, file);
32
+ await renameWithWindowsRetry(temporaryPath, file);
10
33
  }
11
34
  catch (error) {
12
35
  const code = error.code;
package/dist/src/cli.js CHANGED
@@ -21,6 +21,7 @@ import { describeUnmanagedRemoval, removeInspectedUnmanagedSkills } from "./unma
21
21
  import { executeHostAddition, executeHostRemoval, planHostAddition, planHostRemoval } from "./skill-hosts.js";
22
22
  import { executeSkillRemoval, inspectSkillRemovals, planSkillRemoval, describeSkillRemoval, recheckSkillRemoval, removeSkillFiles, removeSkillsAndRecords, assertManagedInstallationRecordsUnchanged, assertRemovalInspectionUnchanged, selectUserGlobalSkills, SkillSelectionError, userGlobalRemovalOptions, } from "./skill-removal.js";
23
23
  import { defaultProjectSkillTreeAccess, executeOrRejectMethod, executeSingleInstall, externalMethodFailure, isValidFixedVersion, findSource, installOne, outputInstallationResult, outputInstallPreview, outputSingleInstallPreview, planSingleInstall, refreshResolvedSource, rejectInstallationCollision, resolveSelection, rollbackTransactions, } from "./skill-install.js";
24
+ import { describeProjectTarget, resolveProjectRootOption } from "./project-target.js";
24
25
  import { CliUsageError } from "./usage-error.js";
25
26
  import { createSelfUpdateOperations } from "./self-update.js";
26
27
  import { selectionWithInstallation } from "./skill-update.js";
@@ -34,10 +35,11 @@ const USAGE = [
34
35
  " agent-depot source remove <id> [--skill <id|path>...] [--all] [--yes]",
35
36
  " agent-depot source migrate <old-id> <new-id> (--skill <path>... | --all) [--yes]",
36
37
  " agent-depot discover <source-id> [source-id...]",
37
- " agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--yes] [--confirm-additional-host]",
38
- " agent-depot install --scope project --manifest --portable-v1 [--yes] [--confirm-additional-host]",
39
- " agent-depot update check --scope <project|user-global> [--package <selection>...]",
40
- " agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>... | --package <selection>...) [--yes] [--confirm-path <relative-path>...]",
38
+ " agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--yes] [--confirm-additional-host] [--project <path>]",
39
+ " agent-depot install --scope project --manifest --portable-v1 [--yes] [--confirm-additional-host] [--project <path>]",
40
+ " agent-depot update check --scope <project|user-global> [--package <selection>...] [--project <path>]",
41
+ " agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>... | --package <selection>...) [--yes] [--confirm-path <relative-path>...] [--project <path>]",
42
+ " --project <path> picks the project directory for --scope project (default: the current directory).",
41
43
  " Apps and Packages run after Skills; --all --scope project skips Apps and Packages.",
42
44
  " agent-depot self update [--yes] (npm global installations only; otherwise prints manual guidance)",
43
45
  " agent-depot skill remove <id|path>... [--yes]",
@@ -57,14 +59,14 @@ const USAGE = [
57
59
  " agent-depot package forget <selection> --host <host> --yes",
58
60
  ` ${EXPORT_USAGE}`,
59
61
  ` ${IMPORT_USAGE}`,
60
- " agent-depot tui",
62
+ " agent-depot tui [--project <path>]",
61
63
  " agent-depot --version",
62
64
  "",
63
65
  "Options for every command, anywhere on the line:",
64
66
  " --verbose stream the full child output (npm, pi, git, App commands) to stderr line by line as it runs,",
65
67
  " instead of a spinner and the last few lines; stdout is unchanged.",
66
68
  ].join("\n");
67
- 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]";
69
+ 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>]";
68
70
  const DISCOVER_USAGE = "Usage: agent-depot discover <source-id> [source-id...]\nSelect at least one Source ID explicitly; run `agent-depot source list` to see registered Sources";
69
71
  const REMOVE_SOURCE_USAGE = "Usage: agent-depot source remove <id> [--skill <id|path>...] [--all] [--yes]";
70
72
  const MIGRATE_SOURCE_USAGE = "Usage: agent-depot source migrate <old-id> <new-id> (--skill <path>... | --all) [--yes]";
@@ -144,16 +146,17 @@ const COMMANDS = new Map([
144
146
  }
145
147
  return 0;
146
148
  }],
147
- ["tui", async (_values, { operations, dependencies }) => {
149
+ ["tui", async (values, { operations, dependencies }) => {
148
150
  const isInteractive = dependencies.isInteractive ?? (() => process.stdin.isTTY === true);
149
151
  if (!isInteractive()) {
150
152
  throw new Error("agent-depot tui requires an interactive terminal");
151
153
  }
154
+ const projectRoot = await parseTuiProjectRoot(values, dependencies);
152
155
  // Loaded lazily so non-interactive commands never import Ink/React.
153
156
  const renderTui = dependencies.renderTui ?? (await import("./tui/render.js")).renderTui;
154
157
  await renderTui(operations, {
155
158
  homeDirectory: dependencies.homeDirectory,
156
- projectRoot: dependencies.projectRoot,
159
+ projectRoot,
157
160
  sourceAccess: dependencies.sourceAccess,
158
161
  installationOptions: dependencies.installationOptions,
159
162
  projectManifestStore: dependencies.projectManifestStore,
@@ -169,6 +172,14 @@ const COMMANDS = new Map([
169
172
  return 0;
170
173
  }],
171
174
  ]);
175
+ /** `tui [--project <path>]`: the project directory the TUI acts on; also where its file browser opens. */
176
+ async function parseTuiProjectRoot(values, dependencies) {
177
+ if (values.length === 0)
178
+ return dependencies.projectRoot;
179
+ if (values.length !== 2 || values[0] !== "--project")
180
+ throw new CliUsageError(USAGE);
181
+ return resolveProjectRootOption(requireOptionValue(values, 1, "--project"));
182
+ }
172
183
  async function runSourceRefresh(values, { operations, output, progress }) {
173
184
  if (values.length < 1 || values.length > 2 || (values.length === 2 && values[1] !== "--yes")) {
174
185
  throw new CliUsageError("Usage: agent-depot source refresh <id> [--yes]");
@@ -816,6 +827,7 @@ async function runSelfUpdate(argv, dependencies, output, progress) {
816
827
  }
817
828
  async function runUpdate(argv, operations, dependencies, output, progress) {
818
829
  const options = parseUpdateOptions(argv);
830
+ const projectRoot = options.project === undefined ? dependencies.projectRoot : await resolveProjectRootOption(options.project);
819
831
  const apps = dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory });
820
832
  const packages = packageOperationsFor(dependencies, operations);
821
833
  const requestedPackages = options.scope === "user-global" && options.requestedPackages.length > 0
@@ -847,7 +859,7 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
847
859
  scope: options.scope,
848
860
  operations,
849
861
  ...(dependencies.homeDirectory === undefined ? {} : { homeDirectory: dependencies.homeDirectory }),
850
- ...(dependencies.projectRoot === undefined ? {} : { projectRoot: dependencies.projectRoot }),
862
+ ...(projectRoot === undefined ? {} : { projectRoot }),
851
863
  ...(dependencies.sourceAccess === undefined ? {} : { sourceAccess: dependencies.sourceAccess }),
852
864
  ...(dependencies.projectManifestStore === undefined ? {} : { projectManifestStore: dependencies.projectManifestStore }),
853
865
  ...(dependencies.installationOptions?.fileSystem === undefined ? {} : { installationFileSystem: dependencies.installationOptions.fileSystem }),
@@ -857,7 +869,9 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
857
869
  output(`Error (Skills, ${options.scope}): ${error instanceof Error ? error.message : String(error)}`);
858
870
  }
859
871
  if (loaded) {
860
- const { assessment, installed, context } = loaded;
872
+ const { assessment, installed, context, projectTarget } = loaded;
873
+ for (const line of projectTarget)
874
+ output(line);
861
875
  const userGlobalInventory = options.action === "check" && options.scope === "user-global"
862
876
  ? await scanUserGlobalSkillInventory({ homeDirectory: context.projectRoot, managedInstallations: installed, reportWarning: output })
863
877
  : undefined;
@@ -874,7 +888,7 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
874
888
  if (options.action === "check") {
875
889
  return appCheckFailed || packageCheckFailed ? 1 : 0;
876
890
  }
877
- const { assessment, context } = loaded;
891
+ const { assessment, context, projectTarget } = loaded;
878
892
  const selectedApps = options.all ? appUpdates.filter(item => item.status === "update available")
879
893
  : options.requestedApps.map(name => {
880
894
  const matches = appUpdates.filter(item => item.entry.recipe?.name === name);
@@ -927,6 +941,10 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
927
941
  const { plans, failures: previewFailures, nonCanonicalPaths } = selected.length === 0
928
942
  ? await previewUpdateCandidates(selected, context)
929
943
  : await progress.run("Previewing Skill updates", () => previewUpdateCandidates(selected, context));
944
+ // The assessment output above can be long, so the previews restate the target.
945
+ if (selected.length > 0)
946
+ for (const line of projectTarget)
947
+ output(line);
930
948
  // Successes and failures are reported in candidate order, as the preview runs.
931
949
  const failedIds = new Set(previewFailures.map(({ item }) => item.id));
932
950
  const plansById = new Map(plans.map((plan) => [plan.item.id, plan]));
@@ -1049,6 +1067,7 @@ function parseUpdateOptions(argv) {
1049
1067
  throw new CliUsageError(USAGE);
1050
1068
  }
1051
1069
  let scope;
1070
+ let project;
1052
1071
  let all = false;
1053
1072
  let confirmed = false;
1054
1073
  const requested = [];
@@ -1060,6 +1079,9 @@ function parseUpdateOptions(argv) {
1060
1079
  case "--scope":
1061
1080
  scope = requireOptionValue(argv, ++index, "--scope");
1062
1081
  break;
1082
+ case "--project":
1083
+ project = requireOptionValue(argv, ++index, "--project");
1084
+ break;
1063
1085
  case "--confirm-path":
1064
1086
  confirmedPaths.push(normalizeConfirmedPath(requireOptionValue(argv, ++index, "--confirm-path")));
1065
1087
  break;
@@ -1085,6 +1107,8 @@ function parseUpdateOptions(argv) {
1085
1107
  if (scope !== "project" && scope !== "user-global") {
1086
1108
  throw new CliUsageError("Update scope must be explicit: use --scope project or --scope user-global");
1087
1109
  }
1110
+ if (project !== undefined && scope !== "project")
1111
+ throw new CliUsageError("--project only applies to --scope project");
1088
1112
  if (action === "check" && (all || requested.length > 0 || requestedApps.length > 0 || confirmed || confirmedPaths.length > 0)) {
1089
1113
  throw new CliUsageError("update check only accepts --scope and optional --package filters; use update apply to select and apply updates");
1090
1114
  }
@@ -1111,6 +1135,7 @@ function parseUpdateOptions(argv) {
1111
1135
  requestedPackages: Object.freeze(requestedPackages),
1112
1136
  confirmed,
1113
1137
  confirmedPaths: Object.freeze(confirmedPaths),
1138
+ ...(project === undefined ? {} : { project }),
1114
1139
  };
1115
1140
  }
1116
1141
  /** Normalizes a --confirm-path value like the relative path shown in the preview. */
@@ -1169,7 +1194,9 @@ async function runInstall(argv, operations, dependencies, output, progress) {
1169
1194
  await runUserGlobalInstall(options, operations, dependencies, sourceAccess, output, progress);
1170
1195
  return;
1171
1196
  }
1172
- const projectRoot = path.resolve(dependencies.projectRoot ?? process.cwd());
1197
+ const projectRoot = options.project === undefined
1198
+ ? path.resolve(dependencies.projectRoot ?? process.cwd())
1199
+ : await resolveProjectRootOption(options.project);
1173
1200
  const manifestStore = dependencies.projectManifestStore ?? new ProjectManifestStore(defaultProjectManifestPath(projectRoot));
1174
1201
  const context = { options, operations, dependencies, sourceAccess, output, progress, projectRoot, manifestStore };
1175
1202
  if (options.fromManifest) {
@@ -1214,6 +1241,8 @@ async function runManifestInstall(context) {
1214
1241
  // Two different manifest selections can have different Source/path
1215
1242
  // identities while still resolving to the same basename directory.
1216
1243
  const inspections = await inspectManifestInstallations(resolved, projectRoot, sourceAccess, dependencies);
1244
+ for (const line of await describeProjectTarget(projectRoot, manifestStore.path))
1245
+ output(line);
1217
1246
  for (let index = 0; index < resolved.length; index += 1) {
1218
1247
  const item = resolved[index];
1219
1248
  outputInstallPreview(item.selection, item.source, projectRoot, output, item.files, item.method, "project", inspections[index], false, options.confirmed);
@@ -1269,7 +1298,7 @@ async function runSingleProjectInstall(context) {
1269
1298
  }
1270
1299
  const existing = await manifestStore.load();
1271
1300
  const request = { ...options, scope: "project", sourceId: options.sourceId, skillPath: options.skillPath, version: options.version };
1272
- const plan = await progress.run(`Resolving ${options.skillPath}`, () => planSingleInstall(request, { operations, sourceAccess, environment: dependencies, root: projectRoot, existing: existing.skills, recordedIn: manifestStore.path }), { mayPrompt: refreshesSource(options) });
1301
+ const plan = await progress.run(`Resolving ${options.skillPath}`, () => planSingleInstall(request, { operations, sourceAccess, environment: dependencies, root: projectRoot, existing: existing.skills, recordedIn: manifestStore.path, manifestPath: manifestStore.path }), { mayPrompt: refreshesSource(options) });
1273
1302
  outputSingleInstallPreview(plan, output);
1274
1303
  requireConfirmation(options.confirmed, "Installation not confirmed; rerun with --yes to continue");
1275
1304
  await progress.run(`Installing ${options.skillPath}`, () => executeSingleInstall(plan, async (record) => {
@@ -1309,6 +1338,7 @@ function collectHosts(hosts, value) {
1309
1338
  }
1310
1339
  const INSTALL_VALUE_OPTIONS = new Map([
1311
1340
  ["--scope", (draft, value) => { draft.scope = value; }],
1341
+ ["--project", (draft, value) => { draft.project = value; }],
1312
1342
  ["--source", (draft, value) => { draft.sourceId = value; }],
1313
1343
  ["--skill", (draft, value) => { draft.skillPath = value; }],
1314
1344
  ["--host", (draft, value) => { collectHosts(draft.hosts, value); }],
@@ -1353,6 +1383,9 @@ function finalizeInstallOptions(draft) {
1353
1383
  if (scope !== "project" && scope !== "user-global") {
1354
1384
  throw new CliUsageError("Installation scope must be explicit: use --scope project or --scope user-global");
1355
1385
  }
1386
+ if (draft.project !== undefined && scope !== "project") {
1387
+ throw new CliUsageError("--project only applies to --scope project");
1388
+ }
1356
1389
  const selectsSkill = sourceId !== undefined || skillPath !== undefined || ref !== undefined || hosts.length > 0;
1357
1390
  if (fromManifest) {
1358
1391
  if (scope !== "project") {
@@ -1378,6 +1411,7 @@ function finalizeInstallOptions(draft) {
1378
1411
  confirmed: draft.confirmed,
1379
1412
  overwrite,
1380
1413
  confirmAdditionalHostExposure: draft.confirmAdditionalHostExposure,
1414
+ ...(draft.project === undefined ? {} : { project: draft.project }),
1381
1415
  };
1382
1416
  }
1383
1417
  function parseMethodOption(value) {
@@ -8,6 +8,9 @@ export function pathsOverlap(left, right) {
8
8
  (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ||
9
9
  (!reverse.startsWith(`..${path.sep}`) && !path.isAbsolute(reverse));
10
10
  }
11
+ /** A path component that is a symbolic link or junction; the message names it. */
12
+ export class SymlinkPathError extends Error {
13
+ }
11
14
  /** Walks every existing component of a path and rejects the first symbolic link. */
12
15
  export async function assertNoSymlinkPath(candidate, label) {
13
16
  const absolute = path.resolve(candidate);
@@ -18,7 +21,7 @@ export async function assertNoSymlinkPath(candidate, label) {
18
21
  current = path.join(current, segment);
19
22
  try {
20
23
  if ((await lstat(current)).isSymbolicLink()) {
21
- throw new Error(`${label} contains a symbolic link: ${current}`);
24
+ throw new SymlinkPathError(`${label} contains a symbolic link: ${current}`);
22
25
  }
23
26
  }
24
27
  catch (error) {
@@ -45,7 +45,7 @@ export function describeProfileReplica(replica) {
45
45
  case "package": return `Installed Package ${describeCoordinates(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 { describeCoordinates, 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 ${describeCoordinates(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([]),