skillshop 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ npm install --global skillshop
13
13
  skillshop init
14
14
  ```
15
15
 
16
- `init` asks which agents you use and saves their destinations in `~/.skillshop/config.json`. Change the selection later with `skillshop config agents`. Codex and OpenCode use `.agents/skills` inside projects and `~/.agents/skills` for user-level installs. Copilot and Claude use their own skill directories. Shared destinations get one copy.
16
+ `skillshop init` asks which agents you use and saves their destinations in `~/.skillshop/config.json`. It 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`. Run `skillshop init` to choose system-wide defaults; change that selection later with `skillshop config agents`. Codex and OpenCode use `.agents/skills` inside projects and `~/.agents/skills` for user-level installs. Copilot and Claude use their own skill directories. Shared destinations get one copy.
17
17
 
18
18
  ### Install a named skill
19
19
 
@@ -34,7 +34,7 @@ skillshop one-shot --global
34
34
  # Equivalent: sk install one-shot --global
35
35
  ```
36
36
 
37
- For Codex or OpenCode, this writes `~/.agents/skills/one-shot`. A global install replaces an existing skill directory with that name, including local edits and extra files. Global installs use `~/.skillshop/global-skills.json` for bookkeeping. Each CLI run reconciles recorded installations; it does not install the entire bundled registry.
37
+ For Codex or OpenCode, this writes `~/.agents/skills/one-shot`. A global install replaces an existing skill directory with that name, including local edits and extra files. Global installs use `~/.skillshop/global-skills.json` for bookkeeping. Except for `skillshop upgrade`, ordinary non-agent-instruction CLI runs reconcile recorded installations. Upgrade skips reconciliation in the old process and refreshes changed copies from the newly installed package in a fresh process. Agent instruction commands also bypass reconciliation and do not install the bundled registry.
38
38
 
39
39
  ### Update or remove skills
40
40
 
@@ -48,16 +48,74 @@ skillshop uninstall one-shot --global
48
48
 
49
49
  `update` replaces skills managed in the current project's lockfile. `update --push` updates registered projects. Neither command updates global copies; reinstall a global skill by name with `--global`. Project uninstall removes only a skill recorded in `.skillshop-lock`. Global uninstall removes the named skill from configured user directories.
50
50
 
51
+ ### Upgrade Skillshop
52
+
53
+ ```bash
54
+ skillshop upgrade
55
+ ```
56
+
57
+ The command upgrades the npm installation that supplied the invoked CLI. A project-local installation updates Skillshop in the owning npm project and preserves the dependency section where it is already declared. It refreshes only that project's recorded Skillshop-managed skill copies. A global installation updates its npm prefix, then refreshes recorded global copies and copies in registered projects; it does not update those projects' package manifests. Running from a source checkout keeps the historical global-install behavior and does not edit the checkout's package manifest.
58
+
59
+ After npm succeeds, the newly installed CLI runs in a fresh process and refreshes only existing copies recorded in `.skillshop-lock` or `~/.skillshop/global-skills.json` whose bundled skill version changed. Same-version copies retain their local bodies. A changed-version replacement overwrites local edits in that managed copy; make durable edits in the bundled source. Deleted paths are pruned from ownership records instead of recreated. Unmanaged directories and package-import snapshots are not adopted. An npm install failure stops before copy refresh; a later refresh failure reports that Skillshop was upgraded and gives a nonzero result with the partial-refresh summary.
60
+
61
+ `skillshop init` is optional. It configures agent destinations and is not required to upgrade Skillshop. The upgrade command never initializes, renders, or rewrites project `AGENTS.md` files or instruction templates.
62
+
51
63
  An exact version such as `skillshop one-shot@2026.8.21` checks the currently bundled version. It does not fetch a historical release. Use `skillshop migrate` to discover older lockfiles and register their repositories.
52
64
 
53
65
  ### Manage agent instructions
54
66
 
55
67
  ```bash
56
68
  skillshop agents
69
+ skillshop agents init
70
+ skillshop agents --check
57
71
  skillshop agents --global
