skills-package-manager 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,12 +50,13 @@ npx skills-package-manager add https://github.com/owner/repo/tree/main/skills/my
50
50
  # Direct specifier — skip discovery
51
51
  npx skills-package-manager add https://github.com/owner/repo.git#path:/skills/my-skill
52
52
  npx skills-package-manager add link:./local-source/skills/my-skill
53
+ npx skills-package-manager add local:./.agents/skills/my-skill
53
54
  npx skills-package-manager add ./local-source
54
55
  npx skills-package-manager add file:./skills-package.tgz#path:/skills/my-skill
55
56
  npx skills-package-manager add npm:@scope/skills-package#path:/skills/my-skill
56
57
  ```
57
58
 
58
- After `npx skills-package-manager add`, the newly added skills are resolved, materialized into `installDir`, and linked to each configured `linkTarget` immediately.
59
+ After `npx skills-package-manager add`, the newly added skills are resolved, installed or registered according to their protocol, and linked to each configured `linkTarget` immediately.
59
60
 
60
61
  #### How it works
61
62
 
@@ -104,9 +105,9 @@ Install all skills declared in `skills.json`:
104
105
  npx skills-package-manager install
105
106
  ```
106
107
 
107
- This resolves each skill from its specifier, materializes it into `installDir` (default `.agents/skills/`), and creates symlinks for each `linkTarget`.
108
+ This resolves each skill from its specifier, installs managed skills into `installDir` (default `.agents/skills/`), registers `local:` skills in place, and creates symlinks for each `linkTarget`.
108
109
  When `selfSkill` is `true`, `npx skills-package-manager install` also installs the bundled `skills-package-manager-cli` skill so users get guidance for `skills.json`, `skills-lock.yaml`, and `npx skills-package-manager` commands. This helper skill is not written to `skills-lock.yaml`.
109
- If `patchedSkills` contains an entry for a skill, the corresponding patch file is applied after the skill is materialized.
110
+ If `patchedSkills` contains an entry for a managed skill, the corresponding patch file is applied after the skill is materialized. `local:` skills cannot be patched because their source directories are user-owned.
110
111
 
111
112
  ### `npx skills-package-manager patch`
112
113
 
@@ -154,7 +155,7 @@ Behavior:
154
155
 
155
156
  - Uses `skills.json` as the source of truth
156
157
  - Re-resolves git refs and npm package targets
157
- - Skips `link:` skills, including the bundled self skill
158
+ - Skips local `link:` and `local:` skills, including the bundled self skill
158
159
  - Fails immediately for unknown skill names
159
160
  - Writes `skills-lock.yaml` only after fetch and link succeed
160
161
 
@@ -183,11 +184,12 @@ const skills = await listRepoSkills('vercel-labs', 'skills')
183
184
  ```text
184
185
  git/file/npm: <source>#[ref&]path:<skill-path>
185
186
  link: link:<path-to-skill-dir>
187
+ local: local:<path-to-existing-skill-dir>
186
188
  ```
187
189
 
188
190
  | Part | Description | Example |
189
191
  |------|-------------|---------|
