skillrepo 4.9.2 → 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
@@ -169,6 +169,32 @@ code and the error message goes to stderr (distinct from
169
169
  `--session-hook`, which is Claude-Code-specific and exits 0 on every
170
170
  error so a sync failure can't block a session start).
171
171
 
172
+ Every sync also inventories the skill folders it manages. A directory
173
+ the CLI did not write — a hand-authored or copied-in skill — gets a
174
+ one-line warning naming the path, why it matters (agents load skills
175
+ from disk whether or not they came from your library), and what to do
176
+ about it (`skillrepo push <path>` to bring it into your library, or
177
+ remove the directory). Each finding is reported once: the warning
178
+ repeats only when something changes, and only interactive runs print
179
+ it (session auto-sync hooks stay quiet). Warnings are local only
180
+ (skill names never leave your machine), and nothing is ever deleted or
181
+ modified. `--json` output includes the total count as `unmanaged`.
182
+ Sync also warns — once per repo, same rules — when a `skillrepo.json`
183
+ at the repo root is gitignored: the skillset declaration only works as
184
+ a committed file, so remove it from `.gitignore` and commit it.
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
+
172
198
  ### `get` — fetch a single skill
173
199
 
174
200
  ```sh
@@ -543,9 +569,9 @@ Skills land at one of two project paths, depending on the agent:
543
569
  | Cursor / Windsurf / Gemini CLI / Codex CLI / Cline / GitHub Copilot | `.agents/skills/<name>/` | `~/.agents/skills/<name>/` |
544
570
  | Windsurf (personal-scope override) | — | `~/.codeium/windsurf/skills/<name>/` |
545
571
 
546
- The CLI auto-adds `.claude/skills/` and `.agents/skills/` to your project
547
- `.gitignore` on first write skills are a per-developer cache, not
548
- committed content. The `--agent` flag overrides detection (e.g.
572
+ The CLI keeps `.claude/skills/` and `.agents/skills/` listed in the
573
+ SkillRepo section of your project `.gitignore`, re-checked on every
574
+ sync — skills are a per-developer cache, not committed content. The `--agent` flag overrides detection (e.g.
549
575
  `--agent claude,agents` writes both paths). See
550
576
  [`docs/vendor-paths.md`](docs/vendor-paths.md) for primary-source
551
577
  citations on each agent's read paths.
@@ -560,6 +586,7 @@ citations on each agent's read paths.
560
586
  | 3 | Disk error (cannot read or write a file/directory) |
561
587
  | 4 | Scope error (key lacks the required `registry:write` scope) |
562
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. |
563
590
 
564
591
  Pass `--verbose` to any command to print stack traces and retry
565
592
  attempts on failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.9.2",
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,
@@ -58,22 +58,39 @@ import { AGENT_REGISTRY } from "./agent-registry.mjs";
58
58
  * and are removed by the gitignore remover. User-authored lines outside
59
59
  * the section are never touched — this is the attribution mechanism.
60
60
  *
61
- * MUST stay in lockstep with `src/lib/mergers/gitignore.mjs`
62
- * SECTION_HEADER. The artifact-registry CI test asserts both modules
63
- * resolve the same header text.
61
+ * The section writer (`src/lib/gitignore-section.mjs`) imports this
62
+ * constant directly (#2361 consolidated the previously duplicated
63
+ * header definitions), so merger, sync pre-flight, and remover are in
64
+ * lockstep at the language level.
64
65
  */
65
66
  export const GITIGNORE_SECTION_HEADER =
66
67
  "# SkillRepo CLI (added by `skillrepo init`)";
67
68
 
68
69
  /**
69
- * Entries written under the gitignore section by init. The remover
70
- * does not match individual entries it removes every line between the
71
- * header and the next blank linebut the expected set is exported so
72
- * tests can verify the two lists stay in sync.
70
+ * Header of the RETIRED sync-time gitignore section (#2361). Before
71
+ * 4.10.0 the sync pre-flight wrote `.agents/skills/` under this
72
+ * separate header, outside the marked section aboveso the uninstall
73
+ * remover never cleaned it. The merger's heal migrates the shape into
74
+ * the marked section on every sync, and the remover strips a leftover
75
+ * legacy section on uninstall. Nothing writes this header anymore;
76
+ * the constant exists so heal and remover stay in lockstep on the
77
+ * exact historical text.
78
+ */
79
+ export const LEGACY_GITIGNORE_SECTION_HEADER =
80
+ "# SkillRepo (CLI-managed library skills)";
81
+
82
+ /**
83
+ * Entries the CLI may write under the gitignore section. Init writes
84
+ * all of them (subject to vendor targets); the sync-time pre-flight
85
+ * (`ensureManagedRootIgnores`) writes only the managed-root entries.
86
+ * The remover does not match individual entries — it removes every
87
+ * line between the header and the next blank line — but the expected
88
+ * set is exported so tests can verify the lists stay in sync.
73
89
  */
74
90
  export const GITIGNORE_REQUIRED_ENTRIES = Object.freeze([
75
91
  ".env.local",
76
92
  ".claude/skills/",
93
+ ".agents/skills/",
77
94
  ".claude/settings.local.json",
78
95
  ]);
79
96
 
@@ -346,9 +363,10 @@ export const ARTIFACT_REGISTRY = Object.freeze([
346
363
  id: "global-config-dir",
347
364
  scope: "global",
348
365
  kind: "directory",
349
- // `~/.claude/skillrepo/` — the parent dir of both config.json and
350
- // .last-sync. Whole-directory removal is correct because both
351
- // children are CLI-owned and there's no room for user content.
366
+ // `~/.claude/skillrepo/` — the parent dir of config.json,
367
+ // .last-sync, .npm-version-check, and .governance-seen (#2361).
368
+ // Whole-directory removal is correct because every child is
369
+ // CLI-owned and there's no room for user content.
352
370
  pathFn: () => join(homedir(), ".claude", "skillrepo"),
353
371
  displayPath: "~/.claude/skillrepo/",
354
372
  }),
@@ -37,6 +37,17 @@
37
37
  * and authorized regardless. Spoofing the params just affects UX
38
38
  * immediacy, not authorization.
39
39
  */
40
+ /**
41
+ * Repo-root skillset declaration file (#2361, design doc D6 amendment
42
+ * 2026-08-07). The committed, human-owned governance contract: the
43
+ * declaration lives at the repo root in `skillrepo.json`, nested under
44
+ * a `skillset` key. H4 ships only the gitignored-declaration warning
45
+ * (`foreign-content.mjs`); reading and resolving the declaration is
46
+ * H5 (#2362), which consumes the sub-schema recorded in the design
47
+ * doc's D6 note.
48
+ */
49
+ export const SKILLSET_DECLARATION_FILE = "skillrepo.json";
50
+
40
51
  const DEFAULT_CLI_AUTH_HOST = "https://skillrepo.dev";
41
52
  const CLI_AUTH_PATH = "/cli/auth";
42
53
  const CLI_AUTH_QUERY = "?source=cli&new=1";
@@ -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
  /**
@@ -56,7 +56,6 @@ import {
56
56
  } from "node:fs";
57
57
  import { dirname, join, isAbsolute, relative } from "node:path";
58
58
 
59
- import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
60
59
  import {
61
60
  claudeSkillsProject,
62
61
  claudeSkillsGlobal,
@@ -71,6 +70,7 @@ import {
71
70
  gitignorePath,
72
71
  } from "./paths.mjs";
73
72
  import { AGENT_REGISTRY, getAgentByKey } from "./agent-registry.mjs";
73
+ import { ensureManagedRootIgnores } from "./gitignore-section.mjs";
74
74
  import { CliError, validationError, diskError } from "./errors.mjs";
75
75
  import { platformConventions } from "./platform.mjs";
76
76
 
@@ -93,9 +93,6 @@ export const BLOCKED_EXTENSIONS = new Set([
93
93
  ".wasm",
94
94
  ]);
95
95
 
96
- const GITIGNORE_AGENTS_SKILLS_LINE = ".agents/skills/";
97
- const GITIGNORE_SKILLS_HEADER = "# SkillRepo (CLI-managed library skills)";
98
-
99
96
  // ── Types (JSDoc only — this is plain JS) ───────────────────────────────
100
97
 
101
98
  /**
@@ -148,16 +145,36 @@ const CONTROL_CHARS_GLOBAL = /[\u0000-\u001f\u007f-\u009f]/g;
148
145
  * \uXXXX-escaped, so the error string can never re-inject the characters
149
146
  * it rejects into terminal output or sync logs.
150
147
  *
148
+ * Exported (#2361 prod-readiness): the foreign-content warnings print
149
+ * on-disk directory names — filesystem-sourced, attacker-influenceable
150
+ * strings — to stderr, the exact injection class this helper exists
151
+ * for. Single source per the shared-logic rule.
152
+ *
151
153
  * @param {string} value
152
154
  * @returns {string}
153
155
  */
154
- function escapeControlChars(value) {
156
+ export function escapeControlChars(value) {
155
157
  return value.replace(
156
158
  CONTROL_CHARS_GLOBAL,
157
159
  (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
158
160
  );
159
161
  }
160
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
+
161
178
  /**
162
179
  * Validate a single file path inside a skill directory.
163
180
  *
@@ -417,16 +434,17 @@ export function writeSkillDir(skill, options = {}) {
417
434
 
418
435
  const targets = placementTargetsFor(options);
419
436
 
420
- // Pre-flight: if any target writes to .agents/skills/, ensure
421
- // .gitignore is set up before starting any writes. A failure here
422
- // is recoverable (user fixes their .gitignore and re-runs) because
423
- // nothing has hit disk yet. A failure here AFTER a successful
424
- // claudeProject write would leave the user with a half-applied
425
- // state and an error message that doesn't reflect what's actually
426
- // on disk.
427
- if (targets.includes("agentsProject")) {
428
- ensureAgentsGitignore();
429
- }
437
+ // Pre-flight: if any target writes to a project-scope managed root
438
+ // (.claude/skills/, .agents/skills/), ensure the root's blanket
439
+ // ignore is present in the CLI's marked .gitignore section before
440
+ // starting any writes (#2361 heal on every sync, both roots; the
441
+ // pre-4.10.0 code covered only .agents/skills/ under a separate
442
+ // header). A failure here is recoverable (user fixes their
443
+ // .gitignore and re-runs) because nothing has hit disk yet. A
444
+ // failure here AFTER a successful write to an earlier target would
445
+ // leave the user with a half-applied state and an error message
446
+ // that doesn't reflect what's actually on disk.
447
+ ensureProjectGitignore(targets);
430
448
 
431
449
  const written = [];
432
450
 
@@ -807,43 +825,25 @@ function writeSkillToDir(skill, targetDir) {
807
825
  }
808
826
 
809
827
  /**
810
- * Ensure `.agents/skills/` is gitignored. Idempotent skips if the
811
- * entry is already present. Creates .gitignore if missing.
828
+ * Ensure every project-scope managed root in the write set has its
829
+ * blanket ignore inside the CLI's marked .gitignore section (#2361).
830
+ * Idempotent; also heals the retired pre-4.10.0 sync-time section.
831
+ * No-op for global-only write sets.
832
+ *
833
+ * `ensureManagedRootIgnores` throws typed diskErrors on read/write
834
+ * failure so a read-only or symlinked .gitignore surfaces as a
835
+ * user-visible error instead of silent data loss; the belt-and-
836
+ * suspenders wrap here only converts a non-CliError escape.
812
837
  *
813
- * Throws diskError on any write failure so a read-only or symlinked
814
- * .gitignore surfaces as a user-visible error instead of silent data
815
- * loss.
838
+ * @param {PlacementTarget[]} targets
816
839
  */
817
- function ensureAgentsGitignore() {
818
- const filePath = gitignorePath();
819
- let existing = null;
820
- try {
821
- existing = readFileSafe(filePath);
822
- } catch (err) {
823
- throw diskError(`Cannot read ${filePath}: ${err.message}`, {
824
- cause: err,
825
- hint:
826
- `Update your .gitignore manually to add ${GITIGNORE_AGENTS_SKILLS_LINE} before re-running.`,
827
- });
828
- }
829
-
830
- if (existing !== null && existing.includes(GITIGNORE_AGENTS_SKILLS_LINE)) {
831
- return; // Already present — no-op
832
- }
833
-
834
- const newContent =
835
- existing === null
836
- ? `${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_AGENTS_SKILLS_LINE}\n`
837
- : `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_AGENTS_SKILLS_LINE}\n`;
838
-
840
+ function ensureProjectGitignore(targets) {
839
841
  try {
840
- writeFileSafe(filePath, newContent);
842
+ ensureManagedRootIgnores(targets);
841
843
  } catch (err) {
842
- throw diskError(`Cannot update ${filePath}: ${err.message}`, {
844
+ if (err instanceof CliError) throw err;
845
+ throw diskError(`Cannot update ${gitignorePath()}: ${err.message}`, {
843
846
  cause: err,
844
- hint:
845
- `The CLI needs to add ${GITIGNORE_AGENTS_SKILLS_LINE} to .gitignore so library skills don't get committed. ` +
846
- "Update .gitignore manually and re-run, or remove the read-only/symlinked constraint.",
847
847
  });
848
848
  }
849
849
  }