@iseoane/agent-depot 0.6.5 → 0.6.7

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,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.7] - 2026-11-04
11
+
12
+ ### Fixed
13
+
14
+ - Claude Skill installations on Windows now use directory junctions instead of
15
+ privilege-sensitive directory symlinks. Installation, additional Host exposure
16
+ and overwrite share the same creation policy; POSIX directory symlinks remain
17
+ unchanged. Rollback removes the managed link without following its target.
18
+ - User-global Skill removal can now explicitly forget a missing installation
19
+ record, without removing files, so the Skill can be selected again in Catalog.
20
+ Recovery refuses unsafe paths, remaining Claude locations, overlapping records
21
+ and changes to the confirmed installation state. CLI and TUI previews and
22
+ completion messages distinguish record-only recovery from file removal.
23
+
24
+ ## [0.6.6] - 2026-10-29
25
+
26
+ ### Fixed
27
+
28
+ - `self update` no longer reports every Windows install as unsupported: an
29
+ `npm.cmd` shim is now accepted when the `npm/bin/npm-cli.js` that ships next
30
+ to it is present, and the global-root provenance checks still apply (#70).
31
+
10
32
  ## [0.6.5] - 2026-10-29
11
33
 
12
34
  ### Added
package/README.md CHANGED
@@ -184,21 +184,28 @@ segment when that is unique; unknown or ambiguous selections fail before anythin
184
184
  changes. It previews every deletion (with adopted/modified warnings), requires
185
185
  `--yes`, rechecks the targets, then deletes the files and their installation
186
186
  records. It is user-global only: project installations and manifests are never
187
- read or changed, and the Git cache is retained.
187
+ read or changed, and the Git cache is retained. If a managed Skill's recorded
188
+ installation is verified absent, the preview instead offers an explicit
189
+ `forget missing installation record` action. Confirming removes only that stale
190
+ record; it never removes files. Existing files, including any Claude location,
191
+ unsafe ancestors, ambiguous overlapping records, or filesystem errors fail
192
+ closed. After the record is forgotten, the Skill can be selected and installed
193
+ again through the normal flow.
188
194
 
189
195
  `skill host add <id|path> --host <host>... [--yes] [--confirm-additional-host]`
190
196
  exposes an already installed user-global Skill to more Hosts without changing its
191
197
  content or version, then records the new Hosts. Non-Claude Hosts share the
192
- canonical `~/.agents/skills/<skill>` location and Claude is a symlink to it, so
193
- only a missing Claude symlink is ever created. The preview lists the new
194
- locations; because it exposes the Skill to more Hosts it needs both `--yes` and
198
+ canonical `~/.agents/skills/<skill>` location and Claude uses a managed directory
199
+ link to it (relative symlink on POSIX; absolute-target junction on Windows), so
200
+ only a missing Claude link is ever created. The preview lists the new locations;
201
+ because it exposes the Skill to more Hosts it needs both `--yes` and
195
202
  `--confirm-additional-host`. It refuses a Claude location that already holds
196
- anything other than the managed symlink, and a Skill that is not installed at the
203
+ anything other than the managed link, and a Skill that is not installed at the
197
204
  canonical location (exposing it would require copying it).
198
205
 
199
206
  `skill remove <id|path> --host <host>... [--yes]` removes only those Hosts. Only
200
- the managed Claude symlink can be deleted this way; the canonical directory backs
201
- every remaining Host (including the Claude symlink) and stays until the last Host
207
+ the managed Claude directory link can be deleted this way; the canonical directory
208
+ backs every remaining Host (including the Claude link) and stays until the last Host
202
209
  is removed. Removing every recorded Host is a full removal with the usual
203
210
  adopted/modified warnings. The targets are rechecked before deleting and the
204
211
  record is updated afterwards. Project installations are not affected.
@@ -310,8 +317,9 @@ select candidates, or install anything automatically.
310
317
  ## User-global installation
311
318
 
312
319
  User-global installations use the same canonical Host locations under the user
313
- home directory (`.agents/skills/<skill>` and, when requested, a Claude symlink
314
- at `.claude/skills/<skill>`). They are independent from project manifests and
320
+ home directory (`.agents/skills/<skill>` and, when requested, a Claude directory
321
+ link at `.claude/skills/<skill>`: relative symlink on POSIX, absolute-target
322
+ junction on Windows). They are independent from project manifests and
315
323
  are recorded in the shared per-user Source state, so separate CLI invocations
316
324
  reuse the same installation records. The selected Source, Skill, Hosts, and
317
325
  actual adopted or installed location are previewed before `--yes` confirmation.
@@ -403,8 +411,9 @@ process crash after the original is moved can leave the retained
403
411
  inspect the backup and target, and manually rename the retained backup back to the intended installation path when recovery is needed;
404
412
  then rerun the install. External installation methods run only after the filesystem
405
413
  and selected-scope state transaction succeeds.
406
- Claude is exposed through a symlink to the canonical `.agents/skills/<skill>`
407
- installation.
414
+ Claude is exposed through a managed directory link to the canonical
415
+ `.agents/skills/<skill>` installation (relative symlink on POSIX, absolute-target
416
+ junction on Windows).
408
417
 
409
418
  ## User-provided installation methods
410
419
 
package/dist/src/cli.js CHANGED
@@ -15,11 +15,11 @@ import { createProgress, NO_PROGRESS } from "./progress.js";
15
15
  import { skillTreeBaseline } from "./skill-discovery.js";
16
16
  import { BUILT_IN_SOURCE, BuiltInSourceError, createSourceOperations, resolveProjectSource, SourceMetadataError, SourceNotFoundError, } from "./sources.js";
17
17
  import { AGENT_DEPOT_PACKAGE_NAME, AGENT_DEPOT_PACKAGE_VERSION, builtinPinMismatch, defaultProjectManifestPath, parseProjectManifest, ProjectManifestStore, PROJECT_HOSTS, } from "./project-manifest.js";
18
- import { inspectProjectSkillInstallation, assertNoOverlappingProjectSkillRemovalTargets, } from "./project-installation.js";
18
+ import { inspectAbsentManagedSkillRecord, inspectProjectSkillInstallation, assertNoOverlappingProjectSkillRemovalTargets, } from "./project-installation.js";
19
19
  import { selectUpdateBatch, UpdateBatchSelectionError, } from "./update-batch.js";
20
20
  import { describeUnmanagedRemoval, removeInspectedUnmanagedSkills } from "./unmanaged-removal.js";
21
21
  import { executeHostAddition, executeHostRemoval, planHostAddition, planHostRemoval } from "./skill-hosts.js";
22
- import { executeSkillRemoval, inspectSkillRemovals, planSkillRemoval, describeSkillRemoval, recheckSkillRemoval, removeSkillFiles, removeSkillsAndRecords, selectUserGlobalSkills, SkillSelectionError, userGlobalRemovalOptions, } from "./skill-removal.js";
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
24
  import { CliUsageError } from "./usage-error.js";
25
25
  import { createSelfUpdateOperations } from "./self-update.js";
@@ -239,7 +239,10 @@ async function runSourceRemoval(argv, operations, dependencies, output, progress
239
239
  const selected = options.all
240
240
  ? dependent
241
241
  : resolveRemovalSelections(dependent, options.requested);
242
- const removalOptions = userGlobalRemovalOptions(path.resolve(dependencies.homeDirectory ?? homedir()), dependencies.installationOptions);
242
+ const removalOptions = userGlobalRemovalOptions(path.resolve(dependencies.homeDirectory ?? homedir()), {
243
+ ...dependencies.installationOptions,
244
+ otherInstallationPaths: installations.filter((record) => !selected.includes(record)).flatMap((record) => record.installation ? [record.installation.path] : []),
245
+ });
243
246
  output(`Preview: remove Git Source ${source.id} from ${source.url}`);
244
247
  output(`Dependent user-global Skills (${dependent.length}):`);
245
248
  for (const [index, selection] of dependent.entries()) {
@@ -255,14 +258,33 @@ async function runSourceRemoval(argv, operations, dependencies, output, progress
255
258
  requireConfirmation(options.confirmed, "Removal not confirmed; rerun with --yes after reviewing every Source and Skill preview");
256
259
  // Recheck every selected target before deleting the first one. A changed later
257
260
  // target must never result in a partially removed batch.
258
- await progress.run("Removing Skill files", async () => {
259
- const rechecked = await recheckSkillRemoval(plan, removalOptions);
260
- await removeSkillFiles(plan, rechecked, removalOptions);
261
+ const rechecked = await progress.run("Removing Skill files", async () => {
262
+ const current = await recheckSkillRemoval(plan, removalOptions);
263
+ assertManagedInstallationRecordsUnchanged(installations, await operations.listUserGlobalInstallations());
264
+ await removeSkillFiles(plan, current, removalOptions);
265
+ return current;
266
+ });
267
+ // Source removal persists records rather than reconciling them one by one.
268
+ // Revalidate every record-only target and the complete record snapshot just
269
+ // before that persistence boundary so a stale preview cannot forget a changed record.
270
+ await progress.run("Revalidating Skill records before Source removal", async () => {
271
+ for (const [index, inspection] of rechecked.inspections.entries()) {
272
+ if (!inspection.recordOnly)
273
+ continue;
274
+ const latest = await inspectAbsentManagedSkillRecord(rechecked.selections[index], removalOptions);
275
+ assertRemovalInspectionUnchanged(inspection, latest);
276
+ }
277
+ assertManagedInstallationRecordsUnchanged(installations, await operations.listUserGlobalInstallations());
261
278
  });
262
279
  await progress.run(`Removing Git Source ${source.id}`, () => operations.removeGitSource(source.id, selected));
263
280
  output(`Removed Git Source: ${source.id}`);
264
281
  if (selected.length > 0) {
265
- output(`Removed ${selected.length} dependent user-global Skill${selected.length === 1 ? "" : "s"}; other dependent Skills remain tracked`);
282
+ const forgotten = plan.inspections.filter((inspection) => inspection.recordOnly).length;
283
+ const removed = selected.length - forgotten;
284
+ if (removed > 0)
285
+ output(`Removed ${removed} dependent user-global Skill${removed === 1 ? "" : "s"}; other dependent Skills remain tracked`);
286
+ if (forgotten > 0)
287
+ output(`Forgot ${forgotten} missing dependent Skill installation record${forgotten === 1 ? "" : "s"}; no files were removed`);
266
288
  }
267
289
  }
268
290
  function parseMigrateSourceOptions(argv) {
@@ -461,7 +483,10 @@ async function runSkillRemoval(argv, operations, dependencies, output, progress)
461
483
  }
462
484
  const installations = await operations.listUserGlobalInstallations();
463
485
  const selected = selectManagedSkills(installations, requested);
464
- const removalOptions = userGlobalRemovalOptions(path.resolve(dependencies.homeDirectory ?? homedir()), dependencies.installationOptions);
486
+ const removalOptions = userGlobalRemovalOptions(path.resolve(dependencies.homeDirectory ?? homedir()), {
487
+ ...dependencies.installationOptions,
488
+ otherInstallationPaths: installations.filter((record) => !selected.includes(record)).flatMap((record) => record.installation ? [record.installation.path] : []),
489
+ });
465
490
  if (hosts.length > 0) {
466
491
  await runSkillHostRemoval(selected[0], hosts, installations, confirmed, operations, removalOptions, output, progress);
467
492
  return;
@@ -473,7 +498,12 @@ async function runSkillRemoval(argv, operations, dependencies, output, progress)
473
498
  output(" Installation records are reconciled after each successful Skill removal");
474
499
  requireConfirmation(confirmed, "Removal not confirmed; rerun with --yes after reviewing every Skill preview");
475
500
  await progress.run(`Removing ${selected.length} user-global Skill${selected.length === 1 ? "" : "s"}`, () => executeSkillRemoval(plan, operations, removalOptions, installations));
476
- output(`Removed ${selected.length} user-global Skill${selected.length === 1 ? "" : "s"}`);
501
+ const forgotten = plan.inspections.filter((inspection) => inspection.recordOnly).length;
502
+ const removed = selected.length - forgotten;
503
+ if (removed > 0)
504
+ output(`Removed ${removed} user-global Skill${removed === 1 ? "" : "s"}`);
505
+ if (forgotten > 0)
506
+ output(`Forgot ${forgotten} missing user-global Skill installation record${forgotten === 1 ? "" : "s"}; no files were removed`);
477
507
  }
478
508
  /** Resolves `<id|path>` arguments to managed installations, reporting selection problems as usage errors. */
479
509
  function selectManagedSkills(installations, requested) {
@@ -488,17 +518,24 @@ function selectManagedSkills(installations, requested) {
488
518
  }
489
519
  async function runSkillHostRemoval(selection, hosts, installations, confirmed, operations, options, output, progress) {
490
520
  const plan = await planHostRemoval(selection, hosts, options).catch(rethrowSelectionError);
521
+ const recordOnly = plan.kind === "full" && plan.plan.inspections.every((inspection) => inspection.recordOnly);
491
522
  output(plan.kind === "full"
492
- ? "Preview: remove 1 user-global Skill (every recorded Host is removed)"
523
+ ? recordOnly
524
+ ? "Preview: forget 1 missing user-global Skill installation record (every recorded Host is removed)"
525
+ : "Preview: remove 1 user-global Skill (every recorded Host is removed)"
493
526
  : `Preview: remove ${hosts.length} Host${hosts.length === 1 ? "" : "s"} from 1 user-global Skill`);
494
527
  for (const line of plan.kind === "full" ? plan.plan.preview : plan.preview)
495
528
  output(line);
496
- output(" The installation record is reconciled after the locations are removed");
529
+ output(recordOnly
530
+ ? " Only the installation record will be forgotten; no files are removed"
531
+ : " The installation record is reconciled after the locations are removed");
497
532
  requireConfirmation(confirmed, "Removal not confirmed; rerun with --yes after reviewing the preview");
498
533
  await progress.run(plan.kind === "full" ? "Removing 1 user-global Skill" : `Removing Hosts from ${selection.path}`, () => executeHostRemoval(plan, operations, options, installations));
499
- output(plan.kind === "full"
500
- ? "Removed 1 user-global Skill"
501
- : `Removed hosts ${plan.removed.join(", ")} from ${JSON.stringify(selection.path)}`);
534
+ output(plan.kind === "full" && plan.plan.inspections.every((inspection) => inspection.recordOnly)
535
+ ? `Forgot missing installation record for ${JSON.stringify(selection.path)}; no files were removed`
536
+ : plan.kind === "full"
537
+ ? "Removed 1 user-global Skill"
538
+ : `Removed hosts ${plan.removed.join(", ")} from ${JSON.stringify(selection.path)}`);
502
539
  }
503
540
  async function runSkillHostAdd(argv, operations, dependencies, output, progress) {
504
541
  const requested = [];
@@ -659,10 +696,13 @@ function assertNoManagedUnmanagedOverlap(managed, unmanaged, message) {
659
696
  }
660
697
  }
661
698
  }
662
- function outputUninstallSummary(options, removedManagedCount, output) {
663
- if (options.skills) {
699
+ function outputUninstallSummary(options, removedManagedCount, forgottenManagedCount, output) {
700
+ if (options.skills && removedManagedCount > 0) {
664
701
  output(`Removed ${removedManagedCount} managed user-global Skill${removedManagedCount === 1 ? "" : "s"}`);
665
702
  }
703
+ if (options.skills && forgottenManagedCount > 0) {
704
+ output(`Forgot ${forgottenManagedCount} missing managed user-global Skill installation record${forgottenManagedCount === 1 ? "" : "s"}; no files were removed`);
705
+ }
666
706
  if (options.unmanagedSkills.length > 0) {
667
707
  output(`Permanently deleted ${options.unmanagedSkills.length} unmanaged user-global Skill${options.unmanagedSkills.length === 1 ? "" : "s"}`);
668
708
  }
@@ -723,7 +763,8 @@ async function runUninstall(argv, operations, dependencies, output, progress) {
723
763
  if (options.data) {
724
764
  await progress.run("Removing catalog data", () => operations.removeCatalogData());
725
765
  }
726
- outputUninstallSummary(options, selectedInstallations.length, output);
766
+ const forgottenManagedCount = managedRechecked.inspections.filter((inspection) => inspection.recordOnly).length;
767
+ outputUninstallSummary(options, selectedInstallations.length - forgottenManagedCount, forgottenManagedCount, output);
727
768
  }
728
769
  function selfUpdateOperationsFor(dependencies) {
729
770
  if (dependencies.selfUpdateOperations !== undefined)
@@ -56,7 +56,7 @@ export async function inspectProjectSkillRemoval(selection, options) {
56
56
  actualBaseline.digest !== selection.installation.baseline.digest;
57
57
  const paths = [targetPath];
58
58
  // A Claude exposure created by Agent Depot is removable only when it is the
59
- // exact relative symlink to this canonical installation. Never follow or
59
+ // exact managed directory link to this canonical installation. Never follow or
60
60
  // remove an unrelated path merely because the Skill selected Claude.
61
61
  const canonicalPath = path.join(projectRoot, ".agents", "skills", skillName);
62
62
  const claudePath = path.join(projectRoot, ".claude", "skills", skillName);
@@ -65,11 +65,11 @@ export async function inspectProjectSkillRemoval(selection, options) {
65
65
  try {
66
66
  const claudeInformation = await fileSystem.lstat(claudePath);
67
67
  if (!claudeInformation.isSymbolicLink()) {
68
- throw new ProjectInstallationError("unsafe-target", `Cannot remove the tracked Skill because its Claude location is not the managed symlink: ${claudePath}; no path was changed`);
68
+ throw new ProjectInstallationError("unsafe-target", `Cannot remove the tracked Skill because its Claude location is not the managed directory link: ${claudePath}; no path was changed`);
69
69
  }
70
70
  const target = await fileSystem.readlink(claudePath);
71
71
  if (path.resolve(path.dirname(claudePath), target) !== canonicalPath) {
72
- throw new ProjectInstallationError("unsafe-target", `Cannot remove the tracked Skill because its Claude symlink does not point to the canonical installation: ${claudePath}; no path was changed`);
72
+ throw new ProjectInstallationError("unsafe-target", `Cannot remove the tracked Skill because its Claude directory link does not point to the canonical installation: ${claudePath}; no path was changed`);
73
73
  }
74
74
  paths.push(claudePath);
75
75
  }
@@ -87,6 +87,46 @@ export async function inspectProjectSkillRemoval(selection, options) {
87
87
  ...(nonCanonicalPath === undefined ? {} : { nonCanonicalPath }),
88
88
  });
89
89
  }
90
+ /**
91
+ * Explicitly inspects a stale user-global record without making it valid for
92
+ * host exposure/update. Every relevant ancestor and both managed host locations
93
+ * are checked; only ENOENT is accepted as absence and no filesystem entry is removed.
94
+ */
95
+ export async function inspectAbsentManagedSkillRecord(selection, options) {
96
+ if (!selection.installation)
97
+ throw new ProjectInstallationError("removal-unavailable", "Cannot forget a Skill without a recorded installation");
98
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
99
+ const root = path.resolve(options.projectRoot);
100
+ const target = resolveInstallationPath(root, selection.installation.path);
101
+ if (!isSafeRelativePath(selection.installation.path) || selection.installation.path.split("/").some((segment) => segment.toLowerCase() === ".git")) {
102
+ throw new ProjectInstallationError("unsafe-target", `Tracked Skill installation path is unsafe: ${JSON.stringify(selection.installation.path)}; no record was changed`);
103
+ }
104
+ await assertNoSymlinkComponents(root, fileSystem);
105
+ await assertDirectory(root, fileSystem, "user-global root");
106
+ await assertNoSymlinkAncestors(root, relativeSegments(root, target), fileSystem);
107
+ const skillName = skillNameFromSelection(selection);
108
+ const canonical = path.join(root, ".agents", "skills", skillName);
109
+ const claude = path.join(root, ".claude", "skills", skillName);
110
+ for (const otherPath of options.otherInstallationPaths ?? []) {
111
+ const other = resolveInstallationPath(root, otherPath);
112
+ if (pathsOverlap(target, other) || pathsOverlap(canonical, other) || pathsOverlap(claude, other)) {
113
+ throw new ProjectInstallationError("overlapping-targets", `Cannot forget ${JSON.stringify(selection.path)} because its recorded path overlaps another managed Skill record; no record was changed`);
114
+ }
115
+ }
116
+ await assertNoSymlinkAncestors(root, [".agents", "skills", skillName], fileSystem);
117
+ await assertNoSymlinkAncestors(root, [".claude", "skills", skillName], fileSystem);
118
+ for (const candidate of new Set([target, canonical, claude])) {
119
+ try {
120
+ await fileSystem.lstat(candidate);
121
+ throw new ProjectInstallationError("unsafe-target", `Cannot forget ${JSON.stringify(selection.path)} because a filesystem object exists at ${candidate}; no record was changed`);
122
+ }
123
+ catch (error) {
124
+ if (!isMissing(error))
125
+ throw error;
126
+ }
127
+ }
128
+ return Object.freeze({ skillName, paths: Object.freeze([]), digest: "absent-record-only", adopted: selection.installation.adopted, modified: false, recordOnly: true });
129
+ }
90
130
  /**
91
131
  * Removes a previously inspected user-global Skill after rechecking ownership.
92
132
  * `deletePaths` narrows the deletion to a subset of the inspected paths (used
@@ -99,6 +139,7 @@ export async function removeProjectSkill(selection, inspection, options, deleteP
99
139
  current.paths.length !== inspection.paths.length ||
100
140
  current.paths.some((candidate, index) => candidate !== inspection.paths[index]) ||
101
141
  current.digest !== inspection.digest ||
142
+ current.recordOnly !== inspection.recordOnly ||
102
143
  current.adopted !== inspection.adopted ||
103
144
  current.modified !== inspection.modified ||
104
145
  current.nonCanonicalPath !== inspection.nonCanonicalPath) {
@@ -120,9 +161,9 @@ export async function removeProjectSkill(selection, inspection, options, deleteP
120
161
  }
121
162
  }
122
163
  /**
123
- * Deletes one managed symlink without ever following or recursing into it:
124
- * look at the path immediately before, require a symlink, then unlink it.
125
- * Partial Host removal only ever deletes the Claude symlink, so it uses this
164
+ * Deletes one managed directory link without ever following or recursing into it:
165
+ * look at the path immediately before, require a link, then unlink it.
166
+ * Partial Host removal only ever deletes the Claude link, so it uses this
126
167
  * instead of the recursive `rm` that full removal needs for the canonical directory.
127
168
  */
128
169
  async function unlinkManagedSymlink(candidate, fileSystem) {
@@ -135,7 +176,7 @@ async function unlinkManagedSymlink(candidate, fileSystem) {
135
176
  /**
136
177
  * Inspects what exposing the installed Skill to `hosts` would change without
137
178
  * writing. Non-Claude Hosts share the canonical `.agents/skills` location, and
138
- * Claude is a symlink to it; anything else at a target location is a conflict.
179
+ * Claude uses a managed directory link to it; anything else at a target location is a conflict.
139
180
  */
140
181
  export async function inspectProjectSkillHostAddition(selection, hosts, options) {
141
182
  const removal = await inspectProjectSkillRemoval(selection, options);
@@ -161,7 +202,7 @@ export async function inspectProjectSkillHostAddition(selection, hosts, options)
161
202
  else {
162
203
  const target = existing.isSymbolicLink() ? await fileSystem.readlink(claudePath) : undefined;
163
204
  if (target === undefined || path.resolve(path.dirname(claudePath), target) !== canonicalPath) {
164
- throw new ProjectInstallationError("target-conflict", `Cannot expose ${JSON.stringify(removal.skillName)} to Claude because ${JSON.stringify(claudePath)} already exists and is not the managed symlink to the installed Skill; no path was changed.`);
205
+ throw new ProjectInstallationError("target-conflict", `Cannot expose ${JSON.stringify(removal.skillName)} to Claude because ${JSON.stringify(claudePath)} already exists and is not the managed directory link to the installed Skill; no path was changed.`);
165
206
  }
166
207
  }
167
208
  }
@@ -446,7 +487,7 @@ async function inspectSkillLocation(candidate, context) {
446
487
  ? Object.freeze({ path: candidate, status: "identical", ...evidence })
447
488
  : collisionLocation(candidate, comparison.reason ?? "content-mismatch", evidence);
448
489
  }
449
- /** A Claude symlink is an exposure only when it points to the canonical Skill that was itself found identical. */
490
+ /** A Claude directory link is an exposure only when it points to the canonical Skill that was itself found identical. */
450
491
  async function isClaudeExposureOfCanonical(candidate, context) {
451
492
  if (candidate !== context.claudePath || !context.canonicalIdentical)
452
493
  return false;
@@ -523,7 +564,7 @@ export async function installProjectSkillTransaction(request, options) {
523
564
  // prevents a later host check from following a pre-existing symlink after
524
565
  // the other host's directories have already been created.
525
566
  await assertNoSymlinkAncestors(projectRoot, [".agents", "skills", skillName], fileSystem);
526
- // A Claude Skill path may be the managed symlink to the canonical tree;
567
+ // A Claude Skill path may be the managed directory link to the canonical tree;
527
568
  // adoption validates that final component before following it.
528
569
  await assertNoSymlinkAncestors(projectRoot, [".claude", "skills"], fileSystem);
529
570
  const adoption = await adoptExistingProjectSkill(projectRoot, skillName, request.selection.path, files, hosts, fileSystem, request.confirmAdditionalHostExposure === true, createdPaths, uncertainCreates);
@@ -609,15 +650,14 @@ async function writeInstalledFile(canonicalPath, file, createdPaths, uncertainCr
609
650
  await fileSystem.chmod(destination, file.source.executable ? 0o755 : 0o644);
610
651
  }
611
652
  async function createClaudeSkillSymlink(claudePath, canonicalPath, createdPaths, uncertainCreates, fileSystem) {
612
- const linkTarget = path.relative(path.dirname(claudePath), canonicalPath);
613
653
  uncertainCreates.add(claudePath);
614
654
  try {
615
- await fileSystem.symlink(linkTarget, claudePath, "dir");
655
+ await createClaudeDirectoryLink(fileSystem, claudePath, canonicalPath);
616
656
  }
617
657
  catch (error) {
618
658
  const reason = error instanceof Error ? error.message : String(error);
619
- throw new ProjectInstallationError("claude-symlink-failed", `Cannot create the Claude Skill symlink at ${JSON.stringify(claudePath)}: ${reason}. ` +
620
- "Grant this process permission to create directory symlinks (on Windows, enable Developer Mode or the required symlink privilege) and retry. " +
659
+ throw new ProjectInstallationError("claude-symlink-failed", `Cannot create the Claude Skill directory link at ${JSON.stringify(claudePath)}: ${reason}. ` +
660
+ (process.platform === "win32" ? "Windows junctions do not require symlink privileges; check the target path and filesystem instead. " : "Check that this process can create directory symlinks. ") +
621
661
  "No duplicate managed copy was created; the canonical installation was rolled back.");
622
662
  }
623
663
  await recordCreatedClaudeSymlink(claudePath, createdPaths, uncertainCreates, fileSystem);
@@ -625,7 +665,7 @@ async function createClaudeSkillSymlink(claudePath, canonicalPath, createdPaths,
625
665
  async function recordCreatedClaudeSymlink(claudePath, createdPaths, uncertainCreates, fileSystem) {
626
666
  const information = await fileSystem.lstat(claudePath);
627
667
  if (!information.isSymbolicLink()) {
628
- throw new ProjectInstallationError("unsafe-target", `Claude installation path is not a symbolic link: ${claudePath}`);
668
+ throw new ProjectInstallationError("unsafe-target", `Claude installation path is not a directory link: ${claudePath}`);
629
669
  }
630
670
  createdPaths.push({ path: claudePath, kind: "symlink", identity: pathIdentity(information) });
631
671
  uncertainCreates.delete(claudePath);
@@ -651,7 +691,7 @@ function assertOverwriteCollision(inspection) {
651
691
  // would make Claude continue reading the old bytes after the canonical move.
652
692
  const claudeLocation = inspection.locations.find((location) => location.path === inspection.claudePath);
653
693
  if (inspection.targetPath === inspection.canonicalPath && claudeLocation?.status === "identical" && claudeLocation.identity !== undefined) {
654
- throw new ProjectInstallationError("ambiguous-target", `Cannot overwrite ${JSON.stringify(inspection.targetPath)} because the Claude location is a separate real directory rather than the managed symlink; no path was changed`);
694
+ throw new ProjectInstallationError("ambiguous-target", `Cannot overwrite ${JSON.stringify(inspection.targetPath)} because the Claude location is a separate real directory rather than the managed directory link; no path was changed`);
655
695
  }
656
696
  }
657
697
  function targetLocation(inspection) {
@@ -847,9 +887,8 @@ async function overwriteProjectSkillTransaction(request, options, files, hosts,
847
887
  const claudeLocation = inspection.locations.find((location) => location.path === inspection.claudePath);
848
888
  if (targetPath === inspection.canonicalPath && hosts.includes("claude") && claudeLocation?.status === "missing") {
849
889
  await ensureDirectoryTree(projectRoot, [".claude", "skills"], exposureCreated, exposureUncertain, fileSystem);
850
- const linkTarget = path.relative(path.dirname(inspection.claudePath), inspection.canonicalPath);
851
890
  exposureUncertain.add(inspection.claudePath);
852
- await fileSystem.symlink(linkTarget, inspection.claudePath, "dir");
891
+ await createClaudeDirectoryLink(fileSystem, inspection.claudePath, inspection.canonicalPath);
853
892
  const linkInformation = await fileSystem.lstat(inspection.claudePath);
854
893
  if (!linkInformation.isSymbolicLink()) {
855
894
  throw new ProjectInstallationError("unsafe-target", `Claude installation path is not a symbolic link: ${inspection.claudePath}`);
@@ -918,15 +957,19 @@ async function assertExistingSkillMatches(candidate, sourceSkillPath, expectedTr
918
957
  throw new ProjectInstallationError("adoption-conflict", `Cannot adopt because an existing Skill already exists at ${JSON.stringify(candidate)} with different content (${comparison.reason}); explicit confirmation would be required to overwrite it, but this safe install flow has no overwrite operation. No path was changed.`);
919
958
  }
920
959
  }
960
+ async function createClaudeDirectoryLink(fileSystem, claudePath, canonicalPath) {
961
+ const windows = process.platform === "win32";
962
+ await fileSystem.symlink(windows ? path.resolve(canonicalPath) : path.relative(path.dirname(claudePath), canonicalPath), claudePath, windows ? "junction" : "dir");
963
+ }
921
964
  async function createAdditionalClaudeExposure(projectRoot, canonicalPath, claudePath, fileSystem, createdPaths, uncertainCreates) {
922
965
  await ensureDirectoryTree(projectRoot, [".claude", "skills"], createdPaths, uncertainCreates, fileSystem);
923
966
  await assertMissing(claudePath, fileSystem, claudePath);
924
967
  uncertainCreates.add(claudePath);
925
968
  try {
926
- await fileSystem.symlink(path.relative(path.dirname(claudePath), canonicalPath), claudePath, "dir");
969
+ await createClaudeDirectoryLink(fileSystem, claudePath, canonicalPath);
927
970
  }
928
971
  catch (error) {
929
- throw new ProjectInstallationError("claude-symlink-failed", `Cannot create the additional Claude Skill symlink at ${JSON.stringify(claudePath)}: ${errorMessage(error)}. No existing path was changed.`);
972
+ throw new ProjectInstallationError("claude-symlink-failed", `Cannot create the additional Claude Skill directory link at ${JSON.stringify(claudePath)}: ${errorMessage(error)}. No existing path was changed.`);
930
973
  }
931
974
  await recordCreatedClaudeSymlink(claudePath, createdPaths, uncertainCreates, fileSystem);
932
975
  }
@@ -1207,6 +1250,9 @@ async function rollbackCreatedPaths(createdPaths, uncertainCreates, fileSystem)
1207
1250
  }
1208
1251
  await fileSystem.rmdir(created.path);
1209
1252
  }
1253
+ else if (created.kind === "symlink") {
1254
+ await unlinkManagedSymlink(created.path, fileSystem);
1255
+ }
1210
1256
  else {
1211
1257
  await fileSystem.rm(created.path);
1212
1258
  }
@@ -68,13 +68,18 @@ function successfulStdout(result) {
68
68
  }
69
69
  export function createSelfUpdateOperations(options) {
70
70
  const resolveExecutable = options.resolveExecutable ?? createPortableExecutableResolver();
71
+ const platform = options.platform ?? process.platform;
71
72
  const run = options.run ?? runProcess;
72
73
  const fetcher = options.fetch ?? globalThis.fetch;
73
74
  async function npmGlobalExecutable() {
74
75
  const npm = await resolveExecutable("npm");
75
76
  if (npm.blocked || npm.executable === undefined)
76
77
  return undefined;
77
- const target = await realpath(npm.executable).catch(() => undefined);
78
+ // On Windows npm resolves to an `npm.cmd` shim; verify the npm-cli.js that ships next to it instead.
79
+ const verifiedFile = platform === "win32" && path.basename(npm.executable).toLowerCase() === "npm.cmd"
80
+ ? path.join(path.dirname(npm.executable), "node_modules", "npm", "bin", "npm-cli.js")
81
+ : npm.executable;
82
+ const target = await realpath(verifiedFile).catch(() => undefined);
78
83
  if (target === undefined || path.basename(target) !== "npm-cli.js" || path.basename(path.dirname(target)) !== "bin" || path.basename(path.dirname(path.dirname(target))) !== "npm")
79
84
  return undefined;
80
85
  let rootResult;
@@ -1,4 +1,4 @@
1
- import { assertNoOverlappingProjectSkillRemovalTargets, inspectProjectSkillRemoval, removeProjectSkill, } from "./project-installation.js";
1
+ import { assertNoOverlappingProjectSkillRemovalTargets, inspectAbsentManagedSkillRecord, inspectProjectSkillRemoval, ProjectInstallationError, removeProjectSkill, } from "./project-installation.js";
2
2
  /** Result of resolving a user-supplied `<id|path>` against managed installations. */
3
3
  export class SkillSelectionError extends Error {
4
4
  }
@@ -32,11 +32,13 @@ export function userGlobalRemovalOptions(homeDirectory, overrides) {
32
32
  }
33
33
  /** Preview lines for one inspected removal, including adopted/modified warnings. */
34
34
  export function describeSkillRemoval(selection, inspection, label) {
35
- const lines = [` remove ${label}${JSON.stringify(selection.path)} at ${inspection.paths.join(", ")}`];
36
- if (inspection.adopted) {
35
+ const lines = inspection.recordOnly
36
+ ? [` forget missing installation record for ${label}${JSON.stringify(selection.path)} (record only; no files removed)`]
37
+ : [` remove ${label}${JSON.stringify(selection.path)} at ${inspection.paths.join(", ")}`];
38
+ if (!inspection.recordOnly && inspection.adopted) {
37
39
  lines.push(" WARNING: this Skill was adopted; removal deletes content that Agent Depot did not create");
38
40
  }
39
- if (inspection.modified) {
41
+ if (!inspection.recordOnly && inspection.modified) {
40
42
  lines.push(" WARNING: this Skill is locally modified or has no trusted baseline; removal deletes those changes");
41
43
  }
42
44
  return lines;
@@ -48,7 +50,20 @@ export function describeSkillRemoval(selection, inspection, label) {
48
50
  export async function inspectSkillRemovals(selections, options, onInspected) {
49
51
  const inspections = [];
50
52
  for (const selection of selections) {
51
- const inspection = await inspectProjectSkillRemoval(selection, options);
53
+ const otherInstallationPaths = [
54
+ ...(options.otherInstallationPaths ?? []),
55
+ ...selections.filter((candidate) => candidate !== selection)
56
+ .flatMap((candidate) => candidate.installation ? [candidate.installation.path] : []),
57
+ ];
58
+ let inspection;
59
+ try {
60
+ inspection = await inspectProjectSkillRemoval(selection, { ...options, otherInstallationPaths });
61
+ }
62
+ catch (error) {
63
+ if (!(error instanceof ProjectInstallationError) || error.code !== "missing-installation" || options.installationTrust !== "user-global-state")
64
+ throw error;
65
+ inspection = await inspectAbsentManagedSkillRecord(selection, { ...options, otherInstallationPaths });
66
+ }
52
67
  inspections.push(inspection);
53
68
  onInspected?.(selection, inspection);
54
69
  }
@@ -70,9 +85,9 @@ export async function recheckSkillRemoval(plan, options) {
70
85
  return rechecked;
71
86
  }
72
87
  export function assertRemovalInspectionUnchanged(before, after) {
73
- const unchanged = before.digest === after.digest && before.adopted === after.adopted && before.modified === after.modified &&
74
- before.skillName === after.skillName && before.paths.length === after.paths.length &&
75
- before.paths.every((candidate, pathIndex) => candidate === after.paths[pathIndex]);
88
+ const unchanged = before.digest === after.digest && before.recordOnly === after.recordOnly && before.adopted === after.adopted && before.modified === after.modified &&
89
+ before.skillName === after.skillName && before.nonCanonicalPath === after.nonCanonicalPath &&
90
+ before.paths.length === after.paths.length && before.paths.every((candidate, pathIndex) => candidate === after.paths[pathIndex]);
76
91
  if (!unchanged) {
77
92
  throw new Error("The inspected Skill removal target changed before deletion; no path was removed");
78
93
  }
@@ -86,8 +101,11 @@ export function assertManagedInstallationRecordsUnchanged(expected, actual) {
86
101
  }
87
102
  /** Deletes the files of every rechecked selection; records are left to the caller. */
88
103
  export async function removeSkillFiles(plan, rechecked, options) {
104
+ assertRemovalBatchUnchanged(plan, rechecked);
89
105
  for (const [index, selection] of plan.selections.entries()) {
90
106
  assertRemovalInspectionUnchanged(plan.inspections[index], rechecked.inspections[index]);
107
+ if (rechecked.inspections[index].recordOnly)
108
+ continue;
91
109
  await removeProjectSkill(selection, rechecked.inspections[index], options);
92
110
  }
93
111
  }
@@ -102,16 +120,35 @@ export async function removeSkillsAndRecords(plan, rechecked, operations, option
102
120
  if (!list || !reconcile) {
103
121
  throw new Error("Configured Source operations cannot reconcile removed user-global Skills");
104
122
  }
123
+ assertRemovalBatchUnchanged(plan, rechecked);
105
124
  let expected = initialManaged;
106
125
  for (const [index, selection] of plan.selections.entries()) {
107
126
  assertRemovalInspectionUnchanged(plan.inspections[index], rechecked.inspections[index]);
108
127
  assertManagedInstallationRecordsUnchanged(expected, await list.call(operations));
109
- await removeProjectSkill(selection, rechecked.inspections[index], options);
128
+ if (rechecked.inspections[index].recordOnly) {
129
+ const latest = await inspectAbsentManagedSkillRecord(selection, options);
130
+ assertRemovalInspectionUnchanged(rechecked.inspections[index], latest);
131
+ }
132
+ else {
133
+ await removeProjectSkill(selection, rechecked.inspections[index], options);
134
+ }
110
135
  await reconcile.call(operations, [selection]);
111
136
  expected = await list.call(operations);
112
137
  }
113
138
  return expected;
114
139
  }
140
+ /** Compare the complete confirmed batch before any entry can mutate files or records. */
141
+ function assertRemovalBatchUnchanged(plan, rechecked) {
142
+ if (plan.selections.length !== rechecked.selections.length || plan.inspections.length !== rechecked.inspections.length) {
143
+ throw new Error("The inspected Skill removal batch changed before deletion; no path was removed");
144
+ }
145
+ for (const [index, inspection] of plan.inspections.entries()) {
146
+ const current = rechecked.inspections[index];
147
+ if (!current)
148
+ throw new Error("The inspected Skill removal batch changed before deletion; no path was removed");
149
+ assertRemovalInspectionUnchanged(inspection, current);
150
+ }
151
+ }
115
152
  /** Execute step: recheck, then remove files and records. */
116
153
  export async function executeSkillRemoval(plan, operations, options, initialManaged) {
117
154
  const rechecked = await recheckSkillRemoval(plan, options);
@@ -92,7 +92,9 @@ export async function runBulkUninstall(plan, operations, env) {
92
92
  await runUninstall({ ...item.prepared, installations: expected }, operations, env);
93
93
  // Only the record this item removed moves the baseline the next item is checked against.
94
94
  expected = expected.filter((record) => !item.prepared.plan.selections.some((removed) => sameRecord(removed, record)));
95
- return `Removed ${item.label}`;
95
+ return item.prepared.plan.inspections.every((inspection) => inspection.recordOnly)
96
+ ? `Forgot missing installation record for ${item.label}; no files were removed`
97
+ : `Removed ${item.label}`;
96
98
  }, (item, error) => (item.kind === "unmanaged" ? errorText(error) : `Failed ${item.label}: ${errorText(error)}`));
97
99
  return resultOf(outcomes, plan.skipped);
98
100
  }
@@ -128,15 +128,24 @@ export async function prepareUninstall(operations, environment, skill, installed
128
128
  if (matches.length === 0) {
129
129
  return { kind: recordsFor(installed.project, skill, installed.sources).length > 0 ? "project-only" : "not-installed" };
130
130
  }
131
- const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
132
131
  const record = matches[0];
132
+ const options = userGlobalRemovalOptions(homeOf(environment), {
133
+ ...environment.installationOptions,
134
+ otherInstallationPaths: installations.filter((candidate) => candidate !== record).flatMap((candidate) => candidate.installation ? [candidate.installation.path] : []),
135
+ });
133
136
  const plan = await planSkillRemoval([record], options);
134
- const scopeNote = ` removes the whole user-global installation, from hosts: ${record.hosts.join(", ")}`;
137
+ const scopeNote = plan.inspections[0]?.recordOnly
138
+ ? ` forgets the missing installation record, from hosts: ${record.hosts.join(", ")}; no files are removed`
139
+ : ` removes the whole user-global installation, from hosts: ${record.hosts.join(", ")}`;
135
140
  return { kind: "ready", plan: { ...plan, preview: [...plan.preview, scopeNote] }, installations };
136
141
  }
137
142
  /** Execute step: rechecks the plan, then removes files and records. */
138
143
  export async function runUninstall(prepared, operations, environment) {
139
- const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
144
+ const selected = new Set(prepared.plan.selections.map((selection) => JSON.stringify(selection)));
145
+ const options = userGlobalRemovalOptions(homeOf(environment), {
146
+ ...environment.installationOptions,
147
+ otherInstallationPaths: prepared.installations.filter((candidate) => !selected.has(JSON.stringify(candidate))).flatMap((candidate) => candidate.installation ? [candidate.installation.path] : []),
148
+ });
140
149
  await executeSkillRemoval(prepared.plan, operations, options, prepared.installations);
141
150
  }
142
151
  /** The user-global record of the Skill, when it is installed there. */
@@ -178,16 +187,26 @@ export async function runHostAddition(prepared, operations, environment) {
178
187
  /** Plan step for removing some hosts of a user-global Skill; removing all of them is a full removal. */
179
188
  export async function prepareHostRemoval(operations, environment, skill, installed, hosts) {
180
189
  const { record, installations } = await currentGlobalRecord(operations, skill, installed);
181
- const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
190
+ const options = userGlobalRemovalOptions(homeOf(environment), {
191
+ ...environment.installationOptions,
192
+ otherInstallationPaths: installations.filter((candidate) => JSON.stringify(candidate) !== JSON.stringify(record)).flatMap((candidate) => candidate.installation ? [candidate.installation.path] : []),
193
+ });
182
194
  const plan = await planHostRemoval(record, hosts, options).catch(asMessage);
183
195
  return { plan, installations, preview: plan.kind === "full" ? plan.plan.preview : plan.preview };
184
196
  }
185
197
  /** Execute step: rechecks, removes only the chosen hosts' locations, then reconciles the record. */
186
198
  export async function runHostRemoval(prepared, operations, environment, skill) {
187
- const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
199
+ const selection = prepared.plan.kind === "full" ? prepared.plan.plan.selections[0] : prepared.plan.selection;
200
+ const options = userGlobalRemovalOptions(homeOf(environment), {
201
+ ...environment.installationOptions,
202
+ otherInstallationPaths: prepared.installations.filter((candidate) => selection !== undefined && JSON.stringify(candidate) !== JSON.stringify(selection))
203
+ .flatMap((candidate) => candidate.installation ? [candidate.installation.path] : []),
204
+ });
188
205
  await executeHostRemoval(prepared.plan, operations, options, prepared.installations);
189
206
  const { plan } = prepared;
190
- return plan.kind === "full"
191
- ? `Removed ${skill.name} (${skill.path})`
192
- : `Removed hosts ${plan.removed.join(", ")} from ${plan.selection.path}`;
207
+ return plan.kind === "full" && plan.plan.inspections.every((inspection) => inspection.recordOnly)
208
+ ? `Forgot missing installation record for ${plan.plan.selections[0]?.path ?? skill.path}; no files were removed`
209
+ : plan.kind === "full"
210
+ ? `Removed ${skill.name} (${skill.path})`
211
+ : `Removed hosts ${plan.removed.join(", ")} from ${plan.selection.path}`;
193
212
  }
@@ -139,7 +139,9 @@ function handleConfirmKey(context, mode, input, key) {
139
139
  answerYesNo(input, key, () => {
140
140
  void execute(context, `Removing ${mode.skill.name}…`, async () => {
141
141
  await runUninstall(mode.prepared, operations, env);
142
- return `Removed ${mode.skill.name} (${mode.skill.path})`;
142
+ return mode.prepared.plan.inspections.every((inspection) => inspection.recordOnly)
143
+ ? `Forgot missing installation record for ${mode.skill.path}; no files were removed`
144
+ : `Removed ${mode.skill.name} (${mode.skill.path})`;
143
145
  });
144
146
  }, cancelWith("Uninstall cancelled"));
145
147
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",