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.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Merger for .gitignore — adds the paths `skillrepo init` writes that
3
- * must not be committed.
2
+ * Merger for .gitignore — the init-facing entry point over the CLI's
3
+ * single marked section (core writer: `../gitignore-section.mjs`).
4
4
  *
5
- * Always added:
5
+ * Always added by `skillrepo init`:
6
6
  * - `.env.local` — contains SKILLREPO_ACCESS_KEY, a live credential
7
7
  * - `.claude/settings.local.json` — Claude Code per-user settings
8
8
  *
@@ -10,16 +10,20 @@
10
10
  * - `.claude/skills/` — when any vendor maps to `claudeProject`
11
11
  * - `.agents/skills/` — when any vendor maps to `agentsProject`
12
12
  *
13
- * Idempotent: entries already present are skipped. A single call
14
- * either adds all missing entries in one grouped section or exits
15
- * without writing if they're all already present.
13
+ * The sync-time pre-flight (`file-write.mjs`) manages the managed-root
14
+ * entries through the same core, so every CLI-authored gitignore line
15
+ * lives in the one marked section the uninstall remover strips (#2361).
16
+ *
17
+ * Idempotent: entries already present are skipped; a legacy sync-time
18
+ * section is healed into the marked section as a side effect.
16
19
  */
17
20
 
18
- import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
19
- import { gitignorePath } from "../paths.mjs";
20
21
  import { placementTargetsFor } from "../file-write.mjs";
22
+ import {
23
+ ensureGitignoreEntries,
24
+ MANAGED_ROOT_ENTRIES,
25
+ } from "../gitignore-section.mjs";
21
26
 
22
- const SECTION_HEADER = "# SkillRepo CLI (added by `skillrepo init`)";
23
27
  const ALWAYS_ENTRIES = Object.freeze([
24
28
  ".env.local",
25
29
  ".claude/settings.local.json",
@@ -38,7 +42,7 @@ const ALWAYS_ENTRIES = Object.freeze([
38
42
  function entriesFor(options) {
39
43
  const entries = [...ALWAYS_ENTRIES];
40
44
  if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
41
- entries.push(".claude/skills/");
45
+ entries.push(MANAGED_ROOT_ENTRIES.claudeProject);
42
46
  return entries;
43
47
  }
44
48
  let targets;
@@ -50,72 +54,25 @@ function entriesFor(options) {
50
54
  } catch {
51
55
  // Caller passed an unknown vendor; fall back to the always-set
52
56
  // entries plus `.claude/skills/` so we never under-protect.
53
- entries.push(".claude/skills/");
57
+ entries.push(MANAGED_ROOT_ENTRIES.claudeProject);
54
58
  return entries;
55
59
  }
56
- if (targets.includes("claudeProject")) entries.push(".claude/skills/");
57
- if (targets.includes("agentsProject")) entries.push(".agents/skills/");
60
+ for (const [target, entry] of Object.entries(MANAGED_ROOT_ENTRIES)) {
61
+ if (targets.includes(target)) entries.push(entry);
62
+ }
58
63
  return entries;
59
64
  }
60
65
 
61
66
  /**
62
67
  * Ensure the init-required paths are in .gitignore. Creates the file
63
68
  * if it doesn't exist. Idempotent — returns `"skipped"` if every
64
- * required entry is already present, `"created"` if the file didn't
65
- * exist, `"updated"` if it did and at least one entry was missing.
69
+ * required entry is already present and no legacy section needed
70
+ * healing, `"created"` if the file didn't exist, `"updated"` if it did
71
+ * and at least one entry was missing (or a legacy section was healed).
66
72
  *
67
73
  * @param {{ vendors?: string[], global?: boolean }} [options]
68
74
  * @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
69
75
  */
70
76
  export function mergeGitignore(options = {}) {
71
- const requiredEntries = entriesFor(options);
72
- const filePath = gitignorePath();
73
- const existing = readFileSafe(filePath);
74
-
75
- if (existing === null) {
76
- // Fresh .gitignore — write all required entries as one section.
77
- const content = renderSection(requiredEntries);
78
- writeFileSafe(filePath, content);
79
- return {
80
- path: ".gitignore",
81
- action: "created",
82
- added: [...requiredEntries],
83
- };
84
- }
85
-
86
- // Line-exact match check. `.env.local` as a line is distinct from
87
- // `.env.local.backup` or a comment `# .env.local` — split on
88
- // newlines and trim so we match the entry literally.
89
- const lines = existing.split(/\r?\n/).map((l) => l.trim());
90
- const missing = requiredEntries.filter((entry) => !lines.includes(entry));
91
-
92
- if (missing.length === 0) {
93
- return {
94
- path: ".gitignore",
95
- action: "skipped",
96
- added: [],
97
- };
98
- }
99
-
100
- // Append only the missing entries as a new section. Preserve the
101
- // original line-ending style.
102
- const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
103
- const separator = existing.endsWith("\n") ? "" : lineEnding;
104
- const block = renderSection(missing, lineEnding);
105
- writeFileSafe(filePath, existing + separator + lineEnding + block);
106
-
107
- return {
108
- path: ".gitignore",
109
- action: "updated",
110
- added: missing,
111
- };
112
- }
113
-
114
- /**
115
- * Render the SkillRepo section with the given entries. The leading
116
- * blank line separates the section from whatever precedes it when
117
- * appending, and the trailing newline keeps the file POSIX-clean.
118
- */
119
- function renderSection(entries, lineEnding = "\n") {
120
- return SECTION_HEADER + lineEnding + entries.join(lineEnding) + lineEnding;
77
+ return ensureGitignoreEntries(entriesFor(options));
121
78
  }
package/src/lib/paths.mjs CHANGED
@@ -38,6 +38,17 @@ export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo",
38
38
  export const globalNpmVersionCheckPath = () =>
39
39
  join(homedir(), ".claude", "skillrepo", ".npm-version-check");
40
40
 
41
+ /**
42
+ * Governance warn-on-new state (#2361 owner directive): which unmanaged
43
+ * dirs (per placement root) and gitignored declarations (per repo) the
44
+ * CLI has already warned about. Separate file from `.last-sync` —
45
+ * different concern (what the USER was told vs. library content state)
46
+ * and a schema bump in one must not gate the other. Same parent dir so
47
+ * uninstall's whole-directory removal covers it.
48
+ */
49
+ export const globalGovernanceSeenPath = () =>
50
+ join(homedir(), ".claude", "skillrepo", ".governance-seen");
51
+
41
52
  // ── Skill placement targets ────────────────────────────────────────────
42
53
  //
43
54
  // Per-vendor placement decisions live in `agent-registry.mjs`. This
@@ -22,7 +22,10 @@
22
22
 
23
23
  import { existsSync, readFileSync } from "node:fs";
24
24
  import { writeFileAtomic } from "../fs-utils.mjs";
25
- import { GITIGNORE_SECTION_HEADER } from "../artifact-registry.mjs";
25
+ import {
26
+ GITIGNORE_SECTION_HEADER,
27
+ LEGACY_GITIGNORE_SECTION_HEADER,
28
+ } from "../artifact-registry.mjs";
26
29
  import { gitignorePath } from "../paths.mjs";
27
30
 
28
31
  /**
@@ -50,10 +53,41 @@ export function removeGitignore({ dryRun = false } = {}) {
50
53
  // stays CRLF. `split(/\r?\n/)` discards the line-ending bytes, so
51
54
  // we detect them explicitly and rebuild on that basis.
52
55
  const lineEnding = original.includes("\r\n") ? "\r\n" : "\n";
53
- const lines = original.split(/\r?\n/);
56
+ let lines = original.split(/\r?\n/);
57
+
58
+ // Legacy sync-time section (#2361): pre-4.10.0 syncs wrote
59
+ // `.agents/skills/` under its own header, outside the marked
60
+ // section. If the merger's heal never ran (user uninstalls without
61
+ // syncing on 4.10.0+), strip the attributable shape here: the
62
+ // header line plus a directly-following `.agents/skills/` line.
63
+ // Any other line under the legacy header is user content and
64
+ // survives.
65
+ let legacyRemoved = 0;
66
+ const legacyIdx = lines.findIndex(
67
+ (l) => l === LEGACY_GITIGNORE_SECTION_HEADER,
68
+ );
69
+ if (legacyIdx !== -1) {
70
+ let legacyEnd = legacyIdx + 1;
71
+ if (legacyEnd < lines.length && lines[legacyEnd] === ".agents/skills/") {
72
+ legacyEnd += 1;
73
+ }
74
+ legacyRemoved = legacyEnd - legacyIdx;
75
+ lines = [...lines.slice(0, legacyIdx), ...lines.slice(legacyEnd)];
76
+ }
54
77
 
55
78
  const headerIdx = lines.findIndex((l) => l === GITIGNORE_SECTION_HEADER);
56
79
  if (headerIdx === -1) {
80
+ if (legacyRemoved > 0) {
81
+ if (dryRun) {
82
+ return {
83
+ path: ".gitignore",
84
+ action: "would-remove",
85
+ removed: legacyRemoved,
86
+ };
87
+ }
88
+ writeFileAtomic(filePath, lines.join(lineEnding));
89
+ return { path: ".gitignore", action: "removed", removed: legacyRemoved };
90
+ }
57
91
  return { path: ".gitignore", action: "skipped", removed: 0 };
58
92
  }
59
93
 
@@ -89,7 +123,8 @@ export function removeGitignore({ dryRun = false } = {}) {
89
123
  // ? 1 : 0)` gives the SkillRepo-line count.
90
124
  const consumedTrailingBlank =
91
125
  end > headerIdx + 1 && lines[end - 1] === "";
92
- const reportedRemoved = removedLines - (consumedTrailingBlank ? 1 : 0);
126
+ const reportedRemoved =
127
+ removedLines - (consumedTrailingBlank ? 1 : 0) + legacyRemoved;
93
128
 
94
129
  if (dryRun) {
95
130
  return {