skillshop 2.2.0 → 4.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "skillshop",
3
- "version": "2.2.0",
4
- "description": "Build, export, resolve, and install portable agent skill libraries",
3
+ "version": "4.1.0",
4
+ "description": "Install and sync agent skills from personal folder libraries",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "skillshop": "./src/cli.js",
@@ -13,11 +13,15 @@
13
13
  "./export": "./src/export.js",
14
14
  "./import": "./src/import.js",
15
15
  "./user-install": "./src/user-install.js",
16
- "./specifier": "./src/specifier.js"
16
+ "./specifier": "./src/specifier.js",
17
+ "./dependencies": "./src/dependencies.js",
18
+ "./installation-state": "./src/installation-state.js"
17
19
  },
18
20
  "files": [
19
21
  "agents-content/",
20
22
  "skills-content/",
23
+ "templates/",
24
+ "docs/",
21
25
  "src/",
22
26
  "README.md"
23
27
  ],
@@ -1,21 +1,56 @@
1
1
  ---
2
2
  name: skillshop
3
- description: Manage repository-local agent skills, Skillshop upgrades, and project agent instructions. Use when a repository has a .skillshop-lock or the user asks to install, uninstall, pin, list, update, or upgrade Skillshop-managed copies, or initialize, render, or check project AGENTS.md instructions.
3
+ description: Manage personal folder skill libraries, GitHub skill downloads, owned-copy sync, repository-local agent skills, Skillshop upgrades, and project agent instructions. Use when the user asks to initialize or list a personal library, download or install @owner/skill, install a bare personal skill name, sync personal sources, manage Skillshop copies, upgrade Skillshop, or initialize, render, or check project AGENTS.md instructions.
4
4
  metadata:
5
5
  author: Daniel Fosco
6
- version: "2026.10.23"
6
+ version: "2026.10.28.1"
7
7
  ---
8
8
 
9
- # Skillshop — Local Skill Registry
9
+ # Skillshop — Personal Skill Libraries and Local Registry
10
10
 
11
- Use the `skillshop` CLI to manage skills in this repository. The project-root `.skillshop-lock` is the source of truth for which skill directories are managed by skillshop; directories absent from the lock belong to the project or another installer.
11
+ Use the `skillshop` CLI to manage personal folder libraries and their recorded installed copies. A personal library is the source of truth for user-authored skills; each destination skills directory contains a `.skillshop-lock` ownership manifest for its installed copies. Project-root `skillshop.json` and `skillshop-lock.json` separately declare and lock reproducible project dependencies. Directories absent from ownership records belong to the project or another installer.
12
+
13
+ ## Personal folder libraries
14
+
15
+ ```bash
16
+ skillshop library init # selects ~/skillshop-library
17
+ skillshop library init ./my-skills # creates or adopts an existing folder
18
+ skillshop library list
19
+ skillshop download @alice/review # adds source without installing
20
+ skillshop install @alice/review # downloads if missing, then installs locally
21
+ skillshop install @alice/review --global
22
+ skillshop install review # installs an existing selected source
23
+ skillshop sync # syncs all recorded personal copies
24
+ ```
25
+
26
+ The library is an ordinary folder. It needs no `package.json`, npm author metadata, build step, or version field. `skillshop library init` selects the visible default `~/skillshop-library` or creates/adopts the explicit directory. It preserves existing library skills and config. `skillshop library list` shows the selected source folder.
27
+
28
+ The plain `skillshop-library.json` config names a relative `skillsDirectory` (default `skills`). Each skill is a directory with `SKILL.md` declaring `name` and `description`; references, scripts, hidden files, binary assets, and executable modes are included in the full directory.
29
+
30
+ `skillshop download @owner/skill` fetches `https://github.com/owner/skillshop-library.git` and adds only the requested `skills/skill` directory to the personal library. The leading `@` marks remote syntax and is stripped from the GitHub owner; the repository URL contains no `@`. Unprefixed `owner/skill` inputs are rejected. Download does not install the skill. Same-name authored or different-source collisions fail. `skillshop download @owner/skill --force` explicitly replaces that source after validating the remote tree. `skillshop install @owner/skill` downloads only when the source is absent. An existing same-origin source, including local edits, is reused without an upstream fetch. `skillshop install name` uses a bare name for a source already in the selected personal library.
31
+
32
+ `skillshop sync` updates copies recorded as personal-library installs and is the explicit migration step for tracked legacy bundled copies. It compares and copies the complete source tree, including path and mode changes, repairs edits made inside an installed copy, and removes stale extras inside that owned skill directory. It uses the source root recorded at install time, even after another library is selected. Sync does not fetch upstream. `skillshop library init` never migrates. For a tracked bundle copy, an existing selected-library source wins. If the source is missing, it is imported only when every surviving tracked project and global copy agrees in paths, bytes, and modes. Divergence, unknown source kinds, unreadable ownership state, unmanaged directories, and package-import snapshots are preserved and reported. A missing personal source reports an error and preserves existing destinations. Missing destinations are pruned from their ownership records and are not recreated. Keep durable edits in the personal source; sync replaces edits made in its owned installed copies.
12
33
 