190
- | `source` | Git URL, direct `link:` skill path, `file:` tarball, or `npm:` package name | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
192
+ | `source` | Git URL, direct `link:` or `local:` skill path, `file:` tarball, or `npm:` package name | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `local:./.agents/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
191
193
  | `ref` | Optional git ref | `main`, `v1.0.0`, `HEAD`, `6cb0992`, `6cb0992a176f2ca142e19f64dca8ac12025b035e` |
192
194
  | `path` | Path to skill directory within source | `/skills/my-skill` |
193
195
 
@@ -196,7 +198,8 @@ link: link:<path-to-skill-dir>
196
198
  ### Resolution Types
197
199
 
198
200
  - **`git`** — Clones the repo, resolves commit hash, copies skill files
199
- - **`link`** — Reads from a local directory and copies the selected skill
201
+ - **`link`** — Symlinks a local skill directory into `installDir`
202
+ - **`local`** — Uses an existing user-owned skill directory in place
200
203
  - **`file`** — Extracts a local `tgz` package and copies the selected skill
201
204
  - **`npm`** — Resolves a package from the configured npm registry, locks the tarball URL/version/integrity, and installs from the downloaded tarball
202
205
 
package/dist/index.d.ts CHANGED
@@ -273,7 +273,7 @@ declare type NormalizedSkillsManifest = {
273
273
  };
274
274
 
275
275
  export declare type NormalizedSpecifier = {
276
- type: 'git' | 'link' | 'file' | 'npm';
276
+ type: 'git' | 'link' | 'local' | 'file' | 'npm';
277
277
  source: string;
278
278
  ref: string | null;
279
279
  path: string;
@@ -396,6 +396,9 @@ export declare type SkillsLockEntry = {
396
396
  resolution: {
397
397
  type: 'link';
398
398
  path: string;
399
+ } | {
400
+ type: 'local';
401
+ path: string;
399
402
  } | {
400
403
  type: 'file';
401
404
  tarball: string;
@@ -471,7 +474,7 @@ export declare type UpdateCommandResult = {
471
474
  unchanged: string[];
472
475
  skipped: Array<{
473
476
  name: string;
474
- reason: 'link-specifier';
477
+ reason: 'link-specifier' | 'local-specifier';
475
478
  }>;
476
479
  failed: Array<{
477
480
  name: string;
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { co } from "./npm-tar.js";
16
16
  import { cac } from "./npm-cac.js";
17
17
  import { Ct } from "./npm-clack_core.js";
18
18
  var package_namespaceObject = {
19
- rE: "0.9.0"
19
+ rE: "0.10.0"
20
20
  };
21
21
  function getHomeDir() {
22
22
  return homedir();
@@ -660,6 +660,7 @@ function formatErrorForDisplay(error) {
660
660
  output += `\n - owner/repo (GitHub shorthand)`;
661
661
  output += `\n - https://github.com/owner/repo.git`;
662
662
  output += `\n - link:./path/to/skill-dir`;
663
+ output += `\n - local:./path/to/existing-skill-dir`;
663
664
  output += `\n - file:./path/to/skill-package.tgz#path:/skills/my-skill`;
664
665
  output += `\n - npm:@scope/skill-package#path:/skills/my-skill`;
665
666
  }
@@ -899,6 +900,20 @@ async function resolveLinkEntry(cwd, source, skillName, specifier) {
899
900
  }
900
901
  };
901
902
  }
903
+ async function resolveLocalEntry(cwd, source, skillName, specifier) {
904
+ const sourceRoot = node_path.resolve(cwd, source.slice(6));
905
+ return {
906
+ skillName,
907
+ entry: {
908
+ specifier,
909
+ resolution: {
910
+ type: 'local',
911
+ path: toPortableRelativePath(cwd, sourceRoot)
912
+ },
913
+ digest: ''
914
+ }
915
+ };
916
+ }
902
917
  const node_modules_semver = __webpack_require__("../../node_modules/.pnpm/semver@7.7.4/node_modules/semver/index.js");
903
918
  var node_modules_semver_default = /*#__PURE__*/ __webpack_require__.n(node_modules_semver);
904
919
  const resolvedNpmPackageCache = new Map();
@@ -1159,6 +1174,8 @@ async function resolveEntry(cwd, normalized, skillName) {
1159
1174
  switch(normalized.type){
1160
1175
  case 'link':
1161
1176
  return resolveLinkEntry(cwd, normalized.source, finalSkillName, normalized.normalized);
1177
+ case 'local':
1178
+ return resolveLocalEntry(cwd, normalized.source, finalSkillName, normalized.normalized);
1162
1179
  case 'file':
1163
1180
  return resolveFileEntry(cwd, normalized.source, normalized.path, finalSkillName, normalized.normalized);
1164
1181
  case 'git':
@@ -1172,9 +1189,16 @@ async function resolveEntry(cwd, normalized, skillName) {
1172
1189
  }
1173
1190
  }
1174
1191
  }