58
72
  ```
59
73
 
60
- The local command updates a managed block in the nearest existing `AGENTS.md`. The global command updates the supported user instruction files. Sibling `*.block.md` fragments are composed in filename order. Text outside Skillshop's managed blocks is preserved.
74
+ There are two local modes. Plain `skillshop agents` keeps the bounded legacy behavior for an uninitialized project: it updates the nearest existing `AGENTS.md` within the selected boundary, or creates one at the boundary root if none exists. After project sources are initialized, plain `skillshop agents` renders them instead. The global command remains separate and updates the supported user instruction files.
75
+
76
+ Initialize project-owned instructions with `skillshop agents init`. The neutral starter template is `.agents/instructions/AGENTS.template.md`; put source blocks beside it as `*.block.md`. Skillshop renders these sources into a distinct managed region in the project-root `AGENTS.md`. Existing user text and prior Skillshop managed guidance are preserved. Edit the template and source blocks, not the generated region. Repeated `skillshop agents init` leaves existing sources alone. If an initialized project's template is removed, `skillshop agents` reports an error rather than restoring bundled personal guidance. Symlinked source, template, block, or output path components are rejected, including dangling links.
77
+
78
+ The default boundary is the nearest Git repository or worktree root. Outside Git, the current working directory is the boundary. Use `--project <directory>` to choose another boundary; a relative path is resolved from the command's current directory. This is required when a nested project inside a larger repository should own its own output. Put all three explicit-boundary scripts in `packages/app/package.json` so init, rendering, and checking stay in that package:
79
+
80
+ ```json
81
+ {
82
+ "scripts": {
83
+ "agents": "skillshop agents --project .",
84
+ "agents:init": "skillshop agents init --project .",
85
+ "agents:check": "skillshop agents --check --project ."
86
+ }
87
+ }
88
+ ```
89
+
90
+ For a local project dependency, add Skillshop as a development dependency and opt into rendering through explicit scripts:
91
+
92
+ ```bash
93
+ npm install --save-dev skillshop
94
+ ```
95
+
96
+ ```json
97
+ {
98
+ "scripts": {
99
+ "agents": "skillshop agents",
100
+ "agents:init": "skillshop agents init",
101
+ "agents:check": "skillshop agents --check"
102
+ }
103
+ }
104
+ ```
105
+
106
+ Run `npm run agents:init` once, edit the project sources, then run `npm run agents`. `npm run agents:check` exits successfully when the generated region in the working tree matches the current sources; it does not inspect Git's index or commit. CI can run `npm ci` followed by `npm run agents:check`. Commit the npm manifest and lockfile, `.agents/instructions/AGENTS.template.md`, its source blocks, and the rendered root `AGENTS.md`. There is no install hook: initialization and rendering happen only when the scripts are run.
107
+
108
+ Project and bundled source templates support explicit includes:
109
+
110
+ ```markdown
111
+ {{ include "020_testing.block.md" }}
112
+
113
+ {{ blocks }}
114
+ ```
115
+
116
+ Use `{{ include "020_testing.block.md" }}` or `{{ include '020_testing.block.md' }}` for named blocks. The older `{{ fragments["020_testing.block.md"] }}` and single-quoted lookup forms remain supported. `{{ blocks }}` inserts the remaining fragments in filename order, excluding every explicitly included file even when its include appears later in the template. Repeated includes render at each position. Fragment contents are inserted literally; placeholder-looking text inside a block is not evaluated recursively. Invalid expressions and missing fragments fail before generated output is written.
117
+
118
+ These expressions apply to source templates only. Skillshop does not interpret them in destination instruction files or sibling fragments.
61
119
 
62
120
  ### Use skills from another package
63
121
 
@@ -235,7 +293,9 @@ skillshop install <name> [--global]
235
293
  skillshop update [name]
236
294
  skillshop update --push
237
295
  skillshop uninstall <name> [--global]
238
- skillshop agents [--global]
296
+ skillshop agents init [--project <directory>]
297
+ skillshop agents [--project <directory>] [--check]
298
+ skillshop agents --global
239
299
  skillshop migrate [root ...]
240
300
  skillshop library init
241
301
  skillshop library list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshop",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Build, export, resolve, and install portable agent skill libraries",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,9 @@
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 repository-local agent skills, Skillshop upgrades, and project agent instructions. Use when a repository has a .skillshop-lock or the user asks to install, uninstall, pin, list, update, or upgrade Skillshop-managed copies, or initialize, render, or check project AGENTS.md instructions.
4
4
  metadata:
5
5
  author: Daniel Fosco
6
- version: "2026.10.1"
6
+ version: "2026.10.23"
7
7
  ---
8
8
 
9
9
  # Skillshop — Local Skill Registry
@@ -12,7 +12,9 @@ Use the `skillshop` CLI to manage skills in this repository. The project-root `.
12
12
 
13
13
  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
14
 
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.
15
+ Every non-`agents` invocation, including help, version, and failed commands, reconciles the current project's lockfile, registration, and Git exclusions, except `skillshop upgrade`. Agent instruction commands also bypass reconciliation. Upgrade skips the old process's pre/post reconciliation, installs the npm package first, then starts the installed CLI in a fresh process to refresh only changed-version copies recorded in Skillshop's project/global ownership files. A failed npm install must not touch managed copies; report any partial copy refresh and return failure. Never adopt unmanaged directories or package-import snapshots, and do not recreate deleted paths. Reconciliation removes missing installation paths and stale repository registrations. Tracked installed frontmatter is repaired without replacing bodies or extra fields: names match directory names, author/version use the installation record, and missing descriptions use the bundled skill. Incomplete records fall back to installed or bundled metadata. Valid files are not rewritten. Invalid lockfiles, unsafe paths, and unsupported frontmatter must be reported, not discarded. Do not silently untrack Git files; that remains an explicit migration operation.
16
+
17
+ `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
18
 
17
19
  ## Configure agents
18
20
 
@@ -21,7 +23,48 @@ skillshop init
21
23
  skillshop config agents
22
24
  ```
23
25
 
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.
26
+ 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.
27
+
28
+ ## Manage project agent instructions
29
+
30
+ ```bash
31
+ skillshop agents init
32
+ skillshop agents
33
+ skillshop agents --check
34
+ skillshop agents --global
35
+ ```
36
+
37
+ `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.
38
+
39
+ 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:
40
+
41
+ ```json
42
+ {
43
+ "scripts": {
44
+ "agents": "skillshop agents --project .",
45
+ "agents:init": "skillshop agents init --project .",
46
+ "agents:check": "skillshop agents --check --project ."
47
+ }
48
+ }
49
+ ```
50
+
51
+ `--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.
52
+
53
+ 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.
54
+
55
+ For npm consumers, declare explicit scripts rather than relying on an install hook:
56
+
57
+ ```json
58
+ {
59
+ "scripts": {
60
+ "agents": "skillshop agents",
61
+ "agents:init": "skillshop agents init",
62
+ "agents:check": "skillshop agents --check"
63
+ }
64
+ }
65
+ ```
66
+
67
+ 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.
25
68
 
26
69
  ## Install a skill
27
70
 
@@ -45,7 +88,7 @@ skillshop install <name> --version <version>
45
88
  skillshop <name> --global
46
89
  ```
47
90
 
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.
91
+ `--global` (or `-g`) installs into every configured system skill path without a project lockfile. Installs are recorded in `~/.skillshop/global-skills.json`. Except for `skillshop upgrade`, ordinary commands other than `skillshop agents` can repair their frontmatter and prune missing paths. Agent-instruction commands bypass that reconciliation. `skillshop upgrade` instead refreshes recorded global and registered project copies only after npm succeeds and a fresh CLI starts. Same-version copies keep local body edits; a changed-version refresh replaces the managed copy and overwrites local edits. Older installs are recorded on explicit reinstall; do not adopt other global skills. Global copies are not migrated when the agent setting changes, so remove unwanted global skills before reconfiguration. The local registry contains one current version per skill. `--version` (or `-v`) is an exact compatibility check; do not claim that an unavailable historical version can be resolved.
49
92
 
50
93
  ## Update managed skills
51
94
 
@@ -60,6 +103,16 @@ skillshop up --push
60
103
 
61
104
  `update` with no name replaces every skill listed in the current repository's `.skillshop-lock` and updates its locked metadata. A name updates only that managed skill. `--push` updates every centrally registered repository and cannot be combined with a skill name. Updates and installs replace local edits; keep edits in the registry if they should survive these operations. Global installs cannot be updated with this command; reinstall them by name with `--global`.
62
105
 
106
+ ## Upgrade Skillshop
107
+
108
+ ```bash
109
+ skillshop upgrade
110
+ ```
111
+
112
+ 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
+
114
+ After npm succeeds, the new package's CLI starts in a fresh process. It replaces existing recorded copies only when the bundled skill version changed, preserving local bodies for same-version copies. Changed-version replacement overwrites local edits in that managed copy; make durable changes in the bundled skill source. Missing paths are pruned from records rather than recreated. Unmanaged skills and imported snapshots remain untouched. Install failure prevents refresh; refresh failure reports the npm stage as successful and returns nonzero with a partial-result summary. Upgrade does not run agent initialization or project instruction generation.
115
+
63
116
  ## Migrate existing repositories
64
117
 
65
118
  ```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/legacy-cli.js CHANGED