13
34
  This registry is hand-maintained. `metadata.author` is original-author attribution, not ownership. The user can adapt skills locally. skillshop does not read other installers' state or update their skills; the user resolves name collisions.
14
35
 
15
- Every non-`agents` invocation, including help, version, and failed commands, reconciles the current project's lockfile, registration, and Git exclusions, except `skillshop upgrade`. Agent instruction commands also bypass reconciliation. Upgrade skips the old process's pre/post reconciliation, installs the npm package first, then starts the installed CLI in a fresh process to refresh only changed-version copies recorded in Skillshop's project/global ownership files. A failed npm install must not touch managed copies; report any partial copy refresh and return failure. Never adopt unmanaged directories or package-import snapshots, and do not recreate deleted paths. Reconciliation removes missing installation paths and stale repository registrations. Tracked installed frontmatter is repaired without replacing bodies or extra fields: names match directory names, author/version use the installation record, and missing descriptions use the bundled skill. Incomplete records fall back to installed or bundled metadata. Valid files are not rewritten. Invalid lockfiles, unsafe paths, and unsupported frontmatter must be reported, not discarded. Do not silently untrack Git files; that remains an explicit migration operation.
36
+ Most ordinary non-agent commands reconcile the current project's lockfile, registration, and Git exclusions. Personal library, download, qualified install, sync, upgrade, and agent-instruction commands bypass that legacy reconciliation. In personal mode, `skillshop list` and named installs also bypass it. Upgrade installs the npm package first, then starts the installed CLI in a fresh process to refresh changed-version bundled copies recorded in Skillshop's project/global ownership files. Personal-source copies are excluded from bundled refresh. A failed npm install must not touch managed copies; report any partial copy refresh and return failure. Never adopt unmanaged directories or package-import snapshots, and do not recreate deleted paths. Reconciliation removes missing installation paths and stale repository registrations. Tracked bundled installed frontmatter is repaired without replacing bodies or extra fields. Invalid lockfiles, unsafe paths, and unsupported frontmatter must be reported, not discarded. Do not silently untrack Git files; that remains an explicit migration operation.
16
37
 
17
38
  `skillshop init` is optional for upgrade. It configures agent destinations but does not install the npm package or refresh managed skill copies. Upgrade never generates or edits `AGENTS.md` files or project instruction templates.
18
39
 
40
+ ## Project skill dependencies
41
+
42
+ ```bash
43
+ skillshop dependency add @alice/review@v1.2.0
44
+ skillshop dependency install
45
+ skillshop install
46
+ skillshop ci
47
+ skillshop dependency remove review
48
+ ```
49
+
50
+ `dependency add` declares a GitHub skill in project-root `skillshop.json` and resolves its tag, branch or commit into an exact commit and complete-tree SHA-256 integrity in `skillshop-lock.json`. Commit both files. `skillsDirectory` defaults to `.agents/skills` and is project-owned, independent of user agent selection. Restore reuses the locked commit even when upstream refs move. `ci` and `install --frozen-lockfile` refuse missing/stale locks. These operations bypass personal libraries and unrelated legacy state. Dependency copies are not changed by personal sync, named update or CLI upgrade. Use npm's own lockfile for npm-distributed package dependencies.
51
+
52
+ All installers record/check ownership in the destination `.skillshop-lock`; unmanaged/foreign-owned collisions must fail. Package updates prune removed declarations only for that same package owner. Import `--force` authorizes replacement of the same package-import owner, not adoption of arbitrary directories.
53
+
19
54
  ## Configure agents
20
55
 
21
56
  ```bash
@@ -66,7 +101,7 @@ For npm consumers, declare explicit scripts rather than relying on an install ho
66
101
 
67
102
  Commit the template and block sources together with the rendered root `AGENTS.md`; commit `package.json` and the npm lockfile when Skillshop is a development dependency. Run `npm run agents:check` in CI to detect stale or missing generated output.
68
103
 
69
- ## Install a skill
104
+ ## Install a bundled skill when no personal library is selected
70
105
 
71
106
  Run commands from anywhere inside the repository:
72
107
 
@@ -77,7 +112,7 @@ skillshop i <name>
77
112
  skillshop add <name>
78
113
  ```
79
114
 
80
- The CLI installs the full skill package into every configured project path, refreshes every skill already listed in `.skillshop-lock`, and updates the lockfile. Changing the configured agents migrates managed local copies on the next install or update. Local installs are recorded in `~/.skillshop/repositories.json`; `.skillshop-lock` and its managed skill paths are added to the repository's Git `info/exclude`.
115
+ Without a selected personal library, the CLI installs the full skill package into every configured project path, refreshes every skill already listed in `.skillshop-lock`, and updates the lockfile. Changing the configured agents migrates managed local copies on the next install or update. Local installs are recorded in `~/.skillshop/repositories.json`; destination `.skillshop-lock` files and their managed skill paths are added to the repository's Git `info/exclude`. With a personal library selected, `skillshop list` and unqualified named installs use that library only; they do not reconcile bundled copies or add bundled `skillshop` guidance.
81
116
 