1192
+ function normalizeProtocolPathSource(sourcePart, protocol) {
1193
+ const prefix = `${protocol}:`;
1194
+ const sourcePath = sourcePart.slice(prefix.length).replace(/\\/g, '/').replace(/\/+$/, '');
1195
+ return `${prefix}${sourcePath}`;
1196
+ }
1175
1197
  function normalizeLinkSource(sourcePart) {
1176
- const linkPath = sourcePart.slice(5).replace(/\\/g, '/').replace(/\/+$/, '');
1177
- return `link:${linkPath}`;
1198
+ return normalizeProtocolPathSource(sourcePart, 'link');
1199
+ }
1200
+ function normalizeLocalSource(sourcePart) {
1201
+ return normalizeProtocolPathSource(sourcePart, 'local');
1178
1202
  }
1179
1203
  function parseSpecifier(specifier) {
1180
1204
  const firstHashIndex = specifier.indexOf('#');
@@ -1214,11 +1238,14 @@ function parseSpecifier(specifier) {
1214
1238
  };
1215
1239
  }
1216
1240
  function normalizeSpecifier(specifier) {
1217
- if (specifier.startsWith('link:') && specifier.includes('#')) throw new ParseError({
1218
- code: codes_ErrorCode.INVALID_SPECIFIER,
1219
- message: 'Invalid link specifier: link: must point directly to a skill directory',
1220
- content: specifier
1221
- });
1241
+ if ((specifier.startsWith('link:') || specifier.startsWith('local:')) && specifier.includes('#')) {
1242
+ const protocol = specifier.startsWith('link:') ? 'link' : 'local';
1243
+ throw new ParseError({
1244
+ code: codes_ErrorCode.INVALID_SPECIFIER,
1245
+ message: `Invalid ${protocol} specifier: ${protocol}: must point directly to a skill directory`,
1246
+ content: specifier
1247
+ });
1248
+ }
1222
1249
  let parsed;
1223
1250
  try {
1224
1251
  parsed = parseSpecifier(specifier);
@@ -1231,17 +1258,17 @@ function normalizeSpecifier(specifier) {
1231
1258
  cause: error
1232
1259
  });
1233
1260
  }
