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 +30 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +18 -4
- package/src/lib/artifact-registry.mjs +28 -10
- package/src/lib/constants.mjs +11 -0
- package/src/lib/errors.mjs +23 -0
- package/src/lib/file-write.mjs +47 -47
- package/src/lib/foreign-content.mjs +461 -0
- package/src/lib/gitignore-section.mjs +223 -0
- package/src/lib/mergers/gitignore.mjs +22 -65
- package/src/lib/paths.mjs +11 -0
- package/src/lib/removers/gitignore.mjs +38 -3
- package/src/lib/skillset-declaration.mjs +477 -0
- package/src/lib/sync.mjs +179 -0
- package/src/test/commands/add.test.mjs +24 -0
- package/src/test/commands/get.test.mjs +24 -0
- package/src/test/commands/init.test.mjs +37 -0
- package/src/test/commands/update.test.mjs +194 -0
- package/src/test/e2e/cli-commands.test.mjs +63 -0
- package/src/test/lib/errors.test.mjs +28 -0
- package/src/test/lib/file-write.test.mjs +1 -1
- package/src/test/lib/foreign-content.test.mjs +522 -0
- package/src/test/lib/gitignore-section.test.mjs +213 -0
- package/src/test/lib/skillset-declaration.test.mjs +514 -0
- package/src/test/lib/sync.test.mjs +635 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +65 -0
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
|
|
547
|
-
|
|
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
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* header
|
|
72
|
-
*
|
|
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 above — so 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
|
|
350
|
-
// .last-sync.
|
|
351
|
-
//
|
|
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
|
}),
|
package/src/lib/constants.mjs
CHANGED
|
@@ -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";
|
package/src/lib/errors.mjs
CHANGED
|
@@ -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
|
/**
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -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
|
|
421
|
-
// .
|
|
422
|
-
// is
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
811
|
-
*
|
|
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
|
-
*
|
|
814
|
-
* .gitignore surfaces as a user-visible error instead of silent data
|
|
815
|
-
* loss.
|
|
838
|
+
* @param {PlacementTarget[]} targets
|
|
816
839
|
*/
|
|
817
|
-
function
|
|
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
|
-
|
|
842
|
+
ensureManagedRootIgnores(targets);
|
|
841
843
|
} catch (err) {
|
|
842
|
-
|
|
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
|
}
|