82
117
  To require the exact version currently available from the local registry:
83
118
 
@@ -88,7 +123,7 @@ skillshop install <name> --version <version>
88
123
  skillshop <name> --global
89
124
  ```
90
125
 
91
- `--global` (or `-g`) installs into every configured system skill path without a project lockfile. Installs are recorded in `~/.skillshop/global-skills.json`. Except for `skillshop upgrade`, ordinary commands other than `skillshop agents` can repair their frontmatter and prune missing paths. Agent-instruction commands bypass that reconciliation. `skillshop upgrade` instead refreshes recorded global and registered project copies only after npm succeeds and a fresh CLI starts. Same-version copies keep local body edits; a changed-version refresh replaces the managed copy and overwrites local edits. Older installs are recorded on explicit reinstall; do not adopt other global skills. Global copies are not migrated when the agent setting changes, so remove unwanted global skills before reconfiguration. The local registry contains one current version per skill. `--version` (or `-v`) is an exact compatibility check; do not claim that an unavailable historical version can be resolved.
126
+ `--global` (or `-g`) installs into every configured system skill path without a project lockfile. Ownership is recorded in `.skillshop-lock` inside each global skills directory. For bundled tracked installs, most ordinary commands can repair frontmatter and prune missing paths. Personal library, download, qualified install, sync, upgrade, and agent-instruction commands bypass that legacy reconciliation. In personal mode, `skillshop list` and named installs bypass it too. `skillshop upgrade` refreshes recorded bundled global and registered project copies only after npm succeeds and a fresh CLI starts. Same-version bundled copies keep local body edits; a changed-version refresh replaces that managed copy and overwrites its local edits. For durable personal edits, select a folder library, use explicit sync to adopt agreeing tracked copies, and edit the library source. Personal-library copies are excluded from bundled refresh. Explicit `skillshop sync` never scans untracked directories. Global copies are not migrated when the agent setting changes, so remove unwanted global skills before reconfiguration. The local registry contains one current version per skill. `--version` (or `-v`) is an exact compatibility check; do not claim that an unavailable historical version can be resolved.
92
127
 
93
128
  ## Update managed skills
94
129
 
@@ -101,7 +136,7 @@ skillshop update --push
101
136
  skillshop up --push
102
137
  ```
103
138
 
104
- `update` with no name replaces every skill listed in the current repository's `.skillshop-lock` and updates its locked metadata. A name updates only that managed skill. `--push` updates every centrally registered repository and cannot be combined with a skill name. Updates and installs replace local edits; keep edits in the registry if they should survive these operations. Global installs cannot be updated with this command; reinstall them by name with `--global`.
139
+ `update` with no name replaces every skill listed in the current repository's directory-local ownership manifests and updates its locked metadata. A name updates only that managed skill. `--push` updates every centrally registered repository and cannot be combined with a skill name. Updates and installs replace local edits; keep edits in the registry if they should survive these operations. Global installs cannot be updated with this command; reinstall them by name with `--global`.
105
140
 
106
141
  ## Upgrade Skillshop
107
142
 
@@ -111,7 +146,7 @@ skillshop upgrade
111
146
 
112
147
  Upgrade targets the npm installation that supplied the invoked CLI. A local npm dependency updates in its owning project and stays in its existing dependency section; only that project's locked Skillshop copies are refreshed. A global npm installation updates its active prefix and refreshes recorded global copies plus copies in registered projects without changing those projects' npm manifests. A source checkout retains the compatibility behavior of installing Skillshop globally, without editing the checkout's `package.json`.
113
148
 
114
- After npm succeeds, the new package's CLI starts in a fresh process. It replaces existing recorded copies only when the bundled skill version changed, preserving local bodies for same-version copies. Changed-version replacement overwrites local edits in that managed copy; make durable changes in the bundled skill source. Missing paths are pruned from records rather than recreated. Unmanaged skills and imported snapshots remain untouched. Install failure prevents refresh; refresh failure reports the npm stage as successful and returns nonzero with a partial-result summary. Upgrade does not run agent initialization or project instruction generation.
149
+ After npm succeeds, the new package's CLI starts in a fresh process. It migrates valid legacy project-root and global ownership records to destination manifests before refreshing skills, even when versions are unchanged. Old records are removed only after replacement manifests are saved. Conflicting, malformed or unsafe state is preserved and reported. It replaces existing recorded copies only when the bundled skill version changed, preserving local bodies for same-version copies. Changed-version replacement overwrites local edits in that managed copy. For durable personal edits, select a library, explicitly sync to adopt agreeing tracked copies, then edit the library source and sync. Missing paths are pruned from records rather than recreated. Unmanaged skills and imported snapshots remain untouched. Install failure prevents refresh; refresh failure reports the npm stage as successful and returns nonzero with a partial-result summary. Upgrade does not run agent initialization or project instruction generation.
115
150
 