1234
- const type = parsed.sourcePart.startsWith('link:') ? 'link' : parsed.sourcePart.startsWith('file:') ? 'file' : parsed.sourcePart.startsWith('npm:') ? 'npm' : 'git';
1235
- if ('link' === type) {
1236
- const linkSource = normalizeLinkSource(parsed.sourcePart);
1237
- const linkPath = linkSource.slice(5);
1238
- const skillName = node_path.posix.basename(linkPath);
1261
+ const type = parsed.sourcePart.startsWith('link:') ? 'link' : parsed.sourcePart.startsWith('local:') ? 'local' : parsed.sourcePart.startsWith('file:') ? 'file' : parsed.sourcePart.startsWith('npm:') ? 'npm' : 'git';
1262
+ if ('link' === type || 'local' === type) {
1263
+ const localSource = 'link' === type ? normalizeLinkSource(parsed.sourcePart) : normalizeLocalSource(parsed.sourcePart);
1264
+ const localPath = localSource.slice(`${type}:`.length);
1265
+ const skillName = node_path.posix.basename(localPath);
1239
1266
  return {
1240
1267
  type,
1241
- source: linkSource,
1268
+ source: localSource,
1242
1269
  ref: null,
1243
1270
  path: '/',
1244
- normalized: linkSource,
1271
+ normalized: localSource,
1245
1272
  skillName
1246
1273
  };
1247
1274
  }
@@ -1260,10 +1287,11 @@ function normalizeSpecifier(specifier) {
1260
1287
  function parseForComparison(specifier) {
1261
1288
  const parsed = parseSpecifier(specifier);
1262
1289
  const isLink = parsed.sourcePart.startsWith('link:');
1290
+ const isLocal = parsed.sourcePart.startsWith('local:');
1263
1291
  return {
1264
- sourcePart: isLink ? normalizeLinkSource(parsed.sourcePart) : parsed.sourcePart,
1265
- ref: isLink ? null : parsed.ref,
1266
- path: isLink ? '/' : parsed.path || '/'
1292
+ sourcePart: isLink ? normalizeLinkSource(parsed.sourcePart) : isLocal ? normalizeLocalSource(parsed.sourcePart) : parsed.sourcePart,
1293
+ ref: isLink || isLocal ? null : parsed.ref,
1294
+ path: isLink || isLocal ? '/' : parsed.path || '/'
1267
1295
  };
1268
1296
  }
1269
1297
  function isSpecifierCompatible(manifestSpecifier, lockSpecifier) {
@@ -1344,6 +1372,7 @@ async function resolveLockEntry(cwd, specifier, skillName) {
1344
1372
  async function attachManifestPatchToEntry(cwd, manifest, skillName, entry) {
1345
1373
  const patchPath = manifest.patchedSkills?.[skillName];
1346
1374
  if (!patchPath) return entry;
1375
+ if ('local' === entry.resolution.type) throw new Error(`local: skill ${skillName} cannot be patched because its source is user-owned`);
1347
1376
  const absolutePatchPath = node_path.resolve(cwd, patchPath);
1348
1377
  return {
1349
1378
  ...entry,
@@ -1675,6 +1704,49 @@ async function writeInstallState(rootDir, installDir, value) {
1675
1704
  const filePath = node_path.join(dirPath, INSTALL_STATE_FILE);
1676
1705
  await writeJson(filePath, value);
1677
1706
  }
1707
+ function getLocalSkillDirs(rootDir, lockfiles) {
1708
+ const dirs = [];
1709
+ for (const lockfile of lockfiles)if (lockfile) {
1710
+ for (const entry of Object.values(lockfile.skills))if ('local' === entry.resolution.type) dirs.push(node_path.resolve(rootDir, entry.resolution.path));
1711
+ }
1712
+ return Array.from(new Set(dirs));
1713
+ }
1714
+ function getSkillInstallPath(rootDir, installDir, skillName, entry) {
1715
+ return 'local' === entry.resolution.type ? node_path.resolve(rootDir, entry.resolution.path) : node_path.join(rootDir, installDir, skillName);
1716
+ }
1717
+ function toRepoRelativePath(rootDir, absolutePath) {
1718
+ const relativePath = node_path.relative(rootDir, absolutePath);
1719
+ if (!relativePath || '..' === relativePath || relativePath.startsWith(`..${node_path.sep}`) || node_path.isAbsolute(relativePath)) return null;
1720
+ return relativePath.split(node_path.sep).join('/');
1721
+ }
1722
+ function createUnignoreRules(relativePath) {
1723
+ const parts = relativePath.split('/').filter(Boolean);
1724
+ const rules = [];
1725
+ for(let index = 0; index < parts.length; index += 1)rules.push(`!${parts.slice(0, index + 1).join('/')}/`);
1726
+ rules.push(`!${relativePath}/**`);
1727
+ return rules;
1728
+ }
1729
+ async function ensureLocalSkillGitignoreRules(rootDir, lockfile) {
1730
+ const desiredRules = new Set();
1731
+ for (const dir of getLocalSkillDirs(rootDir, [
1732
+ lockfile
1733
+ ])){
1734
+ const relativePath = toRepoRelativePath(rootDir, dir);
1735
+ if (relativePath) for (const rule of createUnignoreRules(relativePath))desiredRules.add(rule);
1736
+ }
1737
+ if (0 === desiredRules.size) return;
1738
+ const gitignorePath = node_path.join(rootDir, '.gitignore');
1739
+ let existing = '';
1740
+ try {
1741
+ existing = await promises_readFile(gitignorePath, 'utf8');
1742
+ } catch {}
1743
+ const existingRules = new Set(existing.split(/\r?\n/).map((line)=>line.trim()));
1744
+ const missingRules = Array.from(desiredRules).filter((rule)=>!existingRules.has(rule));
1745
+ if (0 === missingRules.length) return;
1746
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
1747
+ const separator = existing.trim() ? '\n' : '';
1748
+ await writeFile(gitignorePath, `${existing}${prefix}${separator}# Keep local skills tracked\n${missingRules.join('\n')}\n`, 'utf8');
1749
+ }
1678
1750
  function resolveTargetPath(rootDir, targetPath) {
1679
1751
  return node_path.isAbsolute(targetPath) ? targetPath : node_path.join(rootDir, targetPath);
1680
1752
  }
@@ -1693,29 +1765,32 @@ async function isManagedSkillDir(dirPath) {
1693
1765
  return false;
1694
1766
  }
1695
1767
  }
1696
- async function pruneManagedSkills(rootDir, installDir, linkTargets, wantedSkillNames) {
1768
+ async function pruneManagedSkills(rootDir, installDir, linkTargets, wantedSkillNames, protectedSkillDirs = []) {
1697
1769
  const wanted = new Set(wantedSkillNames);
1698
1770
  const absoluteInstallDir = node_path.join(rootDir, installDir);
1771
+ const protectedDirs = new Set(protectedSkillDirs.map((dir)=>node_path.resolve(dir)));
1699
1772
  try {
1700
1773
  const entries = await promises_readdir(absoluteInstallDir);
1701
1774
  for (const entry of entries){
1702
1775
  if (entry.startsWith('.')) continue;
1703
1776
  const skillDir = node_path.join(absoluteInstallDir, entry);
1704
- if (await isManagedSkillDir(skillDir)) {
1705
- if (!wanted.has(entry)) {
1706
- await rm(skillDir, {
1707
- recursive: true,
1708
- force: true
1709
- });
1710
- for (const linkTarget of linkTargets){
1711
- const linkPath = node_path.join(resolveTargetPath(rootDir, linkTarget), entry);
1712
- try {
1713
- const stat = await promises_lstat(linkPath);
1714
- if (stat.isSymbolicLink() || stat.isDirectory() || stat.isFile()) await rm(linkPath, {
1715
- recursive: true,
1716
- force: true
1717
- });
1718
- } catch {}
1777
+ if (!protectedDirs.has(node_path.resolve(skillDir))) {
1778
+ if (await isManagedSkillDir(skillDir)) {
1779
+ if (!wanted.has(entry)) {
1780
+ await rm(skillDir, {
1781
+ recursive: true,
1782
+ force: true
1783
+ });
1784
+ for (const linkTarget of linkTargets){
1785
+ const linkPath = node_path.join(resolveTargetPath(rootDir, linkTarget), entry);
1786
+ try {
1787
+ const stat = await promises_lstat(linkPath);
1788
+ if (stat.isSymbolicLink() || stat.isDirectory() || stat.isFile()) await rm(linkPath, {
1789
+ recursive: true,
1790
+ force: true
1791
+ });
1792
+ } catch {}
1793
+ }
1719
1794
  }
1720
1795
  }
1721
1796
  }
@@ -1946,6 +2021,16 @@ async function fetchLinkSkill(rootDir, skillName, entry, installDir) {
1946
2021
  await symlink(sourceRoot, targetDir);
1947
2022
  return targetDir;
1948
2023
  }
2024
+ async function fetchLocalSkill(rootDir, entry) {
2025
+ if ('local' !== entry.resolution.type) throw new Error('Expected local resolution');
2026
+ const sourceRoot = node_path.resolve(rootDir, entry.resolution.path);
2027
+ try {
2028
+ await access(node_path.join(sourceRoot, 'SKILL.md'));
2029
+ } catch {
2030
+ throw new Error(`Invalid local skill at ${sourceRoot}: missing SKILL.md`);
2031
+ }
2032
+ return sourceRoot;
2033
+ }
1949
2034
  const inFlightDownloads = new Map();
1950
2035
  async function fetchNpmSkill(rootDir, skillName, entry, installDir, _cache) {
1951
2036
  if ('npm' !== entry.resolution.type) throw new Error('Expected npm resolution');
@@ -1979,6 +2064,10 @@ async function fetchSkill(rootDir, skillName, entry, installDir, cache) {
1979
2064
  return {
1980
2065
  installPath: await fetchLinkSkill(rootDir, skillName, entry, installDir)
1981
2066
  };
2067
+ case 'local':
2068
+ return {
2069
+ installPath: await fetchLocalSkill(rootDir, entry)
2070
+ };
1982
2071
  case 'file':
1983
2072
  return {
1984
2073
  installPath: await fetchFileSkill(rootDir, skillName, entry, installDir)
@@ -2257,6 +2346,10 @@ function createTaskQueue(processor, options) {
2257
2346
  async function isSkillUpToDate(rootDir, installDir, skillName, entry) {
2258
2347
  const skillDir = node_path.join(rootDir, installDir, skillName);
2259
2348
  try {
2349
+ if ('local' === entry.resolution.type) {
2350
+ await access(node_path.join(node_path.resolve(rootDir, entry.resolution.path), 'SKILL.md'));
2351
+ return true;
2352
+ }
2260
2353
  const stats = await promises_lstat(skillDir);
2261
2354
  if ('link' === entry.resolution.type) {
2262
2355
  if (!stats.isSymbolicLink()) return false;
@@ -2285,7 +2378,7 @@ function createFetchTaskQueue(ctx, bus, options) {
2285
2378
  const result = {
2286
2379
  skillName: task.skillName,
2287
2380
  entry: task.entry,
2288
- installPath: node_path.join(ctx.cwd, installDir, task.skillName),
2381
+ installPath: getSkillInstallPath(ctx.cwd, installDir, task.skillName, task.entry),
2289
2382
  skipped: true
2290
2383
  };
2291
2384
  bus.emitFetched(result);
@@ -2311,8 +2404,8 @@ function createFetchTaskQueue(ctx, bus, options) {
2311
2404
  function links_resolveTargetPath(rootDir, targetPath) {
2312
2405
  return node_path.isAbsolute(targetPath) ? targetPath : node_path.join(rootDir, targetPath);
2313
2406
  }
2314
- async function linkSkill(rootDir, installDir, linkTarget, skillName) {
2315
- const absoluteTarget = node_path.join(rootDir, installDir, skillName);
2407
+ async function linkSkill(rootDir, installDir, linkTarget, skillName, sourcePath) {
2408
+ const absoluteTarget = sourcePath ?? node_path.join(rootDir, installDir, skillName);
2316
2409
  const absoluteLink = node_path.join(links_resolveTargetPath(rootDir, linkTarget), skillName);
2317
2410
  await ensureDir(node_path.dirname(absoluteLink));
2318
2411
  await replaceSymlink(absoluteTarget, absoluteLink);
@@ -2322,7 +2415,7 @@ function createLinkTaskQueue(ctx, bus, options) {
2322
2415
  const linkTargets = ctx.lockfile?.linkTargets ?? ctx.manifest.linkTargets ?? [];
2323
2416
  async function processor(task) {
2324
2417
  try {
2325
- for (const linkTarget of linkTargets)await linkSkill(ctx.cwd, installDir, linkTarget, task.skillName);
2418
+ for (const linkTarget of linkTargets)await linkSkill(ctx.cwd, installDir, linkTarget, task.skillName, task.installPath);
2326
2419
  const result = {
2327
2420
  skillName: task.skillName
2328
2421
  };
@@ -2451,7 +2544,17 @@ async function runPipeline(input) {
2451
2544
  linkTargets,
2452
2545
  skills: entries
2453
2546
  });
2454
- await pruneManagedSkills(ctx.cwd, installDir, linkTargets, skillNames);
2547
+ const runtimeLockfile = {
2548
+ lockfileVersion: '0.1',
2549
+ installDir,
2550
+ linkTargets,
2551
+ skills: entries
2552
+ };
2553
+ await ensureLocalSkillGitignoreRules(ctx.cwd, runtimeLockfile);
2554
+ await pruneManagedSkills(ctx.cwd, installDir, linkTargets, skillNames, getLocalSkillDirs(ctx.cwd, [
2555
+ runtimeLockfile,
2556
+ ctx.lockfile
2557
+ ]));
2455
2558
  if (skipResolve) for (const [skillName, entry] of Object.entries(entries))bus.emitResolved({
2456
2559
  skillName,
2457
2560
  entry
@@ -2608,7 +2711,7 @@ function buildLinkSpecifier(sourceRoot, skillPath) {
2608
2711
  return normalizeLinkSource(`link:${absoluteSkillPath}`);
2609
2712
  }
2610
2713
  function isDirectSkillSpecifier(specifier) {
2611
- return specifier.startsWith('link:') || specifier.startsWith('file:') || specifier.startsWith('npm:') || specifier.includes('#path:') || specifier.includes('&path:');
2714
+ return specifier.startsWith('link:') || specifier.startsWith('local:') || specifier.startsWith('file:') || specifier.startsWith('npm:') || specifier.includes('#path:') || specifier.includes('&path:');
2612
2715
  }
2613
2716
  function isLocalPathSpecifier(specifier) {
2614
2717
  return node_path.isAbsolute(specifier) || specifier.startsWith('./') || specifier.startsWith('../') || '.' === specifier || '..' === specifier || /^[a-zA-Z]:[/\\]/.test(specifier);
@@ -3505,6 +3608,13 @@ async function updateCommand(options) {
3505
3608
  });
3506
3609
  continue;
3507
3610
  }
3611
+ if ('local' === normalized.type) {
3612
+ result.skipped.push({
3613
+ name: skillName,
3614
+ reason: 'local-specifier'
3615
+ });
3616
+ continue;
3617
+ }
3508
3618
  const { entry } = await resolveLockEntry(options.cwd, specifier);
3509
3619
  const nextEntry = await attachManifestPatchToEntry(options.cwd, ctx.manifest, skillName, entry);
3510
3620
  const previous = ctx.lockfile?.skills[skillName];
@@ -3644,7 +3754,10 @@ async function installSkills_withBundledSelfSkillLock(rootDir, manifest, lockfil
3644
3754
  async function fetchSkillsFromLock(rootDir, manifest, lockfile, options) {
3645
3755
  const installDir = manifest.installDir ?? '.agents/skills';
3646
3756
  const linkTargets = manifest.linkTargets ?? [];
3647
- await pruneManagedSkills(rootDir, installDir, linkTargets, Object.keys(lockfile.skills));
3757
+ await ensureLocalSkillGitignoreRules(rootDir, lockfile);
3758
+ await pruneManagedSkills(rootDir, installDir, linkTargets, Object.keys(lockfile.skills), getLocalSkillDirs(rootDir, [
3759
+ lockfile
3760
+ ]));
3648
3761
  const lockDigest = sha256(JSON.stringify(lockfile));
3649
3762
  const state = await readInstallState(rootDir, installDir);
3650
3763
  if (state?.lockDigest === lockDigest && await installSkills_areManagedSkillsInstalled(rootDir, installDir, Object.keys(lockfile.skills))) return {
@@ -3680,7 +3793,7 @@ async function linkSkillsFromLock(rootDir, manifest, lockfile, options) {
3680
3793
  const installDir = manifest.installDir ?? '.agents/skills';
3681
3794
  const linkTargets = manifest.linkTargets ?? [];
3682
3795
  for (const skillName of Object.keys(lockfile.skills)){
3683
- for (const linkTarget of linkTargets)await linkSkill(rootDir, installDir, linkTarget, skillName);
3796
+ for (const linkTarget of linkTargets)await linkSkill(rootDir, installDir, linkTarget, skillName, getSkillInstallPath(rootDir, installDir, skillName, lockfile.skills[skillName]));
3684
3797
  options?.onProgress?.({
3685
3798
  type: 'installed',
3686
3799
  skillName
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-package-manager",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@ Use this skill for repositories that already use `skills-package-manager`, or wh
40
40
 
41
41
  4. `npx skills-package-manager update [skill...]`
42
42
  - Refreshes resolvable entries in `skills-lock.yaml`.
43
- - Skips `link:` skills, including the bundled `skills-package-manager-cli` self skill.
43
+ - Skips local `link:` and `local:` skills, including the bundled `skills-package-manager-cli` self skill.
44
44
 
45
45
  ## How To Triage User Questions
46
46
 
@@ -59,6 +59,7 @@ Use this skill for repositories that already use `skills-package-manager`, or wh
59
59
  ## Specifier Reminders
60
60
 
61
61
  - `link:./path/to/skill-dir` points to a local skill directory.
62
+ - `local:./path/to/existing-skill-dir` keeps an existing user-owned skill directory in place.
62
63
  - `file:./pkg.tgz#path:/skills/name` points to a packaged tarball plus skill path.
63
64
  - `npm:@scope/pkg#path:/skills/name` resolves a package from the configured registry.
64
65
  - GitHub shorthand or Git URLs resolve remote repositories and may need `--skill` when multiple skills are available.