skillshop 2.1.0 → 4.0.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 +95 -178
- package/docs/package-library-compatibility.md +194 -0
- package/package.json +4 -2
- package/skills-content/skillshop/SKILL.md +83 -9
- package/src/agents-instructions.js +41 -4
- package/src/cli.js +13 -16
- package/src/github-library.js +310 -0
- package/src/legacy-cli.js +1293 -49
- package/src/personal-library.js +591 -0
- package/templates/skillshop-library/README.md +38 -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
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: skillshop
|
|
3
|
-
description: Manage repository-local agent skills
|
|
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.1"
|
|
6
|
+
version: "2026.10.23.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; `.skillshop-lock` and `~/.skillshop/global-skills.json` record which project and global directories Skillshop installed from those sources. 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.
|
|
37
|
+
|
|
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.
|
|
16
39
|
|
|
17
40
|
## Configure agents
|
|
18
41
|
|
|
@@ -21,9 +44,50 @@ skillshop init
|
|
|
21
44
|
skillshop config agents
|
|
22
45
|
```
|
|
23
46
|
|
|
24
|
-
The first `skillshop init` prompts for one or more agents and stores the system-wide selection in `~/.skillshop/config.json`. `skillshop config agents` replaces
|
|
47
|
+
The first `skillshop init` prompts for one or more agents and stores the system-wide selection in `~/.skillshop/config.json`. `skillshop init` is optional for project skill installs: without saved agent selection, Skillshop uses the nearest existing `.agents`, `.github`, or `.codex` directory and falls back to project-root `.agents/skills`. `skillshop config agents` replaces the system-wide selection. Copilot uses `.github/skills` locally and `~/.copilot/skills` globally; Claude uses `.claude/skills` in both scopes; Codex, OpenCode, and Others use `.agents/skills` in both scopes. Shared paths are deduplicated.
|
|
48
|
+
|
|
49
|
+
## Manage project agent instructions
|
|
25
50
|
|
|
26
|
-
|
|
51
|
+
```bash
|
|
52
|
+
skillshop agents init
|
|
53
|
+
skillshop agents
|
|
54
|
+
skillshop agents --check
|
|
55
|
+
skillshop agents --global
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`skillshop agents init` opts into project-owned instruction sources at `.agents/instructions/AGENTS.template.md` and sibling `*.block.md` files. Skillshop writes their output to a separate managed region in the project-root `AGENTS.md`, preserving surrounding text and older managed guidance. Edit the template and source blocks, not the generated region. Repeated `skillshop agents init` preserves source edits. If the initialized source template is removed, plain `skillshop agents` reports an error instead of restoring bundled personal guidance.
|
|
59
|
+
|
|
60
|
+
Before project sources are configured, plain `skillshop agents` keeps the bounded legacy behavior: it updates the nearest existing `AGENTS.md` inside the selected boundary, or creates the root output when none exists. The boundary is the nearest Git repository/worktree root, or the current directory outside Git. `--project <directory>` explicitly selects another boundary relative to the current working directory. Use it for nested projects so every script stays inside the nested package:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"scripts": {
|
|
65
|
+
"agents": "skillshop agents --project .",
|
|
66
|
+
"agents:init": "skillshop agents init --project .",
|
|
67
|
+
"agents:check": "skillshop agents --check --project ."
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`--check` is read-only and suitable for CI. It compares generated output in the working tree with current sources; it does not inspect the Git index or commit. Symlink components in project source, block, template, and output paths are rejected, including dangling symlinks. `--global` retains user-level instruction updates.
|
|
73
|
+
|
|
74
|
+
Source templates support `{{ include "name.block.md" }}`, `{{ include 'name.block.md' }}`, the legacy `{{ fragments["name.block.md"] }}` lookup, and `{{ blocks }}` for remaining blocks in filename order. Explicitly included blocks are excluded from the aggregate. Repeated includes are allowed. Block text is inserted literally and is not evaluated recursively.
|
|
75
|
+
|
|
76
|
+
For npm consumers, declare explicit scripts rather than relying on an install hook:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"scripts": {
|
|
81
|
+
"agents": "skillshop agents",
|
|
82
|
+
"agents:init": "skillshop agents init",
|
|
83
|
+
"agents:check": "skillshop agents --check"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
## Install a bundled skill when no personal library is selected
|
|
27
91
|
|
|
28
92
|
Run commands from anywhere inside the repository:
|
|
29
93
|
|
|
@@ -34,7 +98,7 @@ skillshop i <name>
|
|
|
34
98
|
skillshop add <name>
|
|
35
99
|
```
|
|
36
100
|
|
|
37
|
-
|
|
101
|
+
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`; `.skillshop-lock` and its 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.
|
|
38
102
|
|
|
39
103
|
To require the exact version currently available from the local registry:
|
|
40
104
|
|
|
@@ -45,7 +109,7 @@ skillshop install <name> --version <version>
|
|
|
45
109
|
skillshop <name> --global
|
|
46
110
|
```
|
|
47
111
|
|
|
48
|
-
`--global` (or `-g`) installs into every configured system skill path without a project lockfile. Installs are recorded in `~/.skillshop/global-skills.json
|
|
112
|
+
`--global` (or `-g`) installs into every configured system skill path without a project lockfile. Installs are recorded in `~/.skillshop/global-skills.json`. 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.
|
|
49
113
|
|
|
50
114
|
## Update managed skills
|
|
51
115
|
|
|
@@ -60,6 +124,16 @@ skillshop up --push
|
|
|
60
124
|
|
|
61
125
|
`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`.
|
|
62
126
|
|
|
127
|
+
## Upgrade Skillshop
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
skillshop upgrade
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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`.
|
|
134
|
+
|
|
135
|
+
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. 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.
|
|
136
|
+
|
|
63
137
|
## Migrate existing repositories
|
|
64
138
|
|
|
65
139
|
```bash
|
|
@@ -6,6 +6,7 @@ export class AgentsInstructionsError extends Error {}
|
|
|
6
6
|
|
|
7
7
|
const blockSuffix = '.block.md';
|
|
8
8
|
const referencePattern = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
9
|
+
const includePattern = /^include\s+(["'])([^"']+)\1$/;
|
|
9
10
|
const namedReferencePattern = /^fragments\s*\[\s*(["'])([^"'\[\]]+)\1\s*\]$/;
|
|
10
11
|
const aggregateExpression = 'blocks';
|
|
11
12
|
const regionBeginPrefix = '<!-- skillshop agents: begin v';
|
|
@@ -13,6 +14,9 @@ const regionBeginPattern = /^<!-- (?:skillshop|skillz) agents: begin v\S+ -->$/;
|
|
|
13
14
|
const regionEndLine = '<!-- skillshop agents: end -->';
|
|
14
15
|
const oldRegionEndLine = '<!-- skillz agents: end -->';
|
|
15
16
|
const legacyMarkerPattern = /^<!-- skillz agents: AGENTS\.md@\S+ -->$/;
|
|
17
|
+
const projectRegionBeginPrefix = '<!-- skillshop project agents: begin v';
|
|
18
|
+
const projectRegionBeginPattern = /^<!-- skillshop project agents: begin v\S+ -->$/;
|
|
19
|
+
const projectRegionEndLine = '<!-- skillshop project agents: end -->';
|
|
16
20
|
const fragmentsBegin = '<!-- skillshop fragments: begin -->';
|
|
17
21
|
const fragmentsEnd = '<!-- skillshop fragments: end -->';
|
|
18
22
|
|
|
@@ -32,12 +36,12 @@ export function discoverBlocks(contentDirectory) {
|
|
|
32
36
|
function invalidReference(expression, line) {
|
|
33
37
|
return new AgentsInstructionsError(
|
|
34
38
|
`invalid fragment reference {{ ${expression} }} at line ${line} of the agents base template; ` +
|
|
35
|
-
'use {{
|
|
39
|
+
'use {{ include "name.block.md" }} or {{ blocks }}'
|
|
36
40
|
);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
export function composeAgentsInstructions(contentDirectory) {
|
|
40
|
-
const basePath = path.join(contentDirectory,
|
|
43
|
+
export function composeAgentsInstructions(contentDirectory, baseFileName = 'AGENTS.md') {
|
|
44
|
+
const basePath = path.join(contentDirectory, baseFileName);
|
|
41
45
|
if (!fs.existsSync(basePath)) {
|
|
42
46
|
throw new AgentsInstructionsError(`missing agents base template: ${basePath}`);
|
|
43
47
|
}
|
|
@@ -54,7 +58,7 @@ export function composeAgentsInstructions(contentDirectory) {
|
|
|
54
58
|
references.push({ kind: 'aggregate', start: match.index, end: match.index + match[0].length });
|
|
55
59
|
continue;
|
|
56
60
|
}
|
|
57
|
-
const named = expression.match(namedReferencePattern);
|
|
61
|
+
const named = expression.match(includePattern) ?? expression.match(namedReferencePattern);
|
|
58
62
|
if (!named || named[2].includes('/') || named[2].includes('\\')) {
|
|
59
63
|
throw invalidReference(expression, line);
|
|
60
64
|
}
|
|
@@ -91,6 +95,39 @@ export function composeAgentsInstructions(contentDirectory) {
|
|
|
91
95
|
return { content };
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
export function applyProjectInstructions(existing, composed, version) {
|
|
99
|
+
const composedText = composed.trimEnd();
|
|
100
|
+
const region = `${projectRegionBeginPrefix}${version} -->\n${composedText}\n${projectRegionEndLine}`;
|
|
101
|
+
const lines = existing.split('\n');
|
|
102
|
+
const begins = [];
|
|
103
|
+
const ends = [];
|
|
104
|
+
lines.forEach((line, index) => {
|
|
105
|
+
if (projectRegionBeginPattern.test(line)) begins.push(index);
|
|
106
|
+
else if (line === projectRegionEndLine) ends.push(index);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (begins.length !== ends.length || begins.length > 1 || (begins.length && begins[0] >= ends[0])) {
|
|
110
|
+
throw new AgentsInstructionsError('project agents markers are malformed');
|
|
111
|
+
}
|
|
112
|
+
if (!begins.length) return { action: 'installed', text: appendRegion(existing, region) };
|
|
113
|
+
|
|
114
|
+
const [start] = begins;
|
|
115
|
+
const [end] = ends;
|
|
116
|
+
const installedVersion = lines[start].match(/<!-- skillshop project agents: begin v(\S+) -->/)[1];
|
|
117
|
+
const inner = lines.slice(start + 1, end).join('\n');
|
|
118
|
+
if (installedVersion === version && inner === composedText && lines[end] === projectRegionEndLine) {
|
|
119
|
+
return { action: 'unchanged', text: existing };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
action: 'refreshed',
|
|
123
|
+
text: [...lines.slice(0, start), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function hasProjectInstructions(existing) {
|
|
128
|
+
return existing.split('\n').some((line) => projectRegionBeginPattern.test(line) || line === projectRegionEndLine);
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
function regionText(version, composed) {
|
|
95
132
|
return `${regionBeginPrefix}${version} -->\n${composed}\n${regionEndLine}`;
|
|
96
133
|
}
|
package/src/cli.js
CHANGED
|
@@ -11,22 +11,18 @@ import { parseLibrarySkillReference, skillName } from './specifier.js';
|
|
|
11
11
|
|
|
12
12
|
function help() {
|
|
13
13
|
console.log(`
|
|
14
|
-
|
|
14
|
+
Personal folder library commands:
|
|
15
15
|
|
|
16
|
-
skillshop library init
|
|
16
|
+
skillshop library init [directory]
|
|
17
17
|
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>]
|
|
18
|
+
skillshop download @<owner>/<skill> [--force]
|
|
19
|
+
skillshop install @<owner>/<skill> [--global]
|
|
20
|
+
skillshop install <name> [--global]
|
|
21
|
+
skillshop sync
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
Create or select a source folder with skillshop library init. Download @owner/skill adds a GitHub skill to that folder without installing it.
|
|
24
|
+
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.
|
|
25
|
+
No npm package manifest or author metadata is required. Existing npm package export/import APIs remain available for compatibility.`);
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
function flag(args, name) {
|
|
@@ -47,7 +43,7 @@ const libraryCommands = new Set(['export', 'unexport', 'validate', 'build', 'res
|
|
|
47
43
|
async function main(argv, { libraryNamespace = false } = {}) {
|
|
48
44
|
const [command, ...args] = argv;
|
|
49
45
|
if (!libraryNamespace && command === 'library') {
|
|
50
|
-
if (args[0] === 'init' || args[0] === 'list') return
|
|
46
|
+
if (args[0] === 'init' || args[0] === 'list') return runLegacyCli(argv);
|
|
51
47
|
if (!args.length || args[0] === '--help' || args[0] === '-h') return help();
|
|
52
48
|
throw new Error('Usage: skillshop library init | list');
|
|
53
49
|
}
|
|
@@ -146,13 +142,14 @@ async function main(argv, { libraryNamespace = false } = {}) {
|
|
|
146
142
|
|
|
147
143
|
const argv = process.argv.slice(2);
|
|
148
144
|
const usingLibraryCommands = argv[0] === 'library' || libraryCommands.has(argv[0]);
|
|
145
|
+
const personalLibraryCommand = argv[0] === 'library' && ['init', 'list'].includes(argv[1]);
|
|
149
146
|
|
|
150
147
|
try {
|
|
151
|
-
if (usingLibraryCommands) reconcileLegacyState();
|
|
148
|
+
if (usingLibraryCommands && !personalLibraryCommand) reconcileLegacyState();
|
|
152
149
|
try {
|
|
153
150
|
await main(argv);
|
|
154
151
|
} finally {
|
|
155
|
-
if (usingLibraryCommands) reconcileLegacyState();
|
|
152
|
+
if (usingLibraryCommands && !personalLibraryCommand) reconcileLegacyState();
|
|
156
153
|
}
|
|
157
154
|
} catch (error) {
|
|
158
155
|
console.error(`skillshop: ${error.message}`);
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
commitDownloadedPersonalSkill,
|
|
7
|
+
initPersonalLibrary,
|
|
8
|
+
inspectPersonalLibrary,
|
|
9
|
+
libraryConfigFileName,
|
|
10
|
+
libraryOriginsFileName,
|
|
11
|
+
librarySelectionFileName,
|
|
12
|
+
readPersonalLibraryOrigins,
|
|
13
|
+
selectedLibraryRoot,
|
|
14
|
+
validatePersonalSkillDirectory
|
|
15
|
+
} from './personal-library.js';
|
|
16
|
+
|
|
17
|
+
const githubOwnerPattern = /^[A-Za-z0-9-]+$/;
|
|
18
|
+
const skillNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
19
|
+
const reservedRootNames = new Set([libraryConfigFileName, 'README.md', libraryOriginsFileName]);
|
|
20
|
+
|
|
21
|
+
export class GitHubLibraryError extends Error {}
|
|
22
|
+
|
|
23
|
+
function fail(message) {
|
|
24
|
+
return new GitHubLibraryError(message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseGitHubSkillReference(reference) {
|
|
28
|
+
if (typeof reference !== 'string') throw fail('expected GitHub skill reference @<owner>/<skill>');
|
|
29
|
+
if (!reference.startsWith('@')) {
|
|
30
|
+
throw fail(`invalid GitHub skill reference ${reference}; prefix remote references with @ as @<owner>/<skill>`);
|
|
31
|
+
}
|
|
32
|
+
const parts = reference.slice(1).split('/');
|
|
33
|
+
if (parts.length !== 2 || !githubOwnerPattern.test(parts[0]) || !skillNamePattern.test(parts[1])) {
|
|
34
|
+
throw fail(`invalid GitHub skill reference ${reference}; expected @<owner>/<skill>`);
|
|
35
|
+
}
|
|
36
|
+
return { owner: parts[0], name: parts[1], repository: `https://github.com/${parts[0]}/skillshop-library.git` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gitEnvironment() {
|
|
40
|
+
return {
|
|
41
|
+
...process.env,
|
|
42
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
43
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
44
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
45
|
+
GIT_ALLOW_PROTOCOL: 'https:file'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function git(args, { cwd, encoding = 'utf8', maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
50
|
+
const result = spawnSync('git', args, { cwd, encoding, env: gitEnvironment(), maxBuffer });
|
|
51
|
+
if (result.error) throw fail(`could not run Git: ${result.error.message}`);
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
const message = encoding === null ? result.stderr?.toString('utf8') : result.stderr;
|
|
54
|
+
throw fail(`Git ${args[0]} failed${message?.trim() ? `: ${message.trim()}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
return result.stdout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseTreeEntries(buffer) {
|
|
60
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const chunk of buffer.toString('binary').split('\0').filter(Boolean)) {
|
|
63
|
+
const separator = chunk.indexOf('\t');
|
|
64
|
+
if (separator < 0) throw fail('Git returned a malformed tree entry');
|
|
65
|
+
const [mode, type, object] = chunk.slice(0, separator).split(' ');
|
|
66
|
+
const rawPath = Buffer.from(chunk.slice(separator + 1), 'binary');
|
|
67
|
+
let entryPath;
|
|
68
|
+
try {
|
|
69
|
+
entryPath = decoder.decode(rawPath);
|
|
70
|
+
} catch {
|
|
71
|
+
throw fail('repository contains a non-UTF-8 path');
|
|
72
|
+
}
|
|
73
|
+
entries.push({ mode, type, object, path: entryPath });
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function literalPath(value) {
|
|
79
|
+
return `:(literal)${value}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function treeEntry(repositoryDir, commit, filePath) {
|
|
83
|
+
const result = git(['-C', repositoryDir, 'ls-tree', '-z', commit, '--', literalPath(filePath)], { encoding: null });
|
|
84
|
+
const entries = parseTreeEntries(result);
|
|
85
|
+
return entries.find((entry) => entry.path === filePath) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeSkillsDirectory(value) {
|
|
89
|
+
if (typeof value !== 'string' || !value || value.includes('\\') || value.startsWith('/')) {
|
|
90
|
+
throw fail('skillshop-library.json skillsDirectory must be a relative repository path');
|
|
91
|
+
}
|
|
92
|
+
const normalized = path.posix.normalize(value);
|
|
93
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.startsWith('/')) {
|
|
94
|
+
throw fail('skillshop-library.json skillsDirectory contains an unsafe path');
|
|
95
|
+
}
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readRemoteConfig(repositoryDir, commit) {
|
|
100
|
+
const entry = treeEntry(repositoryDir, commit, libraryConfigFileName);
|
|
101
|
+
if (!entry || entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) {
|
|
102
|
+
throw fail(`repository is missing a regular ${libraryConfigFileName}`);
|
|
103
|
+
}
|
|
104
|
+
let config;
|
|
105
|
+
try {
|
|
106
|
+
config = JSON.parse(git(['-C', repositoryDir, 'show', `${commit}:${libraryConfigFileName}`], { encoding: null }).toString('utf8'));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof GitHubLibraryError) throw error;
|
|
109
|
+
throw fail(`cannot parse ${libraryConfigFileName}: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
if (config?.schemaVersion !== 1) throw fail(`${libraryConfigFileName} has an unsupported schemaVersion`);
|
|
112
|
+
const skillsDirectory = normalizeSkillsDirectory(config.skillsDirectory);
|
|
113
|
+
return { skillsDirectory };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function safeRelativeFilePath(skillPath, repoPath) {
|
|
117
|
+
if (!repoPath.startsWith(`${skillPath}/`)) throw fail(`repository path escapes requested skill: ${repoPath}`);
|
|
118
|
+
const relative = repoPath.slice(skillPath.length + 1);
|
|
119
|
+
const parts = relative.split('/');
|
|
120
|
+
if (!relative || relative.includes('\\') || parts.some((part) => !part || part === '.' || part === '..')) {
|
|
121
|
+
throw fail(`repository contains an unsafe skill path: ${repoPath}`);
|
|
122
|
+
}
|
|
123
|
+
return relative;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stageSkill(repositoryDir, commit, skillPath, name, stagingRoot) {
|
|
127
|
+
const rootEntry = treeEntry(repositoryDir, commit, skillPath);
|
|
128
|
+
if (!rootEntry) throw fail(`repository has no skill named ${name}`);
|
|
129
|
+
if (rootEntry.mode !== '040000' || rootEntry.type !== 'tree') {
|
|
130
|
+
throw fail(`repository path for ${name} is not a skill directory`);
|
|
131
|
+
}
|
|
132
|
+
const entries = parseTreeEntries(git([
|
|
133
|
+
'-C', repositoryDir, 'ls-tree', '-r', '-z', '--full-tree', commit, '--', literalPath(skillPath)
|
|
134
|
+
], { encoding: null }));
|
|
135
|
+
if (!entries.length) throw fail(`repository skill ${name} is empty`);
|
|
136
|
+
|
|
137
|
+
const skillDirectory = path.join(stagingRoot, 'skill');
|
|
138
|
+
fs.mkdirSync(skillDirectory, { recursive: true, mode: 0o755 });
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const relative = safeRelativeFilePath(skillPath, entry.path);
|
|
142
|
+
if (seen.has(relative)) throw fail(`repository contains a duplicate skill path: ${relative}`);
|
|
143
|
+
seen.add(relative);
|
|
144
|
+
if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) {
|
|
145
|
+
throw fail(`repository skill contains a symlink, submodule, or non-regular entry: ${entry.path}`);
|
|
146
|
+
}
|
|
147
|
+
const target = path.join(skillDirectory, ...relative.split('/'));
|
|
148
|
+
const contained = path.relative(skillDirectory, target);
|
|
149
|
+
if (contained === '..' || contained.startsWith(`..${path.sep}`) || path.isAbsolute(contained)) {
|
|
150
|
+
throw fail(`repository path escapes requested skill: ${entry.path}`);
|
|
151
|
+
}
|
|
152
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 });
|
|
153
|
+
const contents = git(['-C', repositoryDir, 'cat-file', 'blob', entry.object], { encoding: null });
|
|
154
|
+
fs.writeFileSync(target, contents, { flag: 'wx', mode: entry.mode === '100755' ? 0o755 : 0o644 });
|
|
155
|
+
fs.chmodSync(target, entry.mode === '100755' ? 0o755 : 0o644);
|
|
156
|
+
}
|
|
157
|
+
validatePersonalSkillDirectory(skillDirectory, skillDirectory, name);
|
|
158
|
+
return skillDirectory;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fetchSkill(reference, temporaryRoot) {
|
|
162
|
+
const bareRepository = path.join(temporaryRoot, 'repository.git');
|
|
163
|
+
fs.mkdirSync(bareRepository, { recursive: true });
|
|
164
|
+
git(['init', '--bare', '--quiet', bareRepository]);
|
|
165
|
+
const symbolicRefs = git(['ls-remote', '--symref', reference.repository, 'HEAD']);
|
|
166
|
+
const headReference = symbolicRefs.split(/\r?\n/).find((line) => line.startsWith('ref: ') && line.endsWith('\tHEAD'));
|
|
167
|
+
const headObject = symbolicRefs.split(/\r?\n/).find((line) => /^[a-f0-9]{40,64}\tHEAD$/.test(line));
|
|
168
|
+
if (!headReference || !headObject) throw fail(`could not discover the default branch for ${reference.repository}`);
|
|
169
|
+
const branchRef = headReference.slice('ref: '.length, -'\tHEAD'.length);
|
|
170
|
+
if (!branchRef.startsWith('refs/heads/')) throw fail('GitHub repository default is not a branch');
|
|
171
|
+
git(['-C', bareRepository, 'check-ref-format', branchRef]);
|
|
172
|
+
git(['-C', bareRepository, 'fetch', '--no-tags', '--no-recurse-submodules', '--depth=1', reference.repository, branchRef]);
|
|
173
|
+
const commit = git(['-C', bareRepository, 'rev-parse', '--verify', 'FETCH_HEAD^{commit}']).trim();
|
|
174
|
+
if (!/^[a-f0-9]{40,64}$/.test(commit)) throw fail('Git returned an invalid commit identifier');
|
|
175
|
+
const config = readRemoteConfig(bareRepository, commit);
|
|
176
|
+
const skillPath = config.skillsDirectory === '.' ? reference.name : `${config.skillsDirectory}/${reference.name}`;
|
|
177
|
+
if (config.skillsDirectory === '.' && reservedRootNames.has(reference.name)) {
|
|
178
|
+
throw fail(`skill name collides with repository metadata: ${reference.name}`);
|
|
179
|
+
}
|
|
180
|
+
const skillDirectory = stageSkill(bareRepository, commit, skillPath, reference.name, temporaryRoot);
|
|
181
|
+
return { skillDirectory, origin: { repository: reference.repository, commit } };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function snapshotFile(filePath) {
|
|
185
|
+
try {
|
|
186
|
+
const stat = fs.lstatSync(filePath);
|
|
187
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw fail(`${filePath} must be a regular file`);
|
|
188
|
+
return { contents: fs.readFileSync(filePath), mode: stat.mode & 0o777 };
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error.code === 'ENOENT') return null;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function restoreFile(filePath, snapshot) {
|
|
196
|
+
if (!snapshot) {
|
|
197
|
+
fs.rmSync(filePath, { force: true });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
fs.writeFileSync(filePath, snapshot.contents, { mode: snapshot.mode });
|
|
201
|
+
fs.chmodSync(filePath, snapshot.mode);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function captureInitialization(root, stateHome) {
|
|
205
|
+
let rootExisted = true;
|
|
206
|
+
let rootEmpty = false;
|
|
207
|
+
try {
|
|
208
|
+
const stat = fs.lstatSync(root);
|
|
209
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail(`library root is not a regular directory: ${root}`);
|
|
210
|
+
rootEmpty = fs.readdirSync(root).length === 0;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code !== 'ENOENT') throw error;
|
|
213
|
+
rootExisted = false;
|
|
214
|
+
}
|
|
215
|
+
const skillsBefore = path.join(root, 'skills');
|
|
216
|
+
let skillsExisted = false;
|
|
217
|
+
try {
|
|
218
|
+
fs.lstatSync(skillsBefore);
|
|
219
|
+
skillsExisted = true;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code !== 'ENOENT') throw error;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
rootExisted,
|
|
225
|
+
rootEmpty,
|
|
226
|
+
skillsExisted,
|
|
227
|
+
stateHomeExisted: fs.existsSync(stateHome),
|
|
228
|
+
config: snapshotFile(path.join(root, libraryConfigFileName)),
|
|
229
|
+
readme: snapshotFile(path.join(root, 'README.md')),
|
|
230
|
+
selection: snapshotFile(path.join(stateHome, librarySelectionFileName))
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function rollbackInitialization(root, stateHome, snapshot) {
|
|
235
|
+
if (!snapshot.rootExisted) {
|
|
236
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
237
|
+
} else if (snapshot.rootEmpty) {
|
|
238
|
+
for (const entry of fs.readdirSync(root)) fs.rmSync(path.join(root, entry), { recursive: true, force: true });
|
|
239
|
+
} else {
|
|
240
|
+
restoreFile(path.join(root, libraryConfigFileName), snapshot.config);
|
|
241
|
+
restoreFile(path.join(root, 'README.md'), snapshot.readme);
|
|
242
|
+
if (!snapshot.skillsExisted) fs.rmSync(path.join(root, 'skills'), { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
restoreFile(path.join(stateHome, librarySelectionFileName), snapshot.selection);
|
|
245
|
+
if (!snapshot.stateHomeExisted && fs.existsSync(stateHome) && fs.readdirSync(stateHome).length === 0) {
|
|
246
|
+
fs.rmdirSync(stateHome);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function targetInfo(layout, name) {
|
|
251
|
+
if (path.resolve(layout.skillsDirectory) === path.resolve(layout.root) && reservedRootNames.has(name)) {
|
|
252
|
+
throw fail(`skill name collides with library metadata: ${name}`);
|
|
253
|
+
}
|
|
254
|
+
const target = path.join(layout.skillsDirectory, name);
|
|
255
|
+
try {
|
|
256
|
+
const stat = fs.lstatSync(target);
|
|
257
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail(`personal library target is not a regular skill directory: ${target}`);
|
|
258
|
+
validatePersonalSkillDirectory(layout.root, target, name);
|
|
259
|
+
return { target, exists: true };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error.code === 'ENOENT') return { target, exists: false };
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function downloadPersonalSkill(referenceValue, { stateHome, templateRoot, force = false } = {}) {
|
|
267
|
+
const reference = parseGitHubSkillReference(referenceValue);
|
|
268
|
+
const selected = selectedLibraryRoot(stateHome);
|
|
269
|
+
const root = selected ?? path.resolve(os.homedir(), 'skillshop-library');
|
|
270
|
+
const layout = inspectPersonalLibrary(root);
|
|
271
|
+
const origins = layout.exists ? readPersonalLibraryOrigins(root) : { schemaVersion: 1, skills: {} };
|
|
272
|
+
const existingOrigin = origins.skills[reference.name];
|
|
273
|
+
const target = targetInfo(layout, reference.name);
|
|
274
|
+
if (target.exists && !force) {
|
|
275
|
+
if (existingOrigin?.repository === reference.repository) {
|
|
276
|
+
if (!selected) initPersonalLibrary(root, stateHome, templateRoot);
|
|
277
|
+
return { root, name: reference.name, origin: existingOrigin, alreadyPresent: true };
|
|
278
|
+
}
|
|
279
|
+
if (existingOrigin) throw fail(`${reference.name} already has a different GitHub source; use --force to replace it`);
|
|
280
|
+
throw fail(`${reference.name} already exists as a personal skill; use --force to replace it`);
|
|
281
|
+
}
|
|
282
|
+
if (existingOrigin && existingOrigin.repository !== reference.repository && !force) {
|
|
283
|
+
throw fail(`${reference.name} already has a different GitHub source; use --force to replace it`);
|
|
284
|
+
}
|
|
285
|
+
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skillshop-github-download-'));
|
|
286
|
+
try {
|
|
287
|
+
const downloaded = fetchSkill(reference, temporaryRoot);
|
|
288
|
+
let initialized = false;
|
|
289
|
+
let initializationSnapshot;
|
|
290
|
+
let skillsDirectory = layout.skillsDirectory;
|
|
291
|
+
if (!selected) initializationSnapshot = captureInitialization(root, stateHome);
|
|
292
|
+
try {
|
|
293
|
+
if (!selected) {
|
|
294
|
+
initialized = true;
|
|
295
|
+
const result = initPersonalLibrary(root, stateHome, templateRoot, { includeExample: false });
|
|
296
|
+
skillsDirectory = result.skillsDirectory;
|
|
297
|
+
if (path.resolve(skillsDirectory) !== path.resolve(layout.skillsDirectory)) {
|
|
298
|
+
throw fail('default library layout changed during download');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const committed = commitDownloadedPersonalSkill(root, skillsDirectory, reference.name, downloaded.skillDirectory, downloaded.origin, { force });
|
|
302
|
+
return { root, name: reference.name, origin: downloaded.origin, target: committed.target, alreadyPresent: false };
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (initialized) rollbackInitialization(root, stateHome, initializationSnapshot);
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
}
|