@@ -3,10 +3,17 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { execFileSync } from 'node:child_process';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
7
  import { createInterface } from 'node:readline/promises';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { AgentsInstructionsError, applyInstructions, applyLocalFragments, composeAgentsInstructions } from './agents-instructions.js';
9
+ import {
10
+ AgentsInstructionsError,
11
+ applyInstructions,
12
+ applyLocalFragments,
13
+ applyProjectInstructions,
14
+ composeAgentsInstructions,
15
+ hasProjectInstructions
16
+ } from './agents-instructions.js';
10
17
 
11
18
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
19
  const skillsContentRoot = path.join(packageRoot, 'skills-content');
@@ -21,6 +28,11 @@ const excludeBlockStart = '# >>> skillshop managed dependencies >>>';
21
28
  const excludeBlockEnd = '# <<< skillshop managed dependencies <<<';
22
29
  const legacyExcludeBlockStart = '# >>> skillz managed dependencies >>>';
23
30
  const legacyExcludeBlockEnd = '# <<< skillz managed dependencies <<<';
31
+ const projectTemplateDirectory = path.join('.agents', 'instructions');
32
+ const projectTemplateName = 'AGENTS.template.md';
33
+ const defaultProjectTemplate = '# Project instructions\n\n{{ blocks }}\n';
34
+ const internalUpgradeRefreshCommand = '__skillshop_upgrade_refresh';
35
+ const internalUpgradeRefreshToken = 'skillshop-managed-upgrade-v1';
24
36
  const globalAgentsTargets = [
25
37
  path.join('.codex', 'AGENTS.md'),
26
38
  path.join('.agents', 'AGENTS.md'),
@@ -107,7 +119,9 @@ function printHelp() {
107
119
  console.log(`Usage:
108
120
  skillshop init
109
121
  skillshop config agents
110
- skillshop agents [--global]
122
+ skillshop agents init [--project <directory>]
123
+ skillshop agents [--project <directory>] [--check]
124
+ skillshop agents --global
111
125
  skillshop <name> [--version <version>] [--global]
112
126
  skillshop install <name> [--version <version>] [--global]
113
127
  skillshop uninstall <name> [--global]
@@ -119,8 +133,21 @@ function printHelp() {
119
133
 
120
134
  Install, uninstall, and update skills from the local registry.
121
135
 
136
+ Agent instructions:
137
+ skillshop agents init creates a neutral .agents/instructions/AGENTS.template.md and renders it into a separate managed region in the project-root AGENTS.md. Existing text and prior managed guidance are preserved. Edit the template and sibling *.block.md sources, not the generated region.
138
+ Plain skillshop agents keeps the bounded bundled behavior until project sources exist; afterward it renders project sources. Missing sources in an initialized project are errors.
139
+ skillshop agents --check reports whether generated output is current and never writes. skillshop agents --global retains user-level instruction updates.
140
+ The default boundary is the nearest Git/worktree root, or the current directory outside Git. --project selects another directory relative to the current working directory. Nested projects need an explicit selection.
141
+ Source, block, template, and output paths containing symlink components, including dangling links, are rejected.
142
+ Templates accept {{ include "name.block.md" }}, {{ include 'name.block.md' }}, legacy {{ fragments["name.block.md"] }}, and {{ blocks }} for unreferenced blocks. Inserted block text is literal.
143
+
144
+ Upgrade:
145
+ skillshop upgrade updates the npm installation that supplied the CLI, then refreshes recorded Skillshop-managed copies with the newly installed CLI. skillshop init is optional and upgrade does not generate AGENTS.md files.
146
+
122
147
  Options:
123
148
  -g, --global Use configured system skill directories without a project lockfile
149
+ --project <directory> Select the agents boundary relative to the current directory
150
+ --check Check project-generated instructions without writing
124
151
  --push Update every registered repository
125
152
  -v, --version <version> Require an exact skill version
126
153
  -h, --help Show this help
@@ -775,28 +802,86 @@ function installGlobal(name, requestedVersion) {
775
802
  }
776
803
  }
777
804
 
778
- function nearestAgentsFile(cwd) {
779
- let directory = path.resolve(cwd);
780
- while (true) {
805
+ function instructionProjectRoot(cwd, selectedProject) {
806
+ if (selectedProject) {
807
+ const selected = path.resolve(cwd, selectedProject);
808
+ let stats;
809
+ try {
810
+ stats = fs.statSync(selected);
811
+ } catch {
812
+ throw new CliError(`project directory does not exist: ${selected}`);
813
+ }
814
+ if (!stats.isDirectory()) throw new CliError(`project path is not a directory: ${selected}`);
815
+ return canonicalPath(selected);
816
+ }
817
+
818
+ return canonicalPath(projectRoot(cwd, { required: false }) ?? cwd);
819
+ }
820
+
821
+ function isWithinDirectory(root, target) {
822
+ const relative = path.relative(root, target);
823
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
824
+ }
825
+
826
+ function nearestAgentsFile(cwd, root) {
827
+ const start = canonicalPath(cwd);
828
+ let directory = isWithinDirectory(root, start) ? start : root;
829
+ while (isWithinDirectory(root, directory)) {
781
830
  const candidate = path.join(directory, 'AGENTS.md');
782
831
  if (fs.existsSync(candidate)) return candidate;
783
- const parent = path.dirname(directory);
784
- if (parent === directory) return null;
785
- directory = parent;
832
+ if (directory === root) break;
833
+ directory = path.dirname(directory);
834
+ }
835
+ return path.join(root, 'AGENTS.md');
836
+ }
837
+
838
+ function assertLocalInstructionSources(root, contentDirectory) {
839
+ assertNoInstructionSymlink(root, contentDirectory, 'local instruction source directory');
840
+ if (!fs.existsSync(contentDirectory)) return;
841
+ for (const entry of fs.readdirSync(contentDirectory, { withFileTypes: true })) {
842
+ if (entry.name.endsWith('.block.md')) {
843
+ const sourcePath = path.join(contentDirectory, entry.name);
844
+ const label = `local instruction source ${entry.name}`;
845
+ assertLocalDestination(root, sourcePath, label);
846
+ assertNoInstructionSymlink(root, sourcePath, label);
847
+ }
848
+ }
849
+ }
850
+
851
+ function assertNoInstructionSymlink(root, target, label) {
852
+ const boundary = path.resolve(root);
853
+ const absoluteTarget = path.resolve(target);
854
+ const relative = path.relative(boundary, absoluteTarget);
855
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
856
+ throw new CliError(`${label} resolves outside the project`);
857
+ }
858
+
859
+ let current = boundary;
860
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
861
+ current = path.join(current, segment);
862
+ try {
863
+ if (fs.lstatSync(current).isSymbolicLink()) {
864
+ throw new CliError(`${label} contains a symlink path component`);
865
+ }
866
+ } catch (error) {
867
+ if (error instanceof CliError) throw error;
868
+ if (error.code === 'ENOENT') break;
869
+ throw error;
870
+ }
786
871
  }
787
872
  }
788
873
 
789
- function installAgents(cwd, globalInstall) {
874
+ function installAgents(cwd, globalInstall, selectedProject) {
790
875
  const composed = composeAgentsInstructions(agentsContentRoot);
791
876
  const targetPaths = [];
877
+ const root = globalInstall ? null : instructionProjectRoot(cwd, selectedProject);
792
878
  if (globalInstall) {
793
879
  targetPaths.push(...globalAgentsTargets.map((target) => path.join(os.homedir(), target)));
794
880
  } else {
795
- const targetPath = nearestAgentsFile(cwd);
796
- if (!targetPath) {
797
- console.log(`No AGENTS.md found at or above ${cwd}`);
798
- return;
799
- }
881
+ const targetPath = nearestAgentsFile(cwd, root);
882
+ assertLocalDestination(root, targetPath, 'local agents instructions');
883
+ assertNoInstructionSymlink(root, targetPath, 'local agents instructions');
884
+ assertLocalInstructionSources(root, path.dirname(targetPath));
800
885
  targetPaths.push(targetPath);
801
886
  }
802
887
 
@@ -824,6 +909,97 @@ function installAgents(cwd, globalInstall) {
824
909
  }
825
910
  }
826
911
 
912
+ function projectInstructionPaths(cwd, selectedProject) {
913
+ const root = instructionProjectRoot(cwd, selectedProject);
914
+ const sourceDirectory = path.join(root, projectTemplateDirectory);
915
+ const templatePath = path.join(sourceDirectory, projectTemplateName);
916
+ const outputPath = path.join(root, 'AGENTS.md');
917
+ const paths = [
918
+ [sourceDirectory, 'project instruction source directory'],
919
+ [templatePath, 'project instruction template'],
920
+ [outputPath, 'project agents output']
921
+ ];
922
+ for (const [target, label] of paths) {
923
+ assertLocalDestination(root, target, label);
924
+ assertNoInstructionSymlink(root, target, label);
925
+ }
926
+
927
+ if (fs.existsSync(sourceDirectory) && !fs.statSync(sourceDirectory).isDirectory()) {
928
+ throw new CliError(`project instruction source path is not a directory: ${sourceDirectory}`);
929
+ }
930
+ if (fs.existsSync(templatePath) && !fs.statSync(templatePath).isFile()) {
931
+ throw new CliError(`project instruction template is not a file: ${templatePath}`);
932
+ }
933
+ if (fs.existsSync(outputPath) && !fs.statSync(outputPath).isFile()) {
934
+ throw new CliError(`project agents output is not a file: ${outputPath}`);
935
+ }
936
+ if (fs.existsSync(sourceDirectory)) {
937
+ for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) {
938
+ if (!entry.name.endsWith('.block.md')) continue;
939
+ const sourcePath = path.join(sourceDirectory, entry.name);
940
+ const label = `project instruction source ${entry.name}`;
941
+ assertLocalDestination(root, sourcePath, label);
942
+ assertNoInstructionSymlink(root, sourcePath, label);
943
+ }
944
+ }
945
+
946
+ return { root, sourceDirectory, templatePath, outputPath };
947
+ }
948
+
949
+ function renderProjectAgents(cwd, selectedProject, { initialize = false, check = false } = {}) {
950
+ let paths = projectInstructionPaths(cwd, selectedProject);
951
+ if (initialize) {
952
+ fs.mkdirSync(paths.sourceDirectory, { recursive: true });
953
+ if (!fs.existsSync(paths.templatePath)) {
954
+ fs.writeFileSync(paths.templatePath, defaultProjectTemplate);
955
+ }
956
+ paths = projectInstructionPaths(cwd, selectedProject);
957
+ }
958
+
959
+ if (!fs.existsSync(paths.templatePath)) {
960
+ throw new CliError(`project instruction template is missing; run "skillshop agents init" first: ${paths.templatePath}`);
961
+ }
962
+
963
+ const composed = composeAgentsInstructions(paths.sourceDirectory, projectTemplateName).content;
964
+ const existing = fs.existsSync(paths.outputPath) ? fs.readFileSync(paths.outputPath, 'utf8') : '';
965
+ const planned = applyProjectInstructions(existing, composed, packageJson.version);
966
+
967
+ if (check) {
968
+ if (fs.existsSync(paths.outputPath) && planned.text === existing) {
969
+ console.log(`Project agents instructions are current at ${paths.outputPath}`);
970
+ return;
971
+ }
972
+ console.error(
973
+ `Project agents instructions are ${fs.existsSync(paths.outputPath) ? 'stale' : 'missing'} at ${paths.outputPath}`
974
+ );
975
+ process.exitCode = 1;
976
+ return;
977
+ }
978
+
979
+ if (planned.text === existing) {
980
+ console.log(`Project agents instructions already current at ${paths.outputPath}`);
981
+ return;
982
+ }
983
+ fs.writeFileSync(paths.outputPath, planned.text);
984
+ console.log(`${planned.action === 'refreshed' ? 'Refreshed' : 'Rendered'} project agents instructions at ${paths.outputPath}`);
985
+ }
986
+
987
+ function projectAgentsConfigured(cwd, selectedProject) {
988
+ const root = instructionProjectRoot(cwd, selectedProject);
989
+ const sourceDirectory = path.join(root, projectTemplateDirectory);
990
+ const outputPath = path.join(root, 'AGENTS.md');
991
+ for (const candidate of [sourceDirectory, outputPath]) {
992
+ try {
993
+ const stat = fs.lstatSync(candidate);
994
+ if (candidate === sourceDirectory) return true;
995
+ if (stat.isFile() && hasProjectInstructions(fs.readFileSync(candidate, 'utf8'))) return true;
996
+ } catch (error) {
997
+ if (error.code !== 'ENOENT') throw error;
998
+ }
999
+ }
1000
+ return false;
1001
+ }
1002
+
827
1003
  function installLocal(name, requestedVersion, cwd) {
828
1004
  const root = findLockRoot(cwd) ?? projectRoot(cwd);
829
1005
  const lock = readLock(root);
@@ -983,6 +1159,208 @@ function updateAllRepositories() {
983
1159
  console.log(`Updated ${retained.length} registered ${retained.length === 1 ? 'repository' : 'repositories'}`);
984
1160
  }
985
1161
 
1162
+ function assertNoManagedSymlink(base, target, label) {
1163
+ const boundary = path.resolve(base);
1164
+ const absoluteTarget = path.resolve(target);
1165
+ const relative = path.relative(boundary, absoluteTarget);
1166
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1167
+ throw new CliError(`${label} resolves outside its managed root`);
1168
+ }
1169
+
1170
+ let current = boundary;
1171
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
1172
+ current = path.join(current, segment);
1173
+ try {
1174
+ if (fs.lstatSync(current).isSymbolicLink()) {
1175
+ throw new CliError(`${label} contains a symlink path component`);
1176
+ }
1177
+ } catch (error) {
1178
+ if (error instanceof CliError) throw error;
1179
+ if (error.code === 'ENOENT') break;
1180
+ throw error;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ function replaceManagedSkillDirectory(sourceDir, targetDir) {
1186
+ const temporaryDir = `${targetDir}.skillshop-upgrade-tmp-${process.pid}`;
1187
+ const backupDir = `${targetDir}.skillshop-upgrade-backup-${process.pid}`;
1188
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
1189
+ fs.rmSync(backupDir, { recursive: true, force: true });
1190
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
1191
+ fs.cpSync(sourceDir, temporaryDir, { recursive: true, force: true });
1192
+
1193
+ const hadTarget = fs.existsSync(targetDir);
1194
+ if (hadTarget) fs.renameSync(targetDir, backupDir);
1195
+ try {
1196
+ fs.renameSync(temporaryDir, targetDir);
1197
+ fs.rmSync(backupDir, { recursive: true, force: true });
1198
+ } catch (error) {
1199
+ if (hadTarget && !fs.existsSync(targetDir) && fs.existsSync(backupDir)) {
1200
+ fs.renameSync(backupDir, targetDir);
1201
+ }
1202
+ throw error;
1203
+ } finally {
1204
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
1205
+ }
1206
+ }
1207
+
1208
+ function upgradeSummary() {
1209
+ return { updated: 0, unchanged: 0, missing: 0, failed: [] };
1210
+ }
1211
+
1212
+ function validateManagedCopy(root, target, name) {
1213
+ assertNoManagedSymlink(root, target, `managed ${name} copy`);
1214
+ let targetStats;
1215
+ try {
1216
+ targetStats = fs.lstatSync(target);
1217
+ } catch (error) {
1218
+ if (error.code === 'ENOENT') return false;
1219
+ throw error;
1220
+ }
1221
+ if (!targetStats.isDirectory()) throw new CliError(`${target} is not a managed skill directory`);
1222
+
1223
+ const manifestPath = path.join(target, 'SKILL.md');
1224
+ assertNoManagedSymlink(root, manifestPath, `managed ${name} manifest`);
1225
+ let manifestStats;
1226
+ try {
1227
+ manifestStats = fs.lstatSync(manifestPath);
1228
+ } catch (error) {
1229
+ if (error.code === 'ENOENT') throw new CliError(`${manifestPath} is missing from a recorded skill copy`);
1230
+ throw error;
1231
+ }
1232
+ if (!manifestStats.isFile()) throw new CliError(`${manifestPath} is not a regular skill manifest`);
1233
+ return true;
1234
+ }
1235
+
1236
+ function refreshManagedLock(root, lock, { global = false, summary }) {
1237
+ let changed = false;
1238
+ for (const name of Object.keys(lock.skills).sort()) {
1239
+ const entry = lock.skills[name];
1240
+ try {
1241
+ const metadata = skillMetadata(name);
1242
+ const targets = global ? globalTargets(name, entry) : lockedTargets(root, name, entry);
1243
+ const base = global ? os.homedir() : root;
1244
+ const existingTargets = [];
1245
+ for (const target of targets) {
1246
+ if (validateManagedCopy(base, target, name)) existingTargets.push(target);
1247
+ else summary.missing += 1;
1248
+ }
1249
+
1250
+ if (!existingTargets.length) {
1251
+ delete lock.skills[name];
1252
+ changed = true;
1253
+ continue;
1254
+ }
1255
+
1256
+ const successfulTargets = [];
1257
+ let skillFailed = false;
1258
+ for (const target of existingTargets) {
1259
+ const recordedVersion = global ? entry.copies?.[target]?.version : entry.version;
1260
+ if (recordedVersion === metadata.version) {
1261
+ summary.unchanged += 1;
1262
+ successfulTargets.push(target);
1263
+ continue;
1264
+ }
1265
+ try {
1266
+ replaceManagedSkillDirectory(metadata.sourceDir, target);
1267
+ summary.updated += 1;
1268
+ successfulTargets.push(target);
1269
+ } catch (error) {
1270
+ summary.failed.push(`${name} at ${target}: ${error.message}`);
1271
+ skillFailed = true;
1272
+ }
1273
+ }
1274
+
1275
+ if (global) {
1276
+ const keptTargets = skillFailed ? targets : successfulTargets;
1277
+ entry.paths = keptTargets;
1278
+ entry.copies ??= {};
1279
+ for (const target of successfulTargets) {
1280
+ const previous = entry.copies[target] ?? {};
1281
+ entry.copies[target] = { ...previous, author: metadata.author, version: metadata.version };
1282
+ }
1283
+ if (!skillFailed) changed = true;
1284
+ if (!entry.paths.length) {
1285
+ delete lock.skills[name];
1286
+ changed = true;
1287
+ }
1288
+ } else if (!skillFailed) {
1289
+ entry.paths = successfulTargets.map((target) => relativePath(root, target)).sort();
1290
+ entry.author = metadata.author;
1291
+ entry.version = metadata.version;
1292
+ changed = true;
1293
+ } else if (existingTargets.length !== targets.length) {
1294
+ entry.paths = existingTargets.map((target) => relativePath(root, target)).sort();
1295
+ changed = true;
1296
+ }
1297
+ } catch (error) {
1298
+ summary.failed.push(`${name}: ${error.message}`);
1299
+ }
1300
+ }
1301
+
1302
+ if (changed) {
1303
+ try {
1304
+ if (global) writeGlobalSkills(lock);
1305
+ else writeLock(root, lock);
1306
+ } catch (error) {
1307
+ summary.failed.push(`could not save ${global ? 'global skill manifest' : path.join(root, lockFileName)}: ${error.message}`);
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ function refreshManagedCopies(scope) {
1313
+ const summary = upgradeSummary();
1314
+ if (scope === 'local') {
1315
+ const owner = localPackageOwner();
1316
+ if (!owner) {
1317
+ summary.failed.push('fresh Skillshop package is no longer attached to a local npm dependency');
1318
+ } else {
1319
+ try {
1320
+ const lock = readLock(owner.root);
1321
+ refreshManagedLock(owner.root, lock, { summary });
1322
+ } catch (error) {
1323
+ summary.failed.push(`${owner.root}: ${error.message}`);
1324
+ }
1325
+ }
1326
+ } else if (scope === 'global') {
1327
+ try {
1328
+ const globalLock = readGlobalSkills();
1329
+ refreshManagedLock(null, globalLock, { global: true, summary });
1330
+ } catch (error) {
1331
+ summary.failed.push(`global managed copies: ${error.message}`);
1332
+ }
1333
+
1334
+ try {
1335
+ const registry = readRegistry();
1336
+ for (const root of registry.repositories) {
1337
+ if (!hasLock(root)) continue;
1338
+ try {
1339
+ const lock = readLock(root);
1340
+ refreshManagedLock(root, lock, { summary });
1341
+ } catch (error) {
1342
+ summary.failed.push(`${root}: ${error.message}`);
1343
+ }
1344
+ }
1345
+ } catch (error) {
1346
+ summary.failed.push(`registered project copies: ${error.message}`);
1347
+ }
1348
+ } else {
1349
+ summary.failed.push(`unsupported managed-copy scope: ${scope}`);
1350
+ }
1351
+
1352
+ console.log(
1353
+ `Managed-copy refresh summary: ${summary.updated} updated, ${summary.unchanged} unchanged, ${summary.missing} missing paths pruned, ${summary.failed.length} failed.`
1354
+ );
1355
+ if (summary.failed.length) {
1356
+ for (const failure of summary.failed) console.error(`skillshop: upgrade refresh: ${failure}`);
1357
+ console.error('skillshop: npm upgrade succeeded, but managed-copy refresh was incomplete');
1358
+ process.exitCode = 1;
1359
+ } else {
1360
+ console.log(`Refreshed Skillshop-managed copies for ${scope} scope.`);
1361
+ }
1362
+ }
1363
+
986
1364
  const migrationSkipDirectories = new Set([
987
1365
  '.git',
988
1366
  '.skillshop',
@@ -1074,6 +1452,8 @@ function parseArgs(argv) {
1074
1452
  let pushUpdate = false;
1075
1453
  const agentsTargets = [];
1076
1454
  let requestedVersion = null;
1455
+ let selectedProject = null;
1456
+ let checkProject = false;
1077
1457
 
1078
1458
  for (let index = 0; index < argv.length; index += 1) {
1079
1459
  const argument = argv[index];
@@ -1081,6 +1461,15 @@ function parseArgs(argv) {
1081
1461
  globalInstall = true;
1082
1462
  } else if (argument === '--push') {
1083
1463
  pushUpdate = true;
1464
+ } else if (argument === '--check') {
1465
+ checkProject = true;
1466
+ } else if (argument === '--project') {
1467
+ selectedProject = argv[index + 1];
1468
+ if (!selectedProject) throw new CliError('--project requires a directory');
1469
+ index += 1;
1470
+ } else if (argument.startsWith('--project=')) {
1471
+ selectedProject = argument.slice('--project='.length);
1472
+ if (!selectedProject) throw new CliError('--project requires a directory');
1084
1473
  } else if (argument === '--codex' || argument === '--copilot') {
1085
1474
  throw new CliError(`${argument} is no longer supported; use agents --global`);
1086
1475
  } else if (argument === '--version' || argument === '-v') {
@@ -1097,7 +1486,15 @@ function parseArgs(argv) {
1097
1486
  }
1098
1487
  }
1099
1488
 
1100
- return { positional, globalInstall, pushUpdate, requestedVersion, agentsTargets: [...new Set(agentsTargets)] };
1489
+ return {
1490
+ positional,
1491
+ globalInstall,
1492
+ pushUpdate,
1493
+ requestedVersion,
1494
+ selectedProject,
1495
+ checkProject,
1496
+ agentsTargets: [...new Set(agentsTargets)]
1497
+ };
1101
1498
  }
1102
1499
 
1103
1500
  function listSkills() {
@@ -1107,8 +1504,128 @@ function listSkills() {
1107
1504
  }
1108
1505
  }
1109
1506
 
1507
+ function packageDependencySection(manifest) {
1508
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
1509
+ if (Object.hasOwn(manifest?.[section] ?? {}, 'skillshop')) return section;
1510
+ }
1511
+ return null;
1512
+ }
1513
+
1514
+ function localPackageOwner() {
1515
+ const installedPackageRoot = canonicalPath(packageRoot);
1516
+ let current = path.dirname(installedPackageRoot);
1517
+ while (true) {
1518
+ const manifestPath = path.join(current, 'package.json');
1519
+ if (fs.existsSync(manifestPath)) {
1520
+ try {
1521
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1522
+ const section = packageDependencySection(manifest);
1523
+ const declaredInstall = path.join(current, 'node_modules', 'skillshop');
1524
+ if (section && fs.existsSync(declaredInstall) && canonicalPath(declaredInstall) === installedPackageRoot) {
1525
+ return { root: current, section };
1526
+ }
1527
+ } catch (error) {
1528
+ if (error instanceof SyntaxError) throw new CliError(`cannot parse ${manifestPath}: ${error.message}`);
1529
+ throw error;
1530
+ }
1531
+ }
1532
+
1533
+ const parent = path.dirname(current);
1534
+ if (parent === current) return null;
1535
+ current = parent;
1536
+ }
1537
+ }
1538
+
1539
+ function npmGlobalRoot() {
1540
+ try {
1541
+ return path.resolve(execFileSync('npm', ['root', '--global'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim());
1542
+ } catch (error) {
1543
+ const detail = error.stderr?.toString().trim();
1544
+ throw new CliError(`cannot determine the active npm global package directory${detail ? `: ${detail}` : ''}`);
1545
+ }
1546
+ }
1547
+
1548
+ function npmPrefixFromRoot(globalRoot) {
1549
+ const parent = path.dirname(globalRoot);
1550
+ return path.basename(parent) === 'lib' ? path.dirname(parent) : parent;
1551
+ }
1552
+
1553
+ function resolveUpgradeScope() {
1554
+ const localOwner = localPackageOwner();
1555
+ if (localOwner) return { type: 'local', root: localOwner.root, dependencySection: localOwner.section, packageRoot };
1556
+
1557
+ const insideNodeModules = canonicalPath(packageRoot).split(path.sep).includes('node_modules');
1558
+ const activeGlobalRoot = npmGlobalRoot();
1559
+ if (canonicalPath(path.join(activeGlobalRoot, 'skillshop')) === canonicalPath(packageRoot)) {
1560
+ return { type: 'global', prefix: npmPrefixFromRoot(activeGlobalRoot), packageRoot };
1561
+ }
1562
+ if (insideNodeModules) {
1563
+ throw new CliError(
1564
+ `cannot match this installed Skillshop package to the active npm global prefix (${activeGlobalRoot}); no upgrade was run`
1565
+ );
1566
+ }
1567
+
1568
+ // Preserve the historical source-checkout behavior: install the package into
1569
+ // the active npm global prefix without editing the checkout's package files.
1570
+ return { type: 'global-checkout', packageRoot };
1571
+ }
1572
+
1573
+ function runUpgradeInstall(scope) {
1574
+ const args = scope.type === 'local'
1575
+ ? ['install', `--save-${scope.dependencySection === 'dependencies' ? 'prod' : scope.dependencySection === 'devDependencies' ? 'dev' : scope.dependencySection === 'optionalDependencies' ? 'optional' : 'peer'}`, 'skillshop@latest']
1576
+ : scope.type === 'global'
1577
+ ? ['install', '--global', '--prefix', scope.prefix, 'skillshop@latest']
1578
+ : ['install', '--global', 'skillshop@latest'];
1579
+ try {
1580
+ execFileSync('npm', args, {
1581
+ cwd: scope.type === 'local' ? scope.root : process.cwd(),
1582
+ stdio: 'inherit'
1583
+ });
1584
+ } catch (error) {
1585
+ const status = Number.isInteger(error.status) ? ` (exit ${error.status})` : '';
1586
+ throw new CliError(`npm upgrade failed${status}; managed copies were not refreshed`);
1587
+ }
1588
+ }
1589
+
1590
+ function freshUpgradePackageRoot(scope) {
1591
+ if (scope.type !== 'global-checkout') return scope.packageRoot;
1592
+ return path.join(npmGlobalRoot(), 'skillshop');
1593
+ }
1594
+
1110
1595
  function upgrade() {
1111
- execFileSync('npm', ['install', '--global', 'skillshop'], { stdio: 'inherit' });
1596
+ const scope = resolveUpgradeScope();
1597
+ const scopeLabel = scope.type === 'local'
1598
+ ? `project dependency in ${scope.root}`
1599
+ : scope.type === 'global'
1600
+ ? `global installation at ${scope.packageRoot}`
1601
+ : 'global installation (source-checkout compatibility mode)';
1602
+ console.log(`Upgrading Skillshop ${scopeLabel}...`);
1603
+ runUpgradeInstall(scope);
1604
+
1605
+ let freshCliPath;
1606
+ try {
1607
+ freshCliPath = path.join(freshUpgradePackageRoot(scope), 'src', 'cli.js');
1608
+ if (!fs.statSync(freshCliPath).isFile()) throw new Error('not a file');
1609
+ } catch {
1610
+ throw new CliError('npm upgrade succeeded, but the newly installed Skillshop CLI could not be found for managed-copy refresh');
1611
+ }
1612
+
1613
+ const child = spawnSync(process.execPath, [freshCliPath, internalUpgradeRefreshCommand], {
1614
+ cwd: scope.type === 'local' ? scope.root : process.cwd(),
1615
+ env: {
1616
+ ...process.env,
1617
+ SKILLSHOP_INTERNAL_UPGRADE_TOKEN: internalUpgradeRefreshToken,
1618
+ SKILLSHOP_INTERNAL_UPGRADE_SCOPE: scope.type === 'local' ? 'local' : 'global'
1619
+ },
1620
+ stdio: 'inherit'
1621
+ });
1622
+ if (child.error) {
1623
+ throw new CliError(`npm upgrade succeeded, but fresh Skillshop managed-copy refresh could not start: ${child.error.message}`);
1624
+ }
1625
+ if (child.status !== 0) {
1626
+ const status = child.status === null ? 'without an exit status' : `with exit ${child.status}`;
1627
+ throw new CliError(`npm upgrade succeeded, but fresh Skillshop managed-copy refresh failed ${status}`);
1628
+ }
1112
1629
  }
1113
1630
 
1114
1631
  async function main(argv) {
@@ -1121,9 +1638,25 @@ async function main(argv) {
1121
1638
  return;
1122
1639
  }
1123
1640
 
1124
- const { positional, globalInstall, pushUpdate, requestedVersion, agentsTargets } = parseArgs(argv);
1641
+ const { positional, globalInstall, pushUpdate, requestedVersion, selectedProject, checkProject, agentsTargets } = parseArgs(argv);
1125
1642
  const command = positional[0];
1126
1643
 
1644
+ if ((selectedProject || checkProject) && command !== 'agents') {
1645
+ throw new CliError('--project and --check are only valid with agents');
1646
+ }
1647
+
1648
+ if (command === internalUpgradeRefreshCommand) {
1649
+ if (
1650
+ process.env.SKILLSHOP_INTERNAL_UPGRADE_TOKEN !== internalUpgradeRefreshToken ||
1651
+ positional.length !== 1 ||
1652
+ requestedVersion || globalInstall || pushUpdate || selectedProject || checkProject || agentsTargets.length
1653
+ ) {
1654
+ throw new CliError('invalid internal upgrade continuation');
1655
+ }
1656
+ refreshManagedCopies(process.env.SKILLSHOP_INTERNAL_UPGRADE_SCOPE);
1657
+ return;
1658
+ }
1659
+
1127
1660
  if (command === 'init') {
1128
1661
  if (positional.length !== 1 || requestedVersion || globalInstall || pushUpdate || agentsTargets.length) {
1129
1662
  throw new CliError('init does not accept arguments or options');
@@ -1178,10 +1711,32 @@ async function main(argv) {
1178
1711
  }
1179
1712
 
1180
1713
  if (command === 'agents') {
1181
- if (positional.length !== 1 || requestedVersion || pushUpdate) {
1714
+ const initializeProject = positional.length === 2 && positional[1] === 'init';
1715
+ if (
1716
+ positional.length > 2 ||
1717
+ (positional.length === 2 && !initializeProject) ||
1718
+ requestedVersion ||
1719
+ pushUpdate ||
1720
+ agentsTargets.length
1721
+ ) {
1182
1722
  throw new CliError('agents does not accept skill or update options');
1183
1723
  }
1184
- installAgents(process.cwd(), globalInstall);
1724
+ if (globalInstall) {
1725
+ if (initializeProject || selectedProject || checkProject) {
1726
+ throw new CliError('--global cannot be combined with project agents options');
1727
+ }
1728
+ installAgents(process.cwd(), true);
1729
+ return;
1730
+ }
1731
+ if (initializeProject && checkProject) throw new CliError('agents init does not accept --check');
1732
+ if (initializeProject || checkProject || projectAgentsConfigured(process.cwd(), selectedProject)) {
1733
+ renderProjectAgents(process.cwd(), selectedProject, {
1734
+ initialize: initializeProject,
1735
+ check: checkProject
1736
+ });
1737
+ } else {
1738
+ installAgents(process.cwd(), false, selectedProject);
1739
+ }
1185
1740
  return;
1186
1741
  }
1187
1742
 
@@ -1214,13 +1769,46 @@ async function main(argv) {
1214
1769
  else installLocal(name, version, process.cwd());
1215
1770
  }
1216
1771
 
1772
+ function isIsolatedAgentsInvocation(argv) {
1773
+ // Preserve the top-level help/version routing before looking for a command.
1774
+ // main() handles help anywhere and the standalone version form without parsing.
1775
+ if (argv[0] === '--help' || argv[0] === '-h') return false;
1776
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) return false;
1777
+
1778
+ // parseArgs accepts project options on either side of the command. Ignore help
1779
+ // flags here because main() handles them before parsing, then classify using
1780
+ // the same positional-command rules as execution.
1781
+ try {
1782
+ const args = argv.filter((argument) => argument !== '--help' && argument !== '-h');
1783
+ return parseArgs(args).positional[0] === 'agents';
1784
+ } catch {
1785
+ return false;
1786
+ }
1787
+ }
1788
+
1789
+ function isUpgradeFlowInvocation(argv) {
1790
+ if (argv.includes('--help') || argv.includes('-h')) return false;
1791
+ if (
1792
+ argv[0] === internalUpgradeRefreshCommand &&
1793
+ process.env.SKILLSHOP_INTERNAL_UPGRADE_TOKEN === internalUpgradeRefreshToken
1794
+ ) return true;
1795
+ try {
1796
+ return parseArgs(argv).positional[0] === 'upgrade';
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1801
+
1217
1802
  export async function runLegacyCli(argv) {
1803
+ const isolatedAgentsCommand = isIsolatedAgentsInvocation(argv);
1804
+ const upgradeFlow = isUpgradeFlowInvocation(argv);
1805
+ const skipLegacyReconciliation = isolatedAgentsCommand || upgradeFlow;
1218
1806
  try {
1219
- reconcileCurrentRepository();
1807
+ if (!skipLegacyReconciliation) reconcileCurrentRepository();
1220
1808
  try {
1221
1809
  await main(argv);
1222
1810
  } finally {
1223
- reconcileCurrentRepository();
1811
+ if (!skipLegacyReconciliation) reconcileCurrentRepository();
1224
1812
  }
1225
1813
  } catch (error) {
1226
1814
  if (!(error instanceof CliError) && !(error instanceof AgentsInstructionsError)) throw error;