skillrepo 4.10.0 → 4.11.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
@@ -183,6 +183,18 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
183
183
  at the repo root is gitignored: the skillset declaration only works as
184
184
  a committed file, so remove it from `.gitignore` and commit it.
185
185
 
186
+ A repository can declare a skillset in a root `skillrepo.json`:
187
+ `{"skillset": {"version": 1, "name": "<repo-identity>", "use":
188
+ "owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
189
+ finds the file from any subdirectory (it walks up to the repository
190
+ root) and validates it before syncing. Skillset delivery is not yet
191
+ available: a repository with a declaration fails its sync closed
192
+ (exit code `6`) rather than receiving the whole library — a declared
193
+ repo only ever receives its declared skillset. Keep the declaration
194
+ while your team prepares for skillsets, or remove the `skillset`
195
+ block to sync your whole library. A `skillrepo.json` without a
196
+ `skillset` key is plain configuration and does not change sync.
197
+
186
198
  ### `get` — fetch a single skill
187
199
 
188
200
  ```sh
@@ -574,6 +586,7 @@ citations on each agent's read paths.
574
586
  | 3 | Disk error (cannot read or write a file/directory) |
575
587
  | 4 | Scope error (key lacks the required `registry:write` scope) |
576
588
  | 5 | Validation error (bad flag, malformed identifier, unknown vendor) |
589
+ | 6 | Unresolvable skillset declaration — the repo's root `skillrepo.json` declares a skillset that cannot be honored (invalid or nested declaration, reserved `@` version syntax, or skillset delivery not yet available). Fail-closed: nothing was written or removed. |
577
590
 
578
591
  Pass `--verbose` to any command to print stack traces and retry
579
592
  attempts on failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,6 +94,7 @@ import {
94
94
  authError,
95
95
  validationError,
96
96
  EXIT_AUTH,
97
+ EXIT_UNRESOLVABLE,
97
98
  } from "../lib/errors.mjs";
98
99
  import { cliAuthUrl } from "../lib/constants.mjs";
