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.
@@ -1,18 +1,41 @@
1
1
  ---
2
2
  name: skillshop
3
- description: Manage repository-local agent skills from the installed skillshop registry. Use when a repository has a .skillshop-lock or the user asks to install, uninstall, pin, list, or update local skills with skillshop.
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 — 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; `.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
- Every invocation, including help, version, and failed commands, reconciles the current project's lockfile, registration, and Git exclusions. It 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.
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 that 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.
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
- ## Install a skill
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
- 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`.
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` so every command can repair their frontmatter and prune missing paths. 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.
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 {{ fragments["name.block.md"] }} or {{ blocks }}'
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, 'AGENTS.md');
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
- Library commands:
14
+ Personal folder library commands:
15
15
 
16
- skillshop library init
16
+ skillshop library init [directory]
17
17
  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>]
18
+ skillshop download @<owner>/<skill> [--force]
19
+ skillshop install @<owner>/<skill> [--global]
20
+ skillshop install <name> [--global]
21
+ skillshop sync
26
22
 
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.`);
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 main(args, { libraryNamespace: true });
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
+ }