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.
@@ -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
+ }