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/README.md +71 -190
- package/docs/installation-and-dependency-state.md +76 -0
- package/docs/package-library-compatibility.md +194 -0
- package/docs/releases/4.1.0.md +12 -0
- package/package.json +7 -3
- package/skills-content/skillshop/SKILL.md +48 -13
- package/src/cli.js +40 -16
- package/src/dependencies.js +282 -0
- package/src/github-library.js +322 -0
- package/src/import.js +15 -0
- package/src/index.js +2 -0
- package/src/installation-state.js +113 -0
- package/src/legacy-cli.js +858 -103
- package/src/personal-library.js +591 -0
- package/src/user-install.js +34 -1
- package/templates/skillshop-library/README.md +40 -0
- package/templates/skillshop-library/skills/example/SKILL.md +8 -0
- package/templates/skillshop-library/skills/example/references/example.md +3 -0
- package/templates/skillshop-library/skills/example/scripts/run.sh +2 -0
- package/templates/skillshop-library/skillshop-library.json +4 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillshop",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
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
|
|
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.
|
|
6
|
+
version: "2026.10.28.1"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# Skillshop —
|
|
9
|
+
# Skillshop — Personal Skill Libraries and Local Registry
|
|
10
10
|
|
|
11
|
-
Use the `skillshop` CLI to manage
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
156
|
-
- Global installation with `skillshop <name> --global` writes to the configured system paths and records
|
|
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
|
-
|
|
15
|
+
Personal folder library commands:
|
|
15
16
|
|
|
16
|
-
skillshop library init
|
|
17
|
+
skillshop library init [directory]
|
|
17
18
|
skillshop library list
|
|
18
|
-
skillshop
|
|
19
|
-
skillshop
|
|
20
|
-
skillshop
|
|
21
|
-
skillshop
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
+
}
|