116
151
  ## Migrate existing repositories
117
152
 
@@ -132,7 +167,7 @@ skillshop rm <name>
132
167
  skillshop uninstall <name> --global
133
168
  ```
134
169
 
135
- Local uninstall removes every managed copy of the named directory only when it is listed in `.skillshop-lock`, then removes its lock entry. It must leave unlisted directories alone. Later updates honor the remaining lock entries and do not restore an uninstalled skill. Use `--global` to explicitly remove the same name from the configured system paths and clear corresponding global records.
170
+ Local uninstall removes every managed copy of the named directory only when it is listed in `.skillshop-lock`, then removes its lock entry. It must leave unlisted directories alone. Later updates honor the remaining lock entries and do not restore an uninstalled skill. Global uninstall also requires CLI ownership; it must preserve application-owned and unmanaged skills. Use `dependency remove` for dependency-owned copies.
136
171
 
137
172
  ## Inspect the registry and CLI
138
173
 
@@ -152,6 +187,6 @@ skillshop -h
152
187
  - Do not manually add unrelated skills to `.skillshop-lock`.
153
188
  - Do not remove or overwrite unlisted skill directories.
154
189
  - Installed copies can be edited, but installs and updates replace them. Preserve durable edits in the registry.
155
- - Do not add lock-managed skill directories or `.skillshop-lock` to Git; they are locally excluded dependencies.
156
- - Global installation with `skillshop <name> --global` writes to the configured system paths and records only skillshop's own installs in `~/.skillshop/global-skills.json`.
190
+ - Do not add lock-managed skill directories or `.skillshop-lock` to Git; they are local installation state. Commit `skillshop.json` and `skillshop-lock.json` when using project dependencies.
191
+ - Global installation with `skillshop <name> --global` writes to the configured system paths and records ownership in each destination skills directory's `.skillshop-lock`.
157
192
  - Use `skillshop list` to inspect available local-registry names, versions, and authors.
package/src/cli.js CHANGED
@@ -8,25 +8,34 @@ import { importSkill, resolveSkill } from './import.js';
8
8
  import { installDeclaredUserSkills } from './user-install.js';
9
9
  import { reconcileLegacyState, runLegacyCli } from './legacy-cli.js';
10
10
  import { parseLibrarySkillReference, skillName } from './specifier.js';
11
+ import { addDependency, installDependencies, removeDependency, updateDependencies } from './dependencies.js';
11
12
 
12
13
  function help() {
13
14
  console.log(`
14
- Library commands:
15
+ Personal folder library commands:
15
16
 
16
- skillshop library init
17
+ skillshop library init [directory]
17
18
  skillshop library list
18
- skillshop export skill:<name> <directory>
19
- skillshop export skill:<name> --from <package> [--skill skill:<source-name>]
20
- skillshop unexport skill:<name>
21
- skillshop validate
22
- skillshop build
23
- skillshop resolve <library>:<skill>
24
- skillshop import <library>:<skill> [--global] [--to <directory>] [--force]
25
- skillshop install-user [package-dir] [--if-global] [--to <skills-dir>]
19
+ skillshop download @<owner>/<skill> [--force]
20
+ skillshop install @<owner>/<skill> [--global]
21
+ skillshop install <name> [--global]
22
+ skillshop sync
26
23
 
27
- A library publishes skills by portable specifier, for example skill:review.
28
- Import uses the short <library>:<skill> form and copies to .agents/skills/<skill> by default.
29
- --global copies to ~/.agents/skills/<skill> and replaces an existing directory.`);
24
+ Create or select a source folder with skillshop library init. Download @owner/skill adds a GitHub skill to that folder without installing it.
25
+ Install @owner/skill downloads when needed, then installs the complete source tree. Bare install names use the selected personal library. Sync updates recorded project and global copies from their saved source roots.
26
+ No npm package manifest or author metadata is required. Existing npm package export/import APIs remain available for compatibility.`);
27
+ console.log(`
28
+ Project skill dependencies (separate from personal-library installs):
29
+
30
+ skillshop dependency add @<owner>/<skill>[@<tag-or-commit>]
31
+ skillshop dependency install [--frozen-lockfile]
32
+ skillshop dependency update [name]
33
+ skillshop dependency remove <name>
34
+ skillshop install Restore declared project dependencies
35
+ skillshop ci Restore with a frozen dependency lock
36
+
37
+ Commit skillshop.json and skillshop-lock.json. Installed ownership lives in each skills directory's .skillshop-lock.
38
+ Dependency restores use exact locked Git commits and content integrity; personal sync does not update them.`);
30
39
  }
31
40
 