99
100
  import {
@@ -658,10 +659,23 @@ export async function runInit(argv, io = {}, deps = {}) {
658
659
  // rethrow) as an inconsistent contract: the warning told users
659
660
  // to retry with `update`, but the non-zero exit code made it
660
661
  // look like the whole init had failed.
661
- p.warning(
662
- `Config saved but first sync failed: ${err.message}. ` +
663
- `Run \`skillrepo update\` later to retry.`,
664
- );
662
+ // Declaration-gated repos (#2362) get tailored copy: "run
663
+ // update later to retry" is wrong advice when the sync failed
664
+ // closed on the repo's skillset declaration — retrying changes
665
+ // nothing until the declaration is fixed/removed (or, for the
666
+ // not-yet-available case, until skillset delivery ships). The
667
+ // error message + hint already carry why and what to do.
668
+ if (err instanceof CliError && err.exitCode === EXIT_UNRESOLVABLE) {
669
+ p.warning(
670
+ `Config saved, but this repository's library sync is blocked: ${err.message}` +
671
+ (err.hint ? ` ${err.hint}` : ""),
672
+ );
673
+ } else {
674
+ p.warning(
675
+ `Config saved but first sync failed: ${err.message}. ` +
676
+ `Run \`skillrepo update\` later to retry.`,
677
+ );
678
+ }
665
679
  syncFailedReason = err.message;
666
680
  // Synthesize a zero-delta summary matching the SyncSummary
667
681
  // typedef in sync.mjs (added, updated, removed, notModified,
@@ -9,6 +9,15 @@
9
9
  * 3 — disk error (cannot read or write a file/directory)
10
10
  * 4 — scope error (key lacks the required scope for the requested action)
11
11
  * 5 — validation error (bad CLI input — invalid flag, malformed identifier, etc.)
12
+ * 6 — unresolvable skillset declaration (#2362, design doc D9): the
13
+ * repo's `skillrepo.json` skillset declaration cannot be honored
14
+ * (unparseable file, invalid sub-schema, nested declarations,
15
+ * reserved `@` version syntax, or skillset delivery not yet
16
+ * available). Fail-closed: nothing was written or removed. This
17
+ * one code covers the WHOLE D9 fail-closed class, locally AND —
18
+ * once H8 (#2365) wires the server — the server's typed 422
19
+ * `harness_unresolvable` response, so CI scripts get one stable
20
+ * "declaration cannot be honored" signal.
12
21
  *
13
22
  * These mirror the documented behavior in #683 and are the contract
14
23
  * shell users and CI scripts can rely on.
@@ -40,6 +49,7 @@ export const EXIT_AUTH = 2;
40
49
  export const EXIT_DISK = 3;
41
50
  export const EXIT_SCOPE = 4;
42
51
  export const EXIT_VALIDATION = 5;
52
+ export const EXIT_UNRESOLVABLE = 6;
43
53
 
44
54
  /**
45
55
  * Base error class for typed CLI errors. Carries an exit code and
@@ -89,6 +99,19 @@ export function validationError(message, options) {
89
99
  return new CliError(message, EXIT_VALIDATION, options);
90
100
  }
91
101
 
102
+ /**
103
+ * Unresolvable skillset declaration (#2362). Distinct from
104
+ * `validationError` on purpose: exit 5 means "your CLI input is bad",
105
+ * and one member of this class — skillset delivery not yet being
106
+ * available — is not the user's fault at all (the declaration file may
107
+ * be perfectly valid). D9's fail-closed contract holds for every
108
+ * member: nothing was written or removed, and the sync NEVER falls
109
+ * back to whole-library delivery.
110
+ */
111
+ export function unresolvableError(message, options) {
112
+ return new CliError(message, EXIT_UNRESOLVABLE, options);
113
+ }
114
+
92
115
  // ── Retry helper ───────────────────────────────────────────────────────
93
116
 
94
117
  /**
@@ -160,6 +160,21 @@ export function escapeControlChars(value) {
160
160
  );
161
161
  }
162
162
 
163
+ /**
164
+ * True when the value contains any control character (C0, DEL, or C1 —
165
+ * the same class `escapeControlChars` escapes). Exported (#2362) so
166
+ * validators can REJECT control characters up front with the one
167
+ * canonical class instead of hand-rolling a second, narrower regex —
168
+ * the skillset-declaration validator is the first consumer. Single
169
+ * source per the shared-logic rule.
170
+ *
171
+ * @param {string} value
172
+ * @returns {boolean}
173
+ */
174
+ export function hasControlChars(value) {
175
+ return CONTROL_CHARS.test(value);
176
+ }
177
+
163
178
  /**
164
179
  * Validate a single file path inside a skill directory.
165
180
  *
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Repo skillset-declaration resolution (#2362, epic #2357).
3
+ *
4
+ * The committed, human-owned governance contract lives at the repo
5
+ * root in `skillrepo.json`, nested under a `skillset` key (design doc
6
+ * D6 amendment + sub-schema note, 2026-08-07):
7
+ *
8
+ * {
9
+ * "skillset": {
10
+ * "version": 1,
11
+ * "name": "checkout-service",
12
+ * "use": "acme/backend-core",
13
+ * "extra": ["acme/db-migrations"]
14
+ * }
15
+ * }
16
+ *
17
+ * This module DISCOVERS the declaration (walk-up from cwd) and
18
+ * VALIDATES it (imperative, fail-closed per D9). It performs no
19
+ * network work and never writes anything. Resolution against the
20
+ * server-side skillset entity is H8 (#2365); until that lands, a
21
+ * well-formed declaration is unresolvable BY DEFINITION and sync
22
+ * fails closed with the "not yet available" copy below — the error
23
+ * must never imply the user's file is wrong (H5 design note under D9).
24
+ *
25
+ * WALK-UP SEMANTICS (H5 design note under D6, 2026-08-07):
26
+ *
27
+ * - Directories are probed from cwd upward. The walk's boundary is
28
+ * the first directory containing `.git` (a directory OR a file —
29
+ * worktrees and submodules use a `gitdir:` file), probed
30
+ * INCLUSIVE, so the repo root itself is checked and parent repos
31
+ * are never consulted. Outside any git repo the walk continues to
32
+ * the filesystem root (also inclusive) — ancestor-config
33
+ * semantics, same family as `.gitignore`/`.editorconfig`.
34
+ * - Exactly ONE `skillrepo.json` may exist on the walk path. Two or
35
+ * more is the deferred monorepo-nesting shape and fails closed
36
+ * with a "not yet supported" error (the documented FUTURE
37
+ * semantic is nearest-file-wins; erroring today holds that door
38
+ * open without guessing). Nesting is detected on file PRESENCE,
39
+ * not parsed content — a config-only `skillrepo.json` below a
40
+ * declared root still errors, because silently picking one of two
41
+ * governance-relevant files is exactly the ambiguity D9 forbids.
42
+ * - The single file's directory is the DETECTED ROOT. It — not
43
+ * cwd — anchors everything repo-scoped that follows: H8's
44
+ * skillset-scoped placement writes, D12's realpath-hash state
45
+ * key, and (already in H5) the gitignored-declaration probe and
46
+ * its warn-on-new seen key. Repos with no declaration keep
47
+ * today's cwd-anchored behavior byte-identical (D2).
48
+ *
49
+ * A `skillrepo.json` whose top level parses but has NO `skillset` key
50
+ * is plain tool config, not a declaration — sync proceeds on today's
51
+ * whole-library path. Unknown TOP-LEVEL keys are tolerated (future
52
+ * `skillrepo.json` features version themselves); unknown keys INSIDE
53
+ * the `skillset` block fail closed, because a typo like `"extras"`
54
+ * silently ignored would mean silently mis-delivering what the user
55
+ * believes they declared.
56
+ *
57
+ * Error class: every fail-closed outcome here throws/carries a
58
+ * `CliError` with EXIT_UNRESOLVABLE (6) — see errors.mjs for why this
59
+ * is one code for the whole D9 class rather than a validation error.
60
+ */
61
+
62
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
63
+ import { dirname, join, relative, resolve, isAbsolute } from "node:path";
64
+
65
+ import { SKILLSET_DECLARATION_FILE } from "./constants.mjs";
66
+ import { unresolvableError } from "./errors.mjs";
67
+ import { escapeControlChars, hasControlChars } from "./file-write.mjs";
68
+
69
+ /** Top-level key under which the declaration lives (D6 sub-schema). */
70
+ export const DECLARATION_KEY = "skillset";
71
+
72
+ /**
73
+ * The declaration-schema version this CLI understands. Versions the
74
+ * BLOCK, not the whole file — future top-level `skillrepo.json` keys
75
+ * version themselves (D6 sub-schema note).
76
+ */
77
+ export const DECLARATION_SCHEMA_VERSION = 1;
78
+
79
+ /**
80
+ * @typedef {Object} SkillsetDeclaration
81
+ * @property {number} version - Declaration-schema version (=== 1).
82
+ * @property {string} name - Repo self-declared identity (D12 keys
83
+ * per-repo server state on it).
84
+ * @property {string} use - `"owner/skillset-name"` pointer to the
85
+ * server-side named skillset.
86
+ * @property {string[]} extra - Optional per-repo additions, normalized
87
+ * to `[]` when absent.
88
+ *
89
+ * @typedef {Object} DeclarationResolution
90
+ * @property {"absent" | "config-only" | "declared" | "invalid"} status
91
+ * @property {string} [filePath] - Absolute path of the found file.
92
+ * Present on config-only / declared and on every "invalid"
93
+ * outcome EXCEPT multiple-files: with two or more files on
94
+ * the walk path there is no single file (and no single
95
+ * detected root) to name, so both fields are deliberately
96
+ * absent rather than normalized to an arbitrary winner —
97
+ * H6/H8 consumers must not assume them on "invalid".
98
+ * @property {string} [rootDir] - The DETECTED ROOT: directory of the
99
+ * found file. Anchors repo-scoped behavior (see module doc).
100
+ * Same multiple-files exception as `filePath`.
101
+ * @property {import("./errors.mjs").CliError} [error] - Only on
102
+ * status "invalid" — the typed EXIT_UNRESOLVABLE error the
103
+ * caller throws. Constructed here so copy lives with the
104
+ * schema it describes.
105
+ */
106
+
107
+ /**
108
+ * Collect every `skillrepo.json` on the walk path, cwd upward to the
109
+ * repo boundary (first `.git`-containing dir, inclusive) or the
110
+ * filesystem root (inclusive). Pure discovery — no parsing.
111
+ *
112
+ * Directory entries named `skillrepo.json` are skipped (a directory
113
+ * cannot be a declaration; probing it as one would fail-close every
114
+ * sync under a bizarre-but-harmless tree shape).
115
+ *
116
+ * @param {object} [options]
117
+ * @param {string} [options.cwd] - Test seam; defaults to process.cwd().
118
+ * @returns {string[]} Absolute file paths, nearest (cwd-most) first.
119
+ */
120
+ export function findDeclarationFiles({ cwd = process.cwd() } = {}) {
121
+ const files = [];
122
+ let dir = resolve(cwd);
123
+ for (;;) {
124
+ const candidate = join(dir, SKILLSET_DECLARATION_FILE);
125
+ // lstat (never existsSync/stat, which FOLLOW symlinks): a DANGLING
126
+ // symlink named skillrepo.json must be collected, not skipped —
127
+ // existsSync reports it false, which silently classified the repo
128
+ // as undeclared and delivered the whole library (fail-OPEN; the
129
+ // 2026-08-07 prod-readiness QA audit caught this inverting D9).
130
+ // A dangling entry flows into resolveDeclaration's readFileSync,
131
+ // which throws there and routes into the fail-closed cannot-read
132
+ // branch; a WORKING file symlink reads through like a plain file.
133
+ let entry = null;
134
+ try {
135
+ entry = lstatSync(candidate);
136
+ } catch {
137
+ // No directory entry at this path at all.
138
+ }
139
+ if (entry && (entry.isFile() || entry.isSymbolicLink())) {
140
+ files.push(candidate);
141
+ }
142
+ // Plain directories (and sockets etc.) stay skipped, as before.
143
+ // `.git` may be a directory (normal clone) or a file (worktree /
144
+ // submodule `gitdir:` pointer) — existsSync covers both. The
145
+ // boundary dir itself was just probed above (inclusive).
146
+ if (existsSync(join(dir, ".git"))) break;
147
+ const parent = dirname(dir);
148
+ if (parent === dir) break; // filesystem root reached (probed above)
149
+ dir = parent;
150
+ }
151
+ return files;
152
+ }
153
+
154
+ /**
155
+ * Discover and validate the repo's skillset declaration.
156
+ *
157
+ * Read-only and local-only: no network, no writes. Returns a
158
+ * discriminated resolution rather than throwing so callers on
159
+ * non-gating paths (e.g. the governance-warning plumbing) can consume
160
+ * `rootDir` without try/catch; the sync gate throws `resolution.error`
161
+ * itself.
162
+ *
163
+ * @param {object} [options]
164
+ * @param {string} [options.cwd] - Test seam; defaults to process.cwd().
165
+ * @returns {DeclarationResolution}
166
+ */
167
+ export function resolveDeclaration({ cwd = process.cwd() } = {}) {
168
+ const files = findDeclarationFiles({ cwd });
169
+
170
+ if (files.length === 0) {
171
+ return { status: "absent" };
172
+ }
173
+
174
+ if (files.length > 1) {
175
+ // Detection is by file PRESENCE, so one of these may be plain
176
+ // config rather than a declaration — the copy says "at most one
177
+ // is supported" instead of branding both as declarations.
178
+ const shown = files.map((f) => displayPath(f, cwd)).join(", ");
179
+ return {
180
+ status: "invalid",
181
+ error: unresolvableError(
182
+ `found ${files.length} ${SKILLSET_DECLARATION_FILE} files between this directory and the repository root (${shown}). ` +
183
+ `At most one is supported today (nested and monorepo declarations are not yet supported), and choosing between them silently could deliver the wrong skillset, so nothing was synced.`,
184
+ {
185
+ hint: `Keep a single ${SKILLSET_DECLARATION_FILE} at the repository root and remove the others.`,
186
+ },
187
+ ),
188
+ };
189
+ }
190
+
191
+ const filePath = files[0];
192
+ const rootDir = dirname(filePath);
193
+ const shownPath = displayPath(filePath, cwd);
194
+
195
+ let raw;
196
+ try {
197
+ raw = readFileSync(filePath, "utf8");
198
+ } catch (err) {
199
+ // The file exists but cannot be read — we cannot rule out a
200
+ // declaration, so ignoring it could deliver the whole library
201
+ // into a deliberately-scoped repo (the exact D9 rejection).
202
+ return {
203
+ status: "invalid",
204
+ filePath,
205
+ rootDir,
206
+ error: unresolvableError(
207
+ `cannot read ${shownPath} (${escapeControlChars(err.message)}), so this repository's skillset declaration cannot be checked and nothing was synced.`,
208
+ { cause: err, hint: `Fix the file's permissions (or its symlink target), or remove it to resume whole-library sync.` },
209
+ ),
210
+ };
211
+ }
212
+
213
+ let parsed;
214
+ try {
215
+ // Strip a UTF-8 BOM — Windows editors add one and JSON.parse
216
+ // rejects it (Windows compat is mandatory for CLI work).
217
+ parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
218
+ } catch (err) {
219
+ return {
220
+ status: "invalid",
221
+ filePath,
222
+ rootDir,
223
+ error: unresolvableError(
224
+ // JSON.parse errors can quote raw file bytes — escape them,
225
+ // same as the path (#2402 class).
226
+ `${shownPath} is not valid JSON (${escapeControlChars(err.message)}), so this repository's skillset declaration cannot be read and nothing was synced.`,
227
+ { cause: err, hint: `Fix the JSON, or remove the file to resume whole-library sync.` },
228
+ ),
229
+ };
230
+ }
231
+
232
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
233
+ return {
234
+ status: "invalid",
235
+ filePath,
236
+ rootDir,
237
+ error: unresolvableError(
238
+ `${shownPath} must contain a JSON object at the top level (found ${describeType(parsed)}), so this repository's skillset declaration cannot be read and nothing was synced.`,
239
+ { hint: `Fix the file, or remove it to resume whole-library sync.` },
240
+ ),
241
+ };
242
+ }
243
+
244
+ if (!(DECLARATION_KEY in parsed)) {
245
+ // Plain tool config — not a declaration. Sync proceeds on the
246
+ // whole-library path; rootDir still anchors the gitignored probe.
247
+ return { status: "config-only", filePath, rootDir };
248
+ }
249
+
250
+ const blockError = validateDeclarationBlock(parsed[DECLARATION_KEY], shownPath);
251
+ if (blockError) {
252
+ return { status: "invalid", filePath, rootDir, error: blockError };
253
+ }
254
+
255
+ const block = parsed[DECLARATION_KEY];
256
+ return {
257
+ status: "declared",
258
+ filePath,
259
+ rootDir,
260
+ declaration: {
261
+ version: block.version,
262
+ name: block.name,
263
+ use: block.use,
264
+ extra: Array.isArray(block.extra) ? [...block.extra] : [],
265
+ },
266
+ };
267
+ }
268
+
269
+ /**
270
+ * The pre-H8 dark-shipping error (H5 design note under D9): a
271
+ * well-formed declaration cannot resolve because skillset delivery
272
+ * does not exist yet — server feature (H6) and client wiring (H8) are
273
+ * both unshipped. The copy therefore says "not yet available" and
274
+ * must NEVER imply the user's file is wrong. H8 replaces the call
275
+ * site with real resolution; a server-side `harness_unresolvable`
276
+ * 422 then maps onto this same exit code.
277
+ *
278
+ * @param {DeclarationResolution} resolution - A "declared" resolution.
279
+ * @returns {import("./errors.mjs").CliError}
280
+ */
281
+ export function declarationUnavailableError(resolution) {
282
+ // `use` passed shape validation, but it is still file-sourced text
283
+ // headed for a terminal — escape control characters (#2402 class).
284
+ const use = escapeControlChars(resolution.declaration.use);
285
+ return unresolvableError(
286
+ `this repository declares skillset "${use}" in ${SKILLSET_DECLARATION_FILE}, but skillset delivery is not yet available. ` +
287
+ `Nothing was synced: a repository that declares a skillset receives only that skillset, never the whole library.`,
288
+ {
289
+ hint:
290
+ `Keep the declaration while your team prepares for skillsets, or remove the ` +
291
+ `"${DECLARATION_KEY}" block from ${SKILLSET_DECLARATION_FILE} to sync your whole library here.`,
292
+ },
293
+ );
294
+ }
295
+
296
+ // ── Internals ──────────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * Fields the version-1 sub-schema defines. Anything else inside the
300
+ * block fails closed (typo guard — see module doc).
301
+ */
302
+ const KNOWN_BLOCK_FIELDS = new Set(["version", "name", "use", "extra"]);
303
+
304
+
305
+ /**
306
+ * Validate the `skillset` block against the D6 sub-schema. Returns
307
+ * null when valid, or the typed EXIT_UNRESOLVABLE error to surface.
308
+ * These messages DO describe problems with the user's file — that is
309
+ * exactly their job; only the not-yet-available error above must not.
310
+ *
311
+ * @param {unknown} block
312
+ * @param {string} shownPath - Display path for error copy.
313
+ * @returns {import("./errors.mjs").CliError | null}
314
+ */
315
+ function validateDeclarationBlock(block, shownPath) {
316
+ const fail = (problem, hint) =>
317
+ unresolvableError(
318
+ `${shownPath}: ${problem} Nothing was synced — a repository with a skillset declaration only receives its declared skillset once the declaration is valid.`,
319
+ { hint },
320
+ );
321
+
322
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
323
+ return fail(
324
+ `the "${DECLARATION_KEY}" value must be an object (found ${describeType(block)}).`,
325
+ `Use {"${DECLARATION_KEY}": {"version": 1, "name": "<repo-identity>", "use": "owner/skillset-name"}}.`,
326
+ );
327
+ }
328
+
329
+ const unknown = Object.keys(block).filter((k) => !KNOWN_BLOCK_FIELDS.has(k));
330
+ if (unknown.length > 0) {
331
+ const shown = unknown.map((k) => `"${escapeControlChars(k)}"`).join(", ");
332
+ return fail(
333
+ `the "${DECLARATION_KEY}" block has unknown field${unknown.length > 1 ? "s" : ""} ${shown}.`,
334
+ `Supported fields are "version", "name", "use", and "extra" — check for typos (a misspelled field would otherwise be silently ignored).`,
335
+ );
336
+ }
337
+
338
+ if (!Number.isInteger(block.version)) {
339
+ return fail(
340
+ `the "${DECLARATION_KEY}" block needs an integer "version" field (found ${describeType(block.version)}).`,
341
+ `Set "version": ${DECLARATION_SCHEMA_VERSION}.`,
342
+ );
343
+ }
344
+ if (block.version !== DECLARATION_SCHEMA_VERSION) {
345
+ return fail(
346
+ `declaration schema version ${block.version} is not supported by this CLI (it understands version ${DECLARATION_SCHEMA_VERSION}).`,
347
+ `Install the latest skillrepo CLI, or set "version": ${DECLARATION_SCHEMA_VERSION} if the file was written by hand.`,
348
+ );
349
+ }
350
+
351
+ if (typeof block.name !== "string" || block.name.trim() === "") {
352
+ return fail(
353
+ `the "${DECLARATION_KEY}" block needs a non-empty string "name" field (found ${describeType(block.name)}) — it is this repository's self-declared identity.`,
354
+ `Set "name" to a stable identifier for this repository, e.g. "checkout-service".`,
355
+ );
356
+ }
357
+ // Control characters (C0/DEL/C1, the canonical file-write.mjs
358
+ // class) are never valid in an identifier — no legitimate repo
359
+ // identity or owner/name contains them, and letting them through
360
+ // would defer a certain rejection to copy that blames the wrong
361
+ // thing ("not yet available"). Rejected up front with an honest
362
+ // message; still escaped wherever echoed (#2402 class).
363
+ if (hasControlChars(block.name)) {
364
+ return fail(
365
+ `the "name" field ("${escapeControlChars(block.name)}") contains control characters, which are never valid in an identifier.`,
366
+ `Set "name" to a plain identifier for this repository, e.g. "checkout-service".`,
367
+ );
368
+ }
369
+
370
+ const useError = validatePointer(block.use, "use", fail);
371
+ if (useError) return useError;
372
+
373
+ if (block.extra !== undefined) {
374
+ if (!Array.isArray(block.extra)) {
375
+ return fail(
376
+ `the "extra" field must be an array of "owner/skill" strings (found ${describeType(block.extra)}).`,
377
+ `Use "extra": ["owner/skill", ...], or remove the field.`,
378
+ );
379
+ }
380
+ for (let i = 0; i < block.extra.length; i++) {
381
+ const entryError = validatePointer(block.extra[i], `extra[${i}]`, fail);
382
+ if (entryError) return entryError;
383
+ }
384
+ }
385
+
386
+ return null;
387
+ }
388
+
389
+ /**
390
+ * Shared shape check for the `use` pointer and each `extra` entry:
391
+ * a non-empty `"owner/name"` string with exactly one slash and no
392
+ * reserved `@` version syntax (D3 reservation).
393
+ *
394
+ * @param {unknown} value
395
+ * @param {string} field - Field label for error copy.
396
+ * @param {(problem: string, hint: string) => import("./errors.mjs").CliError} fail
397
+ * @returns {import("./errors.mjs").CliError | null}
398
+ */
399
+ function validatePointer(value, field, fail) {
400
+ if (typeof value !== "string" || value.trim() === "") {
401
+ return fail(
402
+ `the "${field}" field must be a non-empty "owner/name" string (found ${describeType(value)}).`,
403
+ field === "use"
404
+ ? `Set "use" to the skillset this repository runs, e.g. "acme/backend-core".`
405
+ : `Each "extra" entry names one library skill, e.g. "acme/db-migrations".`,
406
+ );
407
+ }
408
+ const shown = escapeControlChars(value);
409
+ if (hasControlChars(value)) {
410
+ return fail(
411
+ `"${field}": "${shown}" contains control characters, which are never valid in an owner/name identifier.`,
412
+ field === "use"
413
+ ? `Set "use" to the skillset this repository runs, e.g. "acme/backend-core".`
414
+ : `Each "extra" entry names one library skill, e.g. "acme/db-migrations".`,
415
+ );
416
+ }
417
+ if (value.includes("@")) {
418
+ // D3: one version-control point in v1 (the library pin). The
419
+ // `@range` syntax is reserved, not silently ignored.
420
+ return fail(
421
+ `"${field}": "${shown}" uses "@" version syntax, which is not yet supported — versions are controlled by library pins in v1.`,
422
+ `Remove the "@..." suffix; the approved version is decided in your library.`,
423
+ );
424
+ }
425
+ const slashAt = value.indexOf("/");
426
+ if (slashAt <= 0 || slashAt !== value.lastIndexOf("/") || slashAt === value.length - 1) {
427
+ return fail(
428
+ `"${field}": "${shown}" must have the form "owner/name" (one slash, both parts non-empty).`,
429
+ field === "use"
430
+ ? `Set "use" to the skillset this repository runs, e.g. "acme/backend-core".`
431
+ : `Each "extra" entry names one library skill, e.g. "acme/db-migrations".`,
432
+ );
433
+ }
434
+ return null;
435
+ }
436
+
437
+ /**
438
+ * Human-facing path for error copy: relative to cwd when the file is
439
+ * at or below it (the common run-at-root case reads as plain
440
+ * `skillrepo.json`), absolute otherwise — a `../../..`-chain is worse
441
+ * than an absolute path.
442
+ *
443
+ * Control characters are escaped HERE, at the single point display
444
+ * paths are produced: every segment comes from real directory names
445
+ * on disk — filesystem-sourced, attacker-influenceable strings (a
446
+ * cloned repo chooses its own directory names), the exact #2402
447
+ * injection class — and the messages carrying them are printed
448
+ * verbatim by the dispatcher and the session-hook one-liner.
449
+ *
450
+ * @param {string} filePath - Absolute path.
451
+ * @param {string} cwd
452
+ * @returns {string}
453
+ */
454
+ function displayPath(filePath, cwd) {
455
+ const rel = relative(resolve(cwd), filePath);
456
+ if (rel === "") return SKILLSET_DECLARATION_FILE;
457
+ if (rel.startsWith("..") || isAbsolute(rel)) return escapeControlChars(filePath);
458
+ return escapeControlChars(rel);
459
+ }
460
+
461
+ /**
462
+ * Type description for error copy. Distinguishes null and arrays from
463
+ * plain objects because "found object" for a null is actively
464
+ * misleading in a JSON context.
465
+ *
466
+ * @param {unknown} value
467
+ * @returns {string}
468
+ */
469
+ function describeType(value) {
470
+ if (value === null) return "null";
471
+ if (value === undefined) return "nothing";
472
+ if (Array.isArray(value)) return "an array";
473
+ if (typeof value === "string") return "a string";
474
+ if (typeof value === "number") return "a number";
475
+ if (typeof value === "boolean") return "a boolean";
476
+ return `${typeof value === "object" ? "an" : "a"} ${typeof value}`;
477
+ }