skillrepo 2.0.0 → 3.1.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.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `.gitignore` section remover (#885).
3
+ *
4
+ * Surgical inverse of `mergers/gitignore.mjs`. Strips the SkillRepo
5
+ * section — the header line plus every non-blank line directly under
6
+ * it — while leaving every other line in the file unchanged.
7
+ *
8
+ * Attribution model: only lines inside the section are considered
9
+ * SkillRepo-owned. A user-authored `.env.local` line elsewhere in the
10
+ * file is preserved, even though it matches one of the strings the
11
+ * installer writes. This matters because `.env.local` is commonly
12
+ * gitignored for reasons unrelated to SkillRepo.
13
+ *
14
+ * Why a dedicated remover instead of inverting the merger by rewriting
15
+ * the file from parsed state: the merger only writes the section; it
16
+ * never touches the rest of the file. Mirroring that contract in the
17
+ * remover means we can guarantee "we only modify our section" at the
18
+ * syntactic level, which is stronger than any semantic-round-trip
19
+ * guarantee. The file keeps byte-for-byte fidelity outside the
20
+ * SkillRepo section.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { writeFileAtomic } from "../fs-utils.mjs";
25
+ import { GITIGNORE_SECTION_HEADER } from "../artifact-registry.mjs";
26
+ import { gitignorePath } from "../paths.mjs";
27
+
28
+ /**
29
+ * Strip the SkillRepo section from .gitignore.
30
+ *
31
+ * @param {object} [options]
32
+ * @param {boolean} [options.dryRun=false] - When true, performs the
33
+ * same detection logic but does not write. Returns action
34
+ * `"would-remove"` instead of `"removed"` so callers can
35
+ * distinguish a preview from an executed removal. The
36
+ * uninstall command uses this to present a pre-execution
37
+ * summary to the user before prompting for confirmation.
38
+ *
39
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; removed: number }}
40
+ */
41
+ export function removeGitignore({ dryRun = false } = {}) {
42
+ const filePath = gitignorePath();
43
+
44
+ if (!existsSync(filePath)) {
45
+ return { path: ".gitignore", action: "skipped", removed: 0 };
46
+ }
47
+
48
+ const original = readFileSync(filePath, "utf-8");
49
+ // Preserve the original line ending so a file written with CRLF
50
+ // stays CRLF. `split(/\r?\n/)` discards the line-ending bytes, so
51
+ // we detect them explicitly and rebuild on that basis.
52
+ const lineEnding = original.includes("\r\n") ? "\r\n" : "\n";
53
+ const lines = original.split(/\r?\n/);
54
+
55
+ const headerIdx = lines.findIndex((l) => l === GITIGNORE_SECTION_HEADER);
56
+ if (headerIdx === -1) {
57
+ return { path: ".gitignore", action: "skipped", removed: 0 };
58
+ }
59
+
60
+ // Section boundary: from the header line, consume every following
61
+ // non-blank line until we hit the first blank line or EOF. This
62
+ // matches the installer's "header + N entries, separated from the
63
+ // rest by a blank line" shape.
64
+ let end = headerIdx + 1;
65
+ while (end < lines.length && lines[end] !== "") {
66
+ end += 1;
67
+ }
68
+
69
+ // Also consume the single trailing blank line that the installer
70
+ // emits after the section — if present. This prevents a stranded
71
+ // blank line where the section used to be. If the user had their
72
+ // own multi-blank separator around the section, only the single
73
+ // separator immediately after the section is eaten; extras survive.
74
+ if (end < lines.length && lines[end] === "") {
75
+ end += 1;
76
+ }
77
+
78
+ const removedLines = end - headerIdx;
79
+ // -1 on removedLines for the display: the "trailing blank line"
80
+ // isn't a meaningful count of SkillRepo entries. Only the header
81
+ // + entries count. But lines after the header that are blank were
82
+ // already excluded by the while-loop above, so `end - 1 -
83
+ // headerIdx` is header+entries. Subtract 1 more only if we
84
+ // consumed the trailing blank.
85
+ //
86
+ // Actually the simpler accounting: count the non-blank deleted
87
+ // lines. `end - headerIdx` includes at most one trailing blank;
88
+ // `Math.min(end, lines.length) - headerIdx - (<consumed-blank>
89
+ // ? 1 : 0)` gives the SkillRepo-line count.
90
+ const consumedTrailingBlank =
91
+ end > headerIdx + 1 && lines[end - 1] === "";
92
+ const reportedRemoved = removedLines - (consumedTrailingBlank ? 1 : 0);
93
+
94
+ if (dryRun) {
95
+ return {
96
+ path: ".gitignore",
97
+ action: "would-remove",
98
+ removed: reportedRemoved,
99
+ };
100
+ }
101
+
102
+ const survivors = [...lines.slice(0, headerIdx), ...lines.slice(end)];
103
+ const rebuilt = survivors.join(lineEnding);
104
+
105
+ writeFileAtomic(filePath, rebuilt);
106
+
107
+ return { path: ".gitignore", action: "removed", removed: reportedRemoved };
108
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `.claude/settings.local.json` SessionStart-hook remover (#885 + #884).
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for removing the SkillRepo SessionStart hook.
5
+ * Both `skillrepo uninstall` (#885) and `skillrepo session-sync disable`
6
+ * (#884) route through this one function — the architect round-1
7
+ * review flagged the earlier dual-implementation design as a
8
+ * maintenance hazard. Consolidating here eliminates the drift surface.
9
+ *
10
+ * The fingerprint lives in `artifact-registry.mjs` and is imported by
11
+ * both this remover and the #884 installer (`mergers/session-hook.mjs`)
12
+ * — single source of truth for the match predicate, enforced at the
13
+ * language level.
14
+ *
15
+ * Settings-file shape (per Claude Code docs):
16
+ *
17
+ * {
18
+ * "hooks": {
19
+ * "SessionStart": [
20
+ * { "hooks": [ { "type": "command", "command": "..." } ] },
21
+ * ...
22
+ * ]
23
+ * }
24
+ * }
25
+ *
26
+ * The outer array holds "hook groups"; each group has its own
27
+ * `hooks` array containing individual commands. #884 writes a
28
+ * single group with a single command. The remover walks both
29
+ * levels and drops any INNER hook that matches the fingerprint; if
30
+ * a group's inner array becomes empty, the whole group is dropped.
31
+ * This keeps unrelated groups intact even if one of them happens
32
+ * to be adjacent in the file.
33
+ *
34
+ * ## Action values
35
+ *
36
+ * Normalized to distinguish three semantic cases:
37
+ *
38
+ * - `"skipped"` — the file does NOT exist (or unparseable). The
39
+ * operation couldn't run.
40
+ * - `"unchanged"` — the file exists (including zero-byte) AND is
41
+ * parseable AND has no SkillRepo hook to remove. The operation
42
+ * ran; there was nothing to do.
43
+ * - `"would-remove"` — dryRun=true and a SkillRepo hook is present.
44
+ * - `"removed"` — a SkillRepo hook was stripped.
45
+ *
46
+ * This is the semantic fix architect round-1 review flagged: earlier
47
+ * versions returned `"skipped"` both for "file missing" AND "file
48
+ * exists but no hook", conflating two distinct states. CI scripts
49
+ * that want to verify "session sync is definitely disabled" can now
50
+ * treat `"skipped" | "unchanged" | "removed"` as success and `error`
51
+ * as failure, cleanly.
52
+ */
53
+
54
+ import { existsSync, readFileSync } from "node:fs";
55
+ import { writeFileAtomic } from "../fs-utils.mjs";
56
+ import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
57
+ import {
58
+ claudeSettingsLocal,
59
+ claudeSettingsLocalGlobal,
60
+ } from "../paths.mjs";
61
+
62
+ /**
63
+ * Strip the SkillRepo SessionStart hook from settings.local.json.
64
+ *
65
+ * @param {object} [options]
66
+ * @param {boolean} [options.dryRun=false] - Preview-only mode.
67
+ * @param {boolean} [options.global=false] - Operate on the user-wide
68
+ * `~/.claude/settings.local.json` instead of project-local.
69
+ *
70
+ * @returns {{
71
+ * path: string;
72
+ * action: "removed" | "would-remove" | "skipped" | "unchanged";
73
+ * error?: string;
74
+ * }}
75
+ */
76
+ export function removeSettingsSessionHook({
77
+ dryRun = false,
78
+ global = false,
79
+ } = {}) {
80
+ const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
81
+ const displayPath = global
82
+ ? "~/.claude/settings.local.json"
83
+ : ".claude/settings.local.json";
84
+
85
+ if (!existsSync(filePath)) {
86
+ return { path: displayPath, action: "skipped" };
87
+ }
88
+
89
+ const raw = readFileSync(filePath, "utf-8");
90
+
91
+ // Empty-file guard — a zero-byte or whitespace-only settings.local.json
92
+ // is a valid state (some tools create the file as a touch-target).
93
+ // Treating it as unparseable would produce a misleading "Cannot parse"
94
+ // error for what is genuinely an empty file. Code-reviewer round-1
95
+ // flagged this for `removeSessionHook`; fixing at the single source
96
+ // of truth closes it everywhere.
97
+ if (raw.trim().length === 0) {
98
+ return { path: displayPath, action: "unchanged" };
99
+ }
100
+
101
+ let config;
102
+ try {
103
+ config = JSON.parse(raw);
104
+ } catch (err) {
105
+ return {
106
+ path: displayPath,
107
+ action: "skipped",
108
+ error: `Cannot parse ${displayPath}: ${err.message}. Fix or delete the file and re-run.`,
109
+ };
110
+ }
111
+
112
+ if (
113
+ !config ||
114
+ typeof config !== "object" ||
115
+ !config.hooks ||
116
+ typeof config.hooks !== "object" ||
117
+ !Array.isArray(config.hooks.SessionStart)
118
+ ) {
119
+ // File is parseable but has no hooks section — nothing to do.
120
+ // This is the "unchanged" semantic, not "skipped": the operation
121
+ // ran successfully, there just wasn't anything to remove.
122
+ return { path: displayPath, action: "unchanged" };
123
+ }
124
+
125
+ const originalSessionStart = config.hooks.SessionStart;
126
+ let anyRemoved = false;
127
+
128
+ const newSessionStart = originalSessionStart
129
+ .map((group) => {
130
+ if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
131
+ // Unknown group shape — leave it alone. The installer only
132
+ // writes a specific shape; anything else is user-authored
133
+ // and the remover must not mutate it.
134
+ return group;
135
+ }
136
+ const beforeCount = group.hooks.length;
137
+ const survivingHooks = group.hooks.filter((h) => {
138
+ if (!h || typeof h !== "object" || typeof h.command !== "string") {
139
+ return true;
140
+ }
141
+ const matches = h.command.includes(SESSION_HOOK_FINGERPRINT);
142
+ if (matches) {
143
+ anyRemoved = true;
144
+ return false;
145
+ }
146
+ return true;
147
+ });
148
+ if (survivingHooks.length !== beforeCount && survivingHooks.length === 0) {
149
+ // The whole group was ours — drop it. Returning `null`
150
+ // flags the group for removal in the subsequent filter.
151
+ return null;
152
+ }
153
+ if (survivingHooks.length !== beforeCount) {
154
+ return { ...group, hooks: survivingHooks };
155
+ }
156
+ return group;
157
+ })
158
+ .filter((g) => g !== null);
159
+
160
+ if (!anyRemoved) {
161
+ return { path: displayPath, action: "unchanged" };
162
+ }
163
+
164
+ if (dryRun) {
165
+ return { path: displayPath, action: "would-remove" };
166
+ }
167
+
168
+ // Clean up empty containers so the file doesn't accumulate dead
169
+ // structure: empty SessionStart array → remove the key; empty
170
+ // hooks object → remove the key. Leaves a minimally-clean file.
171
+ if (newSessionStart.length === 0) {
172
+ delete config.hooks.SessionStart;
173
+ } else {
174
+ config.hooks.SessionStart = newSessionStart;
175
+ }
176
+ if (Object.keys(config.hooks).length === 0) {
177
+ delete config.hooks;
178
+ }
179
+
180
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
181
+
182
+ return { path: displayPath, action: "removed" };
183
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * VS Code `.vscode/mcp.json` remover (#885).
3
+ *
4
+ * Two-artifact file: the installer writes both a server entry
5
+ * (`servers.skillrepo`) and an input entry (an element of the
6
+ * `inputs` array with `id === "skillrepo-api-key"`). This remover
7
+ * tears down BOTH in a single read-mutate-write cycle so the file
8
+ * is never observed with only one half of the SkillRepo footprint.
9
+ *
10
+ * Note: VS Code uses `servers` (not `mcpServers` like Claude/Cursor)
11
+ * and separates credential prompting into the `inputs` array — that's
12
+ * why this remover's shape differs from the other three.
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { writeFileAtomic } from "../fs-utils.mjs";
17
+ import { VSCODE_INPUT_ID } from "../artifact-registry.mjs";
18
+ import { vscodeMcpJson } from "../paths.mjs";
19
+
20
+ /**
21
+ * @param {object} [options]
22
+ * @param {boolean} [options.dryRun=false]
23
+ *
24
+ * @returns {{
25
+ * path: string;
26
+ * action: "removed" | "would-remove" | "skipped";
27
+ * removed?: string[];
28
+ * error?: string;
29
+ * }}
30
+ */
31
+ export function removeVscodeMcp({ dryRun = false } = {}) {
32
+ const filePath = vscodeMcpJson();
33
+
34
+ if (!existsSync(filePath)) {
35
+ return { path: ".vscode/mcp.json", action: "skipped" };
36
+ }
37
+
38
+ const raw = readFileSync(filePath, "utf-8");
39
+ let config;
40
+ try {
41
+ config = JSON.parse(raw);
42
+ } catch (err) {
43
+ return {
44
+ path: ".vscode/mcp.json",
45
+ action: "skipped",
46
+ error: `Cannot parse .vscode/mcp.json: ${err.message}. Fix or delete the file and re-run uninstall.`,
47
+ };
48
+ }
49
+
50
+ if (!config || typeof config !== "object") {
51
+ return { path: ".vscode/mcp.json", action: "skipped" };
52
+ }
53
+
54
+ const removed = [];
55
+
56
+ // Server entry
57
+ if (
58
+ config.servers &&
59
+ typeof config.servers === "object" &&
60
+ "skillrepo" in config.servers
61
+ ) {
62
+ delete config.servers.skillrepo;
63
+ removed.push("servers.skillrepo");
64
+ }
65
+
66
+ // Input entry — filter the inputs array by id. Other input entries
67
+ // (user-authored prompts for other extensions) survive untouched.
68
+ if (Array.isArray(config.inputs)) {
69
+ const before = config.inputs.length;
70
+ config.inputs = config.inputs.filter((i) => i?.id !== VSCODE_INPUT_ID);
71
+ if (config.inputs.length !== before) {
72
+ removed.push(`inputs[${VSCODE_INPUT_ID}]`);
73
+ }
74
+ }
75
+
76
+ if (removed.length === 0) {
77
+ return { path: ".vscode/mcp.json", action: "skipped" };
78
+ }
79
+
80
+ if (dryRun) {
81
+ return { path: ".vscode/mcp.json", action: "would-remove", removed };
82
+ }
83
+
84
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
85
+
86
+ return { path: ".vscode/mcp.json", action: "removed", removed };
87
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Windsurf `~/.codeium/windsurf/mcp_config.json` remover (#885).
3
+ *
4
+ * Windsurf is always global-scope — unlike Claude/Cursor/VS Code,
5
+ * Windsurf has no project-level config. This remover is only
6
+ * invoked when uninstall runs with `--global`.
7
+ *
8
+ * Same schema as claude-mcp / cursor-mcp (surgical delete of
9
+ * `mcpServers.skillrepo`), but the server entry uses `serverUrl`
10
+ * instead of `url` so a future feature that validates the shape of
11
+ * what we're removing would diverge here. Kept separate for
12
+ * symmetry and testability.
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { writeFileAtomic } from "../fs-utils.mjs";
17
+ import { windsurfMcpJson } from "../paths.mjs";
18
+
19
+ const DISPLAY_PATH = "~/.codeium/windsurf/mcp_config.json";
20
+
21
+ /**
22
+ * @param {object} [options]
23
+ * @param {boolean} [options.dryRun=false]
24
+ *
25
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; error?: string }}
26
+ */
27
+ export function removeWindsurfMcp({ dryRun = false } = {}) {
28
+ const filePath = windsurfMcpJson();
29
+
30
+ if (!existsSync(filePath)) {
31
+ return { path: DISPLAY_PATH, action: "skipped" };
32
+ }
33
+
34
+ const raw = readFileSync(filePath, "utf-8");
35
+ let config;
36
+ try {
37
+ config = JSON.parse(raw);
38
+ } catch (err) {
39
+ return {
40
+ path: DISPLAY_PATH,
41
+ action: "skipped",
42
+ error: `Cannot parse ${DISPLAY_PATH}: ${err.message}. Fix or delete the file and re-run uninstall.`,
43
+ };
44
+ }
45
+
46
+ if (
47
+ !config ||
48
+ typeof config !== "object" ||
49
+ !config.mcpServers ||
50
+ typeof config.mcpServers !== "object" ||
51
+ !("skillrepo" in config.mcpServers)
52
+ ) {
53
+ return { path: DISPLAY_PATH, action: "skipped" };
54
+ }
55
+
56
+ if (dryRun) {
57
+ return { path: DISPLAY_PATH, action: "would-remove" };
58
+ }
59
+
60
+ delete config.mcpServers.skillrepo;
61
+
62
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
63
+
64
+ return { path: DISPLAY_PATH, action: "removed" };
65
+ }