32
41
  function flag(args, name) {
@@ -46,8 +55,22 @@ const libraryCommands = new Set(['export', 'unexport', 'validate', 'build', 'res
46
55
 
47
56
  async function main(argv, { libraryNamespace = false } = {}) {
48
57
  const [command, ...args] = argv;
58
+ if (command === 'dependency' || command === 'ci' || (command === 'install' && (args.length === 0 || (args.length === 1 && args[0] === '--frozen-lockfile')))) {
59
+ if (args.includes('--help') || args.includes('-h')) return help();
60
+ let result;
61
+ if (command === 'dependency' && args[0] === 'add' && args.length === 2) result = addDependency(args[1]);
62
+ else if (command === 'dependency' && args[0] === 'remove' && args.length === 2) result = removeDependency(args[1]);
63
+ else if (command === 'dependency' && args[0] === 'update' && (args.length === 1 || args.length === 2)) result = updateDependencies(args[1] ?? null);
64
+ else if (command === 'ci' && args.length === 0) result = installDependencies({ frozen: true });
65
+ else if (command === 'install') result = installDependencies({ frozen: args.includes('--frozen-lockfile') });
66
+ else if (command === 'dependency' && args[0] === 'install' && (args.length === 1 || (args.length === 2 && args[1] === '--frozen-lockfile'))) {
67
+ result = installDependencies({ frozen: args.includes('--frozen-lockfile') });
68
+ } else throw new Error('Usage: skillshop dependency add @owner/skill[@ref] | install [--frozen-lockfile] | update [name] | remove <name>');
69
+ console.log(`Project skill dependencies: ${Object.keys(result.lock.dependencies).length} locked, ${result.changed} installation change(s) at ${result.root}`);
70
+ return;
71
+ }
49
72
  if (!libraryNamespace && command === 'library') {
50
- if (args[0] === 'init' || args[0] === 'list') return main(args, { libraryNamespace: true });
73
+ if (args[0] === 'init' || args[0] === 'list') return runLegacyCli(argv);
51
74
  if (!args.length || args[0] === '--help' || args[0] === '-h') return help();
52
75
  throw new Error('Usage: skillshop library init | list');
53
76
  }
@@ -146,13 +169,14 @@ async function main(argv, { libraryNamespace = false } = {}) {
146
169
 
147
170
  const argv = process.argv.slice(2);
148
171
  const usingLibraryCommands = argv[0] === 'library' || libraryCommands.has(argv[0]);
172
+ const personalLibraryCommand = argv[0] === 'library' && ['init', 'list'].includes(argv[1]);
149
173
 
150
174
  try {
151
- if (usingLibraryCommands) reconcileLegacyState();
175
+ if (usingLibraryCommands && !personalLibraryCommand) reconcileLegacyState();
152
176
  try {
153
177
  await main(argv);
154
178
  } finally {
155
- if (usingLibraryCommands) reconcileLegacyState();
179
+ if (usingLibraryCommands && !personalLibraryCommand) reconcileLegacyState();
156
180
  }
157
181
  } catch (error) {
158
182
  console.error(`skillshop: ${error.message}`);
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { fetchGitHubSkill, parseGitHubSkillReference } from './github-library.js';
7
+ import { atomicJson, assertRegularPath, assertInstallationOwner, readInstallationManifest, writeInstallationManifest, sameOwner } from './installation-state.js';
8
+
9
+ export const dependencyManifestName = 'skillshop.json';
10
+ export const dependencyLockName = 'skillshop-lock.json';
11
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
12
+ const sorted = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
13
+
14
+ export function parseDependencyReference(value) {
15
+ if (typeof value !== 'string') throw new Error('skill dependency must be @owner/skill or @owner/skill@ref');
16
+ const separator = value.lastIndexOf('@');
17
+ const reference = separator > 0 ? value.slice(0, separator) : value;
18
+ const ref = separator > 0 ? value.slice(separator + 1) : null;
19
+ if (ref !== null && (!ref || ref.startsWith('-') || !/^[A-Za-z0-9_./-]+$/.test(ref) || ref.includes('..') || ref.endsWith('/'))) {
20
+ throw new Error(`invalid skill dependency ref: ${ref}`);
21
+ }
22
+ return { ...parseGitHubSkillReference(reference), reference, ref, requested: value };
23
+ }
24
+
25
+ export function dependencyProjectRoot(cwd = process.cwd()) {
26
+ let current = path.resolve(cwd);
27
+ while (true) {
28
+ if (fs.existsSync(path.join(current, dependencyManifestName)) || fs.existsSync(path.join(current, '.git'))) return fs.realpathSync(current);
29
+ const parent = path.dirname(current);
30
+ if (parent === current) return fs.realpathSync(cwd);
31
+ current = parent;
32
+ }
33
+ }
34
+
35
+ function readJson(file) {
36
+ assertRegularPath(file);
37
+ if (!fs.existsSync(file)) return null;
38
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
39
+ catch (error) { throw new Error(`cannot parse ${file}: ${error.message}`); }
40
+ }
41
+
42
+ function skillsPath(root, relative) {
43
+ if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) || relative.includes('\\')) throw new Error('dependency skillsDirectory must be a relative project directory');
44
+ const directory = path.resolve(root, relative);
45
+ const contained = path.relative(root, directory);
46
+ if (!contained || contained === '..' || contained.startsWith(`..${path.sep}`) || path.isAbsolute(contained)) throw new Error('dependency skillsDirectory escapes the project');
47
+ assertRegularPath(directory);
48
+ return directory;
49
+ }
50
+
51
+ function readManifest(root, { create = false } = {}) {
52
+ const manifest = readJson(path.join(root, dependencyManifestName));
53
+ if (!manifest) {
54
+ if (create) return { schemaVersion: 1, skillsDirectory: '.agents/skills', dependencies: {} };
55
+ throw new Error(`no ${dependencyManifestName}; add a dependency with skillshop dependency add @owner/skill@ref`);
56
+ }
57
+ if (manifest.schemaVersion !== 1 || !object(manifest.dependencies)) throw new Error(`${dependencyManifestName} is not a supported dependency manifest`);
58
+ skillsPath(root, manifest.skillsDirectory);
59
+ for (const [name, reference] of Object.entries(manifest.dependencies)) {
60
+ if (parseDependencyReference(reference).name !== name) throw new Error(`dependency ${name} must match its upstream skill name`);
61
+ }
62
+ return manifest;
63
+ }
64
+
65
+ function readLock(root) {
66
+ const lock = readJson(path.join(root, dependencyLockName));
67
+ if (!lock) return null;
68
+ if (lock.lockfileVersion !== 1 || !object(lock.dependencies)) throw new Error(`${dependencyLockName} is not a supported dependency lockfile`);
69
+ skillsPath(root, lock.skillsDirectory);
70
+ for (const [name, entry] of Object.entries(lock.dependencies)) {
71
+ if (!object(entry)) throw new Error(`invalid locked dependency ${name}`);
72
+ const parsed = parseDependencyReference(entry.reference);
73
+ if (parsed.name !== name || entry.repository !== parsed.repository || !/^[a-f0-9]{40,64}$/.test(entry.commit) || !/^sha256-[A-Za-z0-9+/]{43}=$/.test(entry.integrity)) {
74
+ throw new Error(`invalid locked dependency ${name}`);
75
+ }
76
+ }
77
+ return lock;
78
+ }
79
+
80
+ export function skillIntegrity(directory) {
81
+ assertRegularPath(directory);
82
+ const hash = createHash('sha256');
83
+ const visit = (relative = '') => {
84
+ const absolute = path.join(directory, relative);
85
+ const stat = fs.lstatSync(absolute);
86
+ if (stat.isSymbolicLink()) throw new Error(`skill contains a symlink: ${absolute}`);
87
+ if (stat.isDirectory()) {
88
+ hash.update(JSON.stringify(['directory', relative]));
89
+ for (const name of fs.readdirSync(absolute).sort()) visit(relative ? `${relative}/${name}` : name);
90
+ } else if (stat.isFile()) {
91
+ const contents = fs.readFileSync(absolute);
92
+ hash.update(JSON.stringify(['file', relative, Boolean(stat.mode & 0o111), contents.length]));
93
+ hash.update(contents);
94
+ } else throw new Error(`skill contains a non-regular entry: ${absolute}`);
95
+ };
96
+ visit();
97
+ return `sha256-${hash.digest('base64')}`;
98
+ }
99
+
100
+ function lockMatches(manifest, lock) {
101
+ return lock && lock.skillsDirectory === manifest.skillsDirectory &&
102
+ JSON.stringify(sorted(manifest.dependencies)) === JSON.stringify(sorted(Object.fromEntries(Object.entries(lock.dependencies).map(([name, entry]) => [name, entry.reference]))));
103
+ }
104
+
105
+ function snapshotFile(file) {
106
+ assertRegularPath(file);
107
+ return fs.existsSync(file) ? { bytes: fs.readFileSync(file), mode: fs.statSync(file).mode & 0o777 } : null;
108
+ }
109
+
110
+ function dependencyExclusion(root, manifest, directories) {
111
+ let gitRoot;
112
+ try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
113
+ catch { return null; }
114
+ const relativeRoot = path.relative(gitRoot, root);
115
+ if (relativeRoot === '..' || relativeRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRoot)) throw new Error('dependency project is outside its Git root');
116
+ const gitPath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: root, encoding: 'utf8' }).trim();
117
+ const file = path.resolve(root, gitPath);
118
+ assertRegularPath(file);
119
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
120
+ const begin = '# >>> skillshop project dependencies >>>';
121
+ const end = '# <<< skillshop project dependencies <<<';
122
+ const lines = existing.replace(/\r\n/g, '\n').split('\n');
123
+ const starts = lines.flatMap((line, i) => line === begin ? [i] : []);
124
+ const ends = lines.flatMap((line, i) => line === end ? [i] : []);
125
+ if (starts.length !== ends.length || starts.length > 1 || (starts.length && starts[0] >= ends[0])) throw new Error('dependency Git exclude markers are malformed');
126
+ const retained = starts.length ? [...lines.slice(0, starts[0]), ...lines.slice(ends[0] + 1)] : lines;
127
+ while (retained.at(-1) === '') retained.pop();
128
+ const pattern = (target) => `/${path.relative(gitRoot, target).split(path.sep).join('/').replaceAll('\\', '\\\\').replace(/([ !#*?\[\]])/g, '\\$1')}`;
129
+ const directory = skillsPath(root, manifest.skillsDirectory);
130
+ const paths = [...directories.keys()].map((installedDirectory) => pattern(path.join(installedDirectory, '.skillshop-lock')));
131
+ paths.push(...Object.keys(manifest.dependencies).map((name) => `${pattern(path.join(directory, name))}/`));
132
+ return { file, contents: `${[...retained, ...(retained.length ? [''] : []), begin, ...[...new Set(paths)].sort(), end].join('\n')}\n` };
133
+ }
134
+
135
+ // All downloads and ownership/integrity checks finish before mutating any
136
+ // installation. Backups keep replacement/removal and metadata writes recoverable.
137
+ function commitChanges(root, manifest, lock, changes, directoryStates, { saveManifest }) {
138
+ const token = randomUUID();
139
+ const exclusion = dependencyExclusion(root, manifest, directoryStates);
140
+ const files = [path.join(root, dependencyLockName), ...(saveManifest ? [path.join(root, dependencyManifestName)] : []),
141
+ ...[...directoryStates.keys()].map((directory) => path.join(directory, '.skillshop-lock')), ...(exclusion ? [exclusion.file] : [])];
142
+ const before = new Map(files.map((file) => [file, snapshotFile(file)]));
143
+ const applied = [];
144
+ try {
145
+ for (const change of changes) {
146
+ const target = path.join(change.directory, change.name);
147
+ const backup = `${target}.skillshop-backup-${token}`;
148
+ const temporary = `${target}.skillshop-tmp-${token}`;
149
+ fs.mkdirSync(change.directory, { recursive: true });
150
+ if (change.source) fs.cpSync(change.source, temporary, { recursive: true });
151
+ const existed = fs.existsSync(target);
152
+ if (existed) fs.renameSync(target, backup);
153
+ const step = { target, backup, temporary, existed };
154
+ applied.push(step);
155
+ if (change.source) fs.renameSync(temporary, target);
156
+ }
157
+ for (const [directory, state] of directoryStates) writeInstallationManifest(directory, state);
158
+ atomicJson(path.join(root, dependencyLockName), lock);
159
+ if (saveManifest) atomicJson(path.join(root, dependencyManifestName), { ...manifest, dependencies: sorted(manifest.dependencies) });
160
+ if (exclusion && (!before.get(exclusion.file) || before.get(exclusion.file).bytes.toString('utf8') !== exclusion.contents)) {
161
+ fs.mkdirSync(path.dirname(exclusion.file), { recursive: true });
162
+ fs.writeFileSync(exclusion.file, exclusion.contents);
163
+ }
164
+ } catch (error) {
165
+ for (const step of applied.reverse()) {
166
+ fs.rmSync(step.target, { recursive: true, force: true });
167
+ if (step.existed) fs.renameSync(step.backup, step.target);
168
+ }
169
+ for (const [file, previous] of before) {
170
+ if (previous) {
171
+ fs.mkdirSync(path.dirname(file), { recursive: true });
172
+ fs.writeFileSync(file, previous.bytes, { mode: previous.mode });
173
+ fs.chmodSync(file, previous.mode);
174
+ } else fs.rmSync(file, { force: true });
175
+ }
176
+ throw error;
177
+ } finally {
178
+ for (const change of changes) {
179
+ const target = path.join(change.directory, change.name);
180
+ fs.rmSync(`${target}.skillshop-tmp-${token}`, { recursive: true, force: true });
181
+ }
182
+ }
183
+ // Cleanup after the commit cannot roll back a successfully published install.
184
+ for (const step of applied) fs.rmSync(step.backup, { recursive: true, force: true });
185
+ }
186
+
187
+ function installManifest(root, manifest, previousLock, { frozen = false, saveManifest = false } = {}) {
188
+ if (frozen && !lockMatches(manifest, previousLock)) throw new Error(`${dependencyLockName} is missing or out of date with ${dependencyManifestName}; frozen install refused`);
189
+ const directory = skillsPath(root, manifest.skillsDirectory);
190
+ const owner = { kind: 'dependency', id: 'project' };
191
+ const directories = new Map([[directory, readInstallationManifest(directory)]]);
192
+ if (previousLock) {
193
+ const previousDirectory = skillsPath(root, previousLock.skillsDirectory);
194
+ if (!directories.has(previousDirectory)) directories.set(previousDirectory, readInstallationManifest(previousDirectory));
195
+ }
196
+ // Detect ownership conflicts before network requests, including removals.
197
+ for (const name of Object.keys(manifest.dependencies)) assertInstallationOwner(directory, name, owner);
198
+ const removals = [];
199
+ for (const [installedDirectory, state] of directories) {
200
+ for (const [name, entry] of Object.entries(state.skills)) {
201
+ if (sameOwner(entry.owner, owner) && (installedDirectory !== directory || !Object.hasOwn(manifest.dependencies, name))) {
202
+ assertInstallationOwner(installedDirectory, name, owner, { requireOwned: true });
203
+ removals.push({ directory: installedDirectory, name });
204
+ delete state.skills[name];
205
+ }
206
+ }
207
+ }
208
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skillshop-dependencies-'));
209
+ try {
210
+ const dependencies = {};
211
+ const changes = [...removals];
212
+ for (const [name, reference] of Object.entries(sorted(manifest.dependencies))) {
213
+ const parsed = parseDependencyReference(reference);
214
+ const previous = previousLock?.dependencies[name];
215
+ const locked = previous?.reference === reference ? previous : null;
216
+ const target = path.join(directory, name);
217
+ const existing = directories.get(directory).skills[name];
218
+ let current = false;
219
+ if (locked && existing?.source?.commit === locked.commit && existing?.source?.integrity === locked.integrity && fs.existsSync(target)) {
220
+ current = skillIntegrity(target) === locked.integrity;
221
+ }
222
+ let resolved = locked;
223
+ if (!current) {
224
+ const staging = path.join(temporaryRoot, name);
225
+ fs.mkdirSync(staging);
226
+ const fetched = fetchGitHubSkill(parsed.reference, staging, locked ? { commit: locked.commit } : { ref: parsed.ref });
227
+ const integrity = skillIntegrity(fetched.skillDirectory);
228
+ if (locked && integrity !== locked.integrity) throw new Error(`integrity mismatch for locked dependency ${name}; installation unchanged`);
229
+ resolved = { reference, repository: parsed.repository, commit: fetched.origin.commit, integrity };
230
+ changes.push({ directory, name, source: fetched.skillDirectory });
231
+ }
232
+ dependencies[name] = resolved;
233
+ directories.get(directory).skills[name] = { owner, source: { kind: 'dependency', ...resolved } };
234
+ }
235
+ const lock = { lockfileVersion: 1, skillsDirectory: manifest.skillsDirectory, dependencies };
236
+ commitChanges(root, manifest, lock, changes, directories, { saveManifest });
237
+ return { root, lock, changed: changes.length };
238
+ } finally {
239
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
240
+ }
241
+ }
242
+
243
+ export function addDependency(reference, { cwd = process.cwd() } = {}) {
244
+ const parsed = parseDependencyReference(reference);
245
+ const root = dependencyProjectRoot(cwd);
246
+ const manifest = readManifest(root, { create: true });
247
+ manifest.dependencies[parsed.name] = reference;
248
+ return installManifest(root, manifest, readLock(root), { saveManifest: true });
249
+ }
250
+
251
+ export function installDependencies({ cwd = process.cwd(), frozen = false } = {}) {
252
+ const root = dependencyProjectRoot(cwd);
253
+ return installManifest(root, readManifest(root), readLock(root), { frozen });
254
+ }
255
+
256
+ export function updateDependencies(name = null, { cwd = process.cwd() } = {}) {
257
+ const root = dependencyProjectRoot(cwd);
258
+ const manifest = readManifest(root);
259
+ if (name !== null && !Object.hasOwn(manifest.dependencies, name)) throw new Error(`${name} is not a declared project skill dependency`);
260
+ const lock = readLock(root);
261
+ if (lock) {
262
+ for (const key of Object.keys(manifest.dependencies)) {
263
+ if (name === null || key === name) delete lock.dependencies[key];
264
+ }
265
+ }
266
+ return installManifest(root, manifest, lock);
267
+ }
268
+
269
+ export function removeDependency(name, { cwd = process.cwd() } = {}) {
270
+ const root = dependencyProjectRoot(cwd);
271
+ const manifest = readManifest(root);
272
+ if (!Object.hasOwn(manifest.dependencies, name)) throw new Error(`${name} is not a declared project skill dependency`);
273
+ const lock = readLock(root);
274
+ if (!lockMatches(manifest, lock)) throw new Error(`${dependencyLockName} is missing or out of date; install dependencies before removing one`);
275
+ const directory = skillsPath(root, manifest.skillsDirectory);
276
+ const state = assertInstallationOwner(directory, name, { kind: 'dependency', id: 'project' });
277
+ delete state.skills[name];
278
+ delete manifest.dependencies[name];
279
+ delete lock.dependencies[name];
280
+ commitChanges(root, manifest, lock, [{ directory, name }], new Map([[directory, state]]), { saveManifest: true });
281
+ return { root, lock, changed: 1 };
282
+ }