skillrepo 4.9.2 → 4.10.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 +17 -3
- package/package.json +1 -1
- package/src/lib/artifact-registry.mjs +28 -10
- package/src/lib/constants.mjs +11 -0
- package/src/lib/file-write.mjs +32 -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/sync.mjs +117 -0
- package/src/test/commands/update.test.mjs +51 -0
- package/src/test/e2e/cli-commands.test.mjs +63 -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/sync.test.mjs +349 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +65 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreign-content detection for managed vendor skill roots (#2361).
|
|
3
|
+
*
|
|
4
|
+
* Governance doctrine (design doc D7 amendment, 2026-08-07): a skill
|
|
5
|
+
* reaching an agent without going through the library — or, post-H6,
|
|
6
|
+
* the repo's skillset — is a compliance VIOLATION, not a workflow to
|
|
7
|
+
* accommodate. Vendors load skills from DISK, so gitignore posture is
|
|
8
|
+
* repo cosmetics; disk presence is the compliance fact. This module
|
|
9
|
+
* detects that fact at sync time and surfaces it. It never deletes,
|
|
10
|
+
* moves, or rewrites anything — compliance is enforced by surfacing,
|
|
11
|
+
* never by destroying a developer's work (#2108's invariant).
|
|
12
|
+
*
|
|
13
|
+
* WHAT COUNTS AS FOREIGN: a directory inside a managed placement root
|
|
14
|
+
* whose name has no entry in the `.last-sync` per-skill manifest — the
|
|
15
|
+
* CLI has no record of writing it. Classification is by NAME, matching
|
|
16
|
+
* #2108's manifest model:
|
|
17
|
+
*
|
|
18
|
+
* - Manifest-listed dir with modified content is NOT foreign — that
|
|
19
|
+
* is drift ("edited", `drift.mjs`), a different axis (#2111 cat 1
|
|
20
|
+
* territory), and `list` already surfaces it.
|
|
21
|
+
* - Loose FILES directly in a root are ignored: vendors load skills
|
|
22
|
+
* as directories containing SKILL.md, so a stray file never
|
|
23
|
+
* reaches an agent — it is clutter, not a violation.
|
|
24
|
+
* - `.tmp` / `.old` orphan dirs are the CLI's own crashed-write
|
|
25
|
+
* artifacts (`cleanupOrphans`), not foreign content.
|
|
26
|
+
* - Symlinks are skipped, mirroring `placement-walk.mjs`.
|
|
27
|
+
*
|
|
28
|
+
* KNOWN LIMIT (documented for the H7 seam): `.last-sync` is
|
|
29
|
+
* machine-global (D12 gives per-repo state to H5), so a hand-authored
|
|
30
|
+
* dir whose name collides with a library skill synced in a DIFFERENT
|
|
31
|
+
* repo classifies as managed-not-foreign here. The content-hash axis
|
|
32
|
+
* (drift/edited) still flags it. Revisit when per-repo state lands.
|
|
33
|
+
*
|
|
34
|
+
* RECEIPTS SEAM (H7 #2364, aligned with #2111's violation categories):
|
|
35
|
+
* `receiptViolationSummary` is the ONLY shape intended to leave the
|
|
36
|
+
* machine — counts and category, NEVER foreign dir names. Out-of-band
|
|
37
|
+
* names are not the team's library content; exfiltrating them by
|
|
38
|
+
* telemetry is #2111's explicitly rejected posture (its "unmanaged
|
|
39
|
+
* skills" category is team-level opt-in precisely because of this).
|
|
40
|
+
* The developer-facing warnings (`formatForeignWarnings`) keep the
|
|
41
|
+
* names LOCAL — stdout/stderr transparency, mirroring #2111's
|
|
42
|
+
* developer-transparency rule.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
existsSync,
|
|
47
|
+
readdirSync,
|
|
48
|
+
lstatSync,
|
|
49
|
+
readFileSync,
|
|
50
|
+
realpathSync,
|
|
51
|
+
} from "node:fs";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { spawnSync } from "node:child_process";
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
placementTargetsFor,
|
|
57
|
+
resolvePlacementRoot,
|
|
58
|
+
describePlacementTarget,
|
|
59
|
+
escapeControlChars,
|
|
60
|
+
} from "./file-write.mjs";
|
|
61
|
+
import { writeFileAtomic } from "./fs-utils.mjs";
|
|
62
|
+
import { globalGovernanceSeenPath } from "./paths.mjs";
|
|
63
|
+
import { SKILLSET_DECLARATION_FILE } from "./constants.mjs";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Receipt category identifier for this violation class. H7's receipt
|
|
67
|
+
* schema consumes it alongside #2111's categories; the string is the
|
|
68
|
+
* cross-layer contract, so treat renames as a wire-format change.
|
|
69
|
+
*
|
|
70
|
+
* Vocabulary note: the design doc's D7 amendment calls the DETECTION
|
|
71
|
+
* "foreign content" (this module's internal naming), but the category
|
|
72
|
+
* string follows #2111's established violation vocabulary — its
|
|
73
|
+
* category 2 is "Unmanaged skills" — which is also what the
|
|
74
|
+
* user-facing warning copy says. One wire word, one user word.
|
|
75
|
+
*/
|
|
76
|
+
export const UNMANAGED_CONTENT_CATEGORY = "unmanaged";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} ForeignRootResult
|
|
80
|
+
* @property {string} target - PlacementTarget scanned.
|
|
81
|
+
* @property {string} root - Absolute root directory.
|
|
82
|
+
* @property {string} displayRoot - Human-readable root (e.g. ".claude/skills/").
|
|
83
|
+
* @property {string[]} foreignDirs - Foreign directory names (sorted).
|
|
84
|
+
*
|
|
85
|
+
* @typedef {Object} ForeignScanResult
|
|
86
|
+
* @property {ForeignRootResult[]} roots - One entry per scanned root
|
|
87
|
+
* (unique targets only; roots that don't exist scan empty).
|
|
88
|
+
* @property {number} foreignCount - Total foreign dirs across roots.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan the placement roots of the current sync's write set for
|
|
93
|
+
* directories the CLI did not write.
|
|
94
|
+
*
|
|
95
|
+
* Read-only and best-effort: an unreadable root or entry is skipped —
|
|
96
|
+
* detection must never break a sync. Scans exactly the targets the
|
|
97
|
+
* sync writes to (project or global per the caller's scope): those are
|
|
98
|
+
* the roots this sync manages. Personal-scope roots are therefore
|
|
99
|
+
* scanned only under `--global` — broader personal-scope disclosure
|
|
100
|
+
* belongs to D20's dedicated work (#2495), not here.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} options
|
|
103
|
+
* @param {string[]} [options.vendors] - Vendor keys for the current sync.
|
|
104
|
+
* @param {boolean} [options.global]
|
|
105
|
+
* @param {Record<string, unknown> | null | undefined} options.managedSkills
|
|
106
|
+
* The `.last-sync` `skills` map (post-sync state) — keys are
|
|
107
|
+
* `"<owner>/<name>"`. Only the name half is used: placement
|
|
108
|
+
* dirs are keyed by skill name alone.
|
|
109
|
+
* @returns {ForeignScanResult}
|
|
110
|
+
*/
|
|
111
|
+
export function scanForeignContent({ vendors, global, managedSkills }) {
|
|
112
|
+
/** @type {ForeignScanResult} */
|
|
113
|
+
const result = { roots: [], foreignCount: 0 };
|
|
114
|
+
|
|
115
|
+
let targets;
|
|
116
|
+
try {
|
|
117
|
+
targets = placementTargetsFor({ vendors, global: global === true });
|
|
118
|
+
} catch {
|
|
119
|
+
// Invalid vendor/scope combos are surfaced by the sync itself —
|
|
120
|
+
// detection just declines to scan.
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const managedNames = new Set();
|
|
125
|
+
if (managedSkills && typeof managedSkills === "object") {
|
|
126
|
+
for (const key of Object.keys(managedSkills)) {
|
|
127
|
+
const slashAt = key.indexOf("/");
|
|
128
|
+
if (slashAt < 0) continue;
|
|
129
|
+
const name = key.slice(slashAt + 1);
|
|
130
|
+
if (name) managedNames.add(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const target of targets) {
|
|
135
|
+
let root;
|
|
136
|
+
try {
|
|
137
|
+
// Normalized (realpath + win32 case-fold) because `root` doubles
|
|
138
|
+
// as the seen-state key — see normalizeStateKey. The D12 sibling
|
|
139
|
+
// state store already uses realpath-keyed state; same reasoning.
|
|
140
|
+
root = normalizeStateKey(resolvePlacementRoot(target));
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const rootResult = {
|
|
145
|
+
target,
|
|
146
|
+
root,
|
|
147
|
+
displayRoot: describePlacementTarget(target),
|
|
148
|
+
foreignDirs: [],
|
|
149
|
+
};
|
|
150
|
+
result.roots.push(rootResult);
|
|
151
|
+
|
|
152
|
+
if (!existsSync(root)) continue;
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = readdirSync(root);
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (entry.endsWith(".tmp") || entry.endsWith(".old")) continue;
|
|
161
|
+
if (managedNames.has(entry)) continue;
|
|
162
|
+
let stat;
|
|
163
|
+
try {
|
|
164
|
+
stat = lstatSync(join(root, entry));
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
169
|
+
rootResult.foreignDirs.push(entry);
|
|
170
|
+
}
|
|
171
|
+
rootResult.foreignDirs.sort();
|
|
172
|
+
result.foreignCount += rootResult.foreignDirs.length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One warning line per foreign dir. Copy standard (owner directive,
|
|
180
|
+
* 2026-08-07): every warning answers WHY you are seeing it and WHAT to
|
|
181
|
+
* do about it. Sentence case, no exclamation marks, per the copy
|
|
182
|
+
* rules. The names stay on this machine — see the module docstring's
|
|
183
|
+
* privacy contract.
|
|
184
|
+
*
|
|
185
|
+
* @param {ForeignScanResult} scan
|
|
186
|
+
* @returns {string[]}
|
|
187
|
+
*/
|
|
188
|
+
export function formatForeignWarnings(scan) {
|
|
189
|
+
const lines = [];
|
|
190
|
+
for (const rootResult of scan.roots) {
|
|
191
|
+
for (const dir of rootResult.foreignDirs) {
|
|
192
|
+
// Directory names come from readdirSync — filesystem-sourced and
|
|
193
|
+
// attacker-influenceable (a cloned repo can carry any byte except
|
|
194
|
+
// NUL and "/"). Escape control characters so a crafted name can
|
|
195
|
+
// never inject terminal escapes through the warning, matching the
|
|
196
|
+
// server-path treatment in file-write.mjs (#2402 class).
|
|
197
|
+
const safeDir = escapeControlChars(dir);
|
|
198
|
+
const displayPath = `${rootResult.displayRoot}${safeDir}`;
|
|
199
|
+
// The remediation must be a RUNNABLE command. A tilde-shortened
|
|
200
|
+
// display root ("~/.claude/skills/") breaks copy-paste — quotes
|
|
201
|
+
// suppress shell tilde expansion and Windows cmd never expands
|
|
202
|
+
// "~" — so global-scope findings print the absolute path.
|
|
203
|
+
const commandPath = rootResult.displayRoot.startsWith("~")
|
|
204
|
+
? join(rootResult.root, safeDir)
|
|
205
|
+
: displayPath;
|
|
206
|
+
lines.push(
|
|
207
|
+
` warning: ${displayPath}/ is not from your library, but agents still ` +
|
|
208
|
+
`load it from disk. Run 'skillrepo push ${commandPath}' to bring it ` +
|
|
209
|
+
`into your library, or remove the directory.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return lines;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Warn-on-new state (#2361 owner directive: no repeat warnings) ──────
|
|
217
|
+
//
|
|
218
|
+
// A warning that repeats unchanged findings on every sync trains users
|
|
219
|
+
// to ignore it (#2111's own anti-nag rule, applied locally). The CLI
|
|
220
|
+
// persists what it has already warned about in a small machine-global
|
|
221
|
+
// state file and warns only about NEW findings. The state lives at
|
|
222
|
+
// `~/.claude/skillrepo/.governance-seen`, keyed by ABSOLUTE root path —
|
|
223
|
+
// two repos with same-named unmanaged dirs warn independently. Dirs
|
|
224
|
+
// that disappear are pruned from the state, so a dir that is removed
|
|
225
|
+
// and later reappears warns again (a state CHANGE in both directions
|
|
226
|
+
// re-arms the warning).
|
|
227
|
+
//
|
|
228
|
+
// Privacy: the file stores unmanaged dir names LOCALLY, exactly like
|
|
229
|
+
// `.last-sync` stores library skill names. Nothing from it is ever
|
|
230
|
+
// serialized upstream; the receipts seam remains counts/category only.
|
|
231
|
+
//
|
|
232
|
+
// All IO is best-effort: an unreadable/corrupt state file reads as
|
|
233
|
+
// empty (warn about everything — the loud-but-safe direction), and a
|
|
234
|
+
// failed write means the next sync warns again rather than crashing.
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @typedef {Object} GovernanceSeenState
|
|
238
|
+
* @property {number} schemaVersion
|
|
239
|
+
* @property {Record<string, string[]>} roots - abs root path → dir names
|
|
240
|
+
* already warned about (sorted).
|
|
241
|
+
* @property {Record<string, boolean>} declarations - abs repo path →
|
|
242
|
+
* true when the gitignored-declaration warning was already
|
|
243
|
+
* shown and the declaration is still gitignored.
|
|
244
|
+
*/
|
|
245
|
+
|
|
246
|
+
/** @returns {GovernanceSeenState} */
|
|
247
|
+
function emptySeenState() {
|
|
248
|
+
return { schemaVersion: 1, roots: {}, declarations: {} };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @returns {GovernanceSeenState} */
|
|
252
|
+
export function readGovernanceSeen() {
|
|
253
|
+
try {
|
|
254
|
+
const raw = readFileSync(globalGovernanceSeenPath(), "utf-8");
|
|
255
|
+
const parsed = JSON.parse(raw);
|
|
256
|
+
// MAINTAINER: unknown schemaVersion reads as empty (warn-everything,
|
|
257
|
+
// the loud-but-safe direction) and the next write replaces the file
|
|
258
|
+
// at the current schema. A future v2 that must PRESERVE v1 data
|
|
259
|
+
// needs an explicit accept-in-place branch here, the way
|
|
260
|
+
// sync.mjs's readLastSync accepts its immediately-prior schema.
|
|
261
|
+
if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1) {
|
|
262
|
+
return emptySeenState();
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
schemaVersion: 1,
|
|
266
|
+
roots:
|
|
267
|
+
parsed.roots && typeof parsed.roots === "object" && !Array.isArray(parsed.roots)
|
|
268
|
+
? parsed.roots
|
|
269
|
+
: {},
|
|
270
|
+
declarations:
|
|
271
|
+
parsed.declarations &&
|
|
272
|
+
typeof parsed.declarations === "object" &&
|
|
273
|
+
!Array.isArray(parsed.declarations)
|
|
274
|
+
? parsed.declarations
|
|
275
|
+
: {},
|
|
276
|
+
};
|
|
277
|
+
} catch {
|
|
278
|
+
return emptySeenState();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Best-effort persist. A write failure is swallowed — the cost is one
|
|
284
|
+
* repeated warning on the next sync, never a sync failure.
|
|
285
|
+
*
|
|
286
|
+
* Callers MUST go through `commitGovernanceSeen` for sync-time updates
|
|
287
|
+
* — it re-reads the file and merges only the caller's own keys, so a
|
|
288
|
+
* concurrent sync in ANOTHER repo (distinct keys) can't have its state
|
|
289
|
+
* clobbered by a snapshot taken before it wrote (a full-object
|
|
290
|
+
* overwrite from a stale snapshot could silently RESTORE a pruned
|
|
291
|
+
* entry and suppress a documented re-warn — the prod-readiness
|
|
292
|
+
* architect traced this). writeFileAtomic rules out torn files.
|
|
293
|
+
*
|
|
294
|
+
* RESIDUAL (known, tracked): two interactive syncs racing on the SAME
|
|
295
|
+
* key — same repo twice, or two repos sharing a --global home root —
|
|
296
|
+
* still last-write-wins on that key, and the later writer's scan may
|
|
297
|
+
* be the staler one, which CAN revive a pruned entry and suppress a
|
|
298
|
+
* later re-warn for that key. Requires two concurrent NON-hook syncs
|
|
299
|
+
* of one root (hook syncs never write here); the numeric `unmanaged`
|
|
300
|
+
* count is never affected (it is computed from the live scan, before
|
|
301
|
+
* any seen-state filtering). See the follow-up issue on the epic.
|
|
302
|
+
*
|
|
303
|
+
* @param {GovernanceSeenState} state
|
|
304
|
+
*/
|
|
305
|
+
export function writeGovernanceSeen(state) {
|
|
306
|
+
try {
|
|
307
|
+
writeFileAtomic(
|
|
308
|
+
globalGovernanceSeenPath(),
|
|
309
|
+
JSON.stringify(state, null, 2) + "\n",
|
|
310
|
+
);
|
|
311
|
+
} catch {
|
|
312
|
+
// Best-effort by design.
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Select the findings the user has NOT been warned about yet. Pure —
|
|
318
|
+
* no IO. Seen-state bookkeeping lives in `commitGovernanceSeen`.
|
|
319
|
+
*
|
|
320
|
+
* @param {ForeignScanResult} scan
|
|
321
|
+
* @param {GovernanceSeenState} seen
|
|
322
|
+
* @returns {ForeignScanResult} Mirrors the scan shape but carries only
|
|
323
|
+
* unseen dirs; `foreignCount` counts only the new ones.
|
|
324
|
+
*/
|
|
325
|
+
export function selectNewFindings(scan, seen) {
|
|
326
|
+
/** @type {ForeignScanResult} */
|
|
327
|
+
const newScan = { roots: [], foreignCount: 0 };
|
|
328
|
+
for (const rootResult of scan?.roots ?? []) {
|
|
329
|
+
const prior = new Set(
|
|
330
|
+
Array.isArray(seen.roots[rootResult.root]) ? seen.roots[rootResult.root] : [],
|
|
331
|
+
);
|
|
332
|
+
const fresh = rootResult.foreignDirs.filter((d) => !prior.has(d));
|
|
333
|
+
newScan.roots.push({ ...rootResult, foreignDirs: fresh });
|
|
334
|
+
newScan.foreignCount += fresh.length;
|
|
335
|
+
}
|
|
336
|
+
return newScan;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Normalize a path for use as a seen-state key: resolve symlinks
|
|
341
|
+
* (realpath) so two spellings of one directory share a key, and
|
|
342
|
+
* case-fold on Windows where the filesystem is case-insensitive but
|
|
343
|
+
* `process.cwd()` preserves whatever casing the shell used. Falls back
|
|
344
|
+
* to the raw path when realpath fails (root doesn't exist yet).
|
|
345
|
+
*
|
|
346
|
+
* @param {string} p
|
|
347
|
+
* @returns {string}
|
|
348
|
+
*/
|
|
349
|
+
export function normalizeStateKey(p) {
|
|
350
|
+
let resolved = p;
|
|
351
|
+
try {
|
|
352
|
+
resolved = realpathSync(p);
|
|
353
|
+
} catch {
|
|
354
|
+
// Root not created yet — the raw absolute path is still stable.
|
|
355
|
+
}
|
|
356
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Commit this sync's observations into the seen-state file with a
|
|
361
|
+
* NARROW merge: re-read the file and rewrite only the keys this sync
|
|
362
|
+
* actually scanned (its roots) or owns (this repo's declaration flag).
|
|
363
|
+
* Keys belonging to other repos/scopes pass through from the FRESH
|
|
364
|
+
* read — never from a snapshot taken before the scan — so a concurrent
|
|
365
|
+
* sync elsewhere keeps its update even if this process writes last.
|
|
366
|
+
*
|
|
367
|
+
* Replace-not-merge per scanned root: dirs that disappeared are
|
|
368
|
+
* pruned, so a removed-then-reappearing dir is a state change that
|
|
369
|
+
* warns again. A clean root deletes its key.
|
|
370
|
+
*
|
|
371
|
+
* @param {object} options
|
|
372
|
+
* @param {ForeignScanResult} options.scan - The FULL scan (all dirs
|
|
373
|
+
* currently on disk, not just the newly-warned ones).
|
|
374
|
+
* @param {string} options.repoKey - Normalized repo path (declaration
|
|
375
|
+
* flag owner).
|
|
376
|
+
* @param {boolean} options.declarationIgnored - Whether the declaration
|
|
377
|
+
* file currently exists AND is gitignored.
|
|
378
|
+
*/
|
|
379
|
+
export function commitGovernanceSeen({ scan, repoKey, declarationIgnored }) {
|
|
380
|
+
const fresh = readGovernanceSeen();
|
|
381
|
+
const roots = { ...fresh.roots };
|
|
382
|
+
for (const rootResult of scan?.roots ?? []) {
|
|
383
|
+
if (rootResult.foreignDirs.length > 0) {
|
|
384
|
+
roots[rootResult.root] = [...rootResult.foreignDirs];
|
|
385
|
+
} else {
|
|
386
|
+
delete roots[rootResult.root];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const declarations = { ...fresh.declarations };
|
|
390
|
+
if (declarationIgnored) {
|
|
391
|
+
declarations[repoKey] = true;
|
|
392
|
+
} else {
|
|
393
|
+
delete declarations[repoKey];
|
|
394
|
+
}
|
|
395
|
+
writeGovernanceSeen({ schemaVersion: 1, roots, declarations });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The upstream-safe projection of a scan (#2111 privacy posture):
|
|
400
|
+
* category and count only, NEVER names. H7 (#2364) consumes this when
|
|
401
|
+
* wiring the receipts violation section; nothing else about the scan
|
|
402
|
+
* result may be serialized off the machine.
|
|
403
|
+
*
|
|
404
|
+
* @param {ForeignScanResult} scan
|
|
405
|
+
* @returns {{ category: string, count: number }}
|
|
406
|
+
*/
|
|
407
|
+
export function receiptViolationSummary(scan) {
|
|
408
|
+
return {
|
|
409
|
+
category: UNMANAGED_CONTENT_CATEGORY,
|
|
410
|
+
count: scan?.foreignCount ?? 0,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Check whether the repo's skillset declaration file exists but is
|
|
416
|
+
* gitignored (#2361, D6). The declaration (`skillrepo.json`) is the
|
|
417
|
+
* committed, human-owned governance contract — a gitignored copy is
|
|
418
|
+
* out-of-band governance state: it steers this machine while never
|
|
419
|
+
* reaching the repository.
|
|
420
|
+
*
|
|
421
|
+
* Best-effort probe via `git check-ignore`. Silent no-signal when git
|
|
422
|
+
* is absent, the CLI is not inside a work tree, or the spawn fails —
|
|
423
|
+
* a missing git can never break or slow a sync (matches the
|
|
424
|
+
* warn-only, git-optional posture this issue inherited from D7).
|
|
425
|
+
*
|
|
426
|
+
* @param {object} [options]
|
|
427
|
+
* @param {string} [options.cwd] - Test seam; defaults to process.cwd().
|
|
428
|
+
* @returns {{ present: boolean, ignored: boolean }}
|
|
429
|
+
*/
|
|
430
|
+
export function checkDeclarationGitignored({ cwd = process.cwd() } = {}) {
|
|
431
|
+
if (!existsSync(join(cwd, SKILLSET_DECLARATION_FILE))) {
|
|
432
|
+
return { present: false, ignored: false };
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const probe = spawnSync(
|
|
436
|
+
"git",
|
|
437
|
+
["check-ignore", "-q", "--", SKILLSET_DECLARATION_FILE],
|
|
438
|
+
{ cwd, stdio: "ignore", windowsHide: true },
|
|
439
|
+
);
|
|
440
|
+
// status 0 = ignored; 1 = not ignored; 128 = not a git work tree.
|
|
441
|
+
// `probe.error` (e.g. ENOENT, git not installed) → no signal.
|
|
442
|
+
return { present: true, ignored: !probe.error && probe.status === 0 };
|
|
443
|
+
} catch {
|
|
444
|
+
return { present: true, ignored: false };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The one-line warning for a gitignored declaration. Same copy
|
|
450
|
+
* standard: why you are seeing it, what to do about it.
|
|
451
|
+
*
|
|
452
|
+
* @returns {string}
|
|
453
|
+
*/
|
|
454
|
+
export function formatDeclarationIgnoredWarning() {
|
|
455
|
+
return (
|
|
456
|
+
` warning: ${SKILLSET_DECLARATION_FILE} is gitignored, so this repo's ` +
|
|
457
|
+
`skillset declaration only affects your machine and never reaches the ` +
|
|
458
|
+
`repository. Remove it from .gitignore and commit it so it applies to ` +
|
|
459
|
+
`everyone who works here.`
|
|
460
|
+
);
|
|
461
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core writer for the CLI's marked .gitignore section (#2361).
|
|
3
|
+
*
|
|
4
|
+
* ONE section, one header (`GITIGNORE_SECTION_HEADER` from the artifact
|
|
5
|
+
* registry). Every line the CLI adds to a project's .gitignore lives
|
|
6
|
+
* inside it; the uninstall remover strips exactly that section. Shared
|
|
7
|
+
* by the init-time merger (`mergers/gitignore.mjs`) and the sync-time
|
|
8
|
+
* pre-flight in `file-write.mjs` — a lib-level module with no
|
|
9
|
+
* `file-write.mjs` dependency, so both can import it without a cycle.
|
|
10
|
+
*
|
|
11
|
+
* Managed skill roots are OWNED SPACE (design doc D7 amendment,
|
|
12
|
+
* 2026-08-07): the CLI keeps simple blanket ignores for them. Content
|
|
13
|
+
* in those roots that the CLI did not write is a governance violation
|
|
14
|
+
* surfaced by the sync-time foreign-content scan (`foreign-content.mjs`)
|
|
15
|
+
* — never something the ignore machinery accommodates.
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: entries already present anywhere in the file are skipped;
|
|
18
|
+
* missing entries are inserted INTO the existing marked section when one
|
|
19
|
+
* exists (never a duplicate header), else appended as a new section.
|
|
20
|
+
* Every call also heals the retired pre-4.10.0 sync-time section shape
|
|
21
|
+
* (see `healLegacySection`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
|
|
25
|
+
import { gitignorePath } from "./paths.mjs";
|
|
26
|
+
import { diskError } from "./errors.mjs";
|
|
27
|
+
import {
|
|
28
|
+
GITIGNORE_SECTION_HEADER,
|
|
29
|
+
LEGACY_GITIGNORE_SECTION_HEADER,
|
|
30
|
+
} from "./artifact-registry.mjs";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The manual-recovery hint attached to every gitignore disk error the
|
|
34
|
+
* section writer throws. One string so the read and write branches
|
|
35
|
+
* never drift.
|
|
36
|
+
*/
|
|
37
|
+
const GITIGNORE_HINT =
|
|
38
|
+
"The CLI needs to gitignore its managed skill directories " +
|
|
39
|
+
"(.claude/skills/, .agents/skills/) so library skills don't get committed. " +
|
|
40
|
+
"Update .gitignore manually and re-run, or remove the read-only/symlinked constraint.";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Blanket ignore line for each project-scope managed skill root. Global
|
|
44
|
+
* targets have no entry — a personal-scope root is not repo content.
|
|
45
|
+
*/
|
|
46
|
+
export const MANAGED_ROOT_ENTRIES = Object.freeze({
|
|
47
|
+
claudeProject: ".claude/skills/",
|
|
48
|
+
agentsProject: ".agents/skills/",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sync-time pre-flight (#2361): ensure the blanket ignore for every
|
|
53
|
+
* project-scope managed root the current write set touches, inside the
|
|
54
|
+
* CLI's marked section. Runs on every sync/write (heal): a repo that
|
|
55
|
+
* never ran `init` still gets its managed roots ignored on first sync,
|
|
56
|
+
* and the legacy sync-time section is migrated into the marked section.
|
|
57
|
+
*
|
|
58
|
+
* Global-only write sets are a no-op — personal-scope roots are not
|
|
59
|
+
* repo content, so there is nothing to ignore.
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} targets - PlacementTarget values for the current write.
|
|
62
|
+
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
export function ensureManagedRootIgnores(targets) {
|
|
65
|
+
const entries = [];
|
|
66
|
+
if (Array.isArray(targets)) {
|
|
67
|
+
for (const [target, entry] of Object.entries(MANAGED_ROOT_ENTRIES)) {
|
|
68
|
+
if (targets.includes(target)) entries.push(entry);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
return { path: ".gitignore", action: "skipped", added: [] };
|
|
73
|
+
}
|
|
74
|
+
return ensureGitignoreEntries(entries);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Core ensure: required entries end up line-exact in the file, inside
|
|
79
|
+
* the CLI's marked section, with the legacy sync-time section healed
|
|
80
|
+
* away. Single writer for every gitignore mutation the CLI performs.
|
|
81
|
+
*
|
|
82
|
+
* Returns `"skipped"` if every required entry is already present and no
|
|
83
|
+
* legacy section needed healing, `"created"` if the file didn't exist,
|
|
84
|
+
* `"updated"` otherwise.
|
|
85
|
+
*
|
|
86
|
+
* @param {string[]} requiredEntries
|
|
87
|
+
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
|
88
|
+
*/
|
|
89
|
+
export function ensureGitignoreEntries(requiredEntries) {
|
|
90
|
+
const filePath = gitignorePath();
|
|
91
|
+
let existing;
|
|
92
|
+
try {
|
|
93
|
+
existing = readFileSafe(filePath);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// readFileSafe returns null on ENOENT and re-throws everything
|
|
96
|
+
// else (EISDIR, EACCES, ...). Surface as a typed diskError with a
|
|
97
|
+
// "Cannot read" message so callers abort BEFORE any skill write.
|
|
98
|
+
throw diskError(`Cannot read ${filePath}: ${err.message}`, {
|
|
99
|
+
cause: err,
|
|
100
|
+
hint: GITIGNORE_HINT,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (existing === null) {
|
|
105
|
+
// Fresh .gitignore — write all required entries as one section.
|
|
106
|
+
const content =
|
|
107
|
+
GITIGNORE_SECTION_HEADER + "\n" + requiredEntries.join("\n") + "\n";
|
|
108
|
+
writeSectionFile(filePath, content);
|
|
109
|
+
return {
|
|
110
|
+
path: ".gitignore",
|
|
111
|
+
action: "created",
|
|
112
|
+
added: [...requiredEntries],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Preserve the original line-ending style throughout.
|
|
117
|
+
const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
118
|
+
|
|
119
|
+
const { lines, healed } = healLegacySection(existing.split(/\r?\n/));
|
|
120
|
+
|
|
121
|
+
// Line-exact match check. `.env.local` as a line is distinct from
|
|
122
|
+
// `.env.local.backup` or a comment `# .env.local` — compare trimmed
|
|
123
|
+
// lines so we match the entry literally.
|
|
124
|
+
const trimmed = lines.map((l) => l.trim());
|
|
125
|
+
const missing = requiredEntries.filter((entry) => !trimmed.includes(entry));
|
|
126
|
+
|
|
127
|
+
if (missing.length === 0 && !healed) {
|
|
128
|
+
return {
|
|
129
|
+
path: ".gitignore",
|
|
130
|
+
action: "skipped",
|
|
131
|
+
added: [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let outLines = lines;
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
const headerIdx = lines.findIndex(
|
|
138
|
+
(l) => l.trim() === GITIGNORE_SECTION_HEADER,
|
|
139
|
+
);
|
|
140
|
+
if (headerIdx !== -1) {
|
|
141
|
+
// Insert missing entries at the end of the existing marked
|
|
142
|
+
// section (header + following non-blank lines) — never a second
|
|
143
|
+
// header. This is what keeps the uninstall remover's "strip the
|
|
144
|
+
// section" contract complete.
|
|
145
|
+
let end = headerIdx + 1;
|
|
146
|
+
while (end < lines.length && lines[end].trim() !== "") {
|
|
147
|
+
end += 1;
|
|
148
|
+
}
|
|
149
|
+
outLines = [...lines.slice(0, end), ...missing, ...lines.slice(end)];
|
|
150
|
+
} else {
|
|
151
|
+
// No marked section yet — append one, separated by a blank line.
|
|
152
|
+
// Drop trailing empty lines from the split so the separator is
|
|
153
|
+
// exactly one blank line regardless of the file's tail shape.
|
|
154
|
+
let tail = lines.length;
|
|
155
|
+
while (tail > 0 && lines[tail - 1] === "") tail -= 1;
|
|
156
|
+
outLines = [
|
|
157
|
+
...lines.slice(0, tail),
|
|
158
|
+
"",
|
|
159
|
+
GITIGNORE_SECTION_HEADER,
|
|
160
|
+
...missing,
|
|
161
|
+
"",
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
writeSectionFile(filePath, outLines.join(lineEnding));
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
path: ".gitignore",
|
|
170
|
+
action: "updated",
|
|
171
|
+
added: missing,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Write the file, converting a raw fs failure (read-only file,
|
|
177
|
+
* symlinked target, EACCES) into a typed diskError with the shared
|
|
178
|
+
* recovery hint.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} filePath
|
|
181
|
+
* @param {string} content
|
|
182
|
+
*/
|
|
183
|
+
function writeSectionFile(filePath, content) {
|
|
184
|
+
try {
|
|
185
|
+
writeFileSafe(filePath, content);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
throw diskError(`Cannot update ${filePath}: ${err.message}`, {
|
|
188
|
+
cause: err,
|
|
189
|
+
hint: GITIGNORE_HINT,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Remove the legacy sync-time section shape from a line array. The
|
|
196
|
+
* pre-#2361 sync pre-flight wrote exactly:
|
|
197
|
+
*
|
|
198
|
+
* # SkillRepo (CLI-managed library skills)
|
|
199
|
+
* .agents/skills/
|
|
200
|
+
*
|
|
201
|
+
* Only that attributable shape is removed: the header line itself and
|
|
202
|
+
* a directly-following `.agents/skills/` line when present. Any other
|
|
203
|
+
* line — including user-authored lines that happen to sit under the
|
|
204
|
+
* legacy header — survives verbatim.
|
|
205
|
+
*
|
|
206
|
+
* @param {string[]} lines
|
|
207
|
+
* @returns {{ lines: string[]; healed: boolean }}
|
|
208
|
+
*/
|
|
209
|
+
function healLegacySection(lines) {
|
|
210
|
+
const headerIdx = lines.findIndex(
|
|
211
|
+
(l) => l.trim() === LEGACY_GITIGNORE_SECTION_HEADER,
|
|
212
|
+
);
|
|
213
|
+
if (headerIdx === -1) return { lines, healed: false };
|
|
214
|
+
|
|
215
|
+
let end = headerIdx + 1;
|
|
216
|
+
if (end < lines.length && lines[end].trim() === ".agents/skills/") {
|
|
217
|
+
end += 1;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
lines: [...lines.slice(0, headerIdx), ...lines.slice(end)],
|
|
221
|
+
healed: true,
|
|
222
|
+
};
|
|
223
|
+
}
|