skillrepo 3.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.
- package/README.md +72 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +132 -14
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/test/commands/init.test.mjs +211 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +158 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code `.mcp.json` remover (#885).
|
|
3
|
+
*
|
|
4
|
+
* Surgical inverse of `mergers/claude-mcp.mjs`: parse the JSON,
|
|
5
|
+
* delete `mcpServers.skillrepo` if present, write back. Any other
|
|
6
|
+
* `mcpServers.*` entries (vendor configs the user added manually or
|
|
7
|
+
* via another tool) are preserved verbatim.
|
|
8
|
+
*
|
|
9
|
+
* A file that doesn't exist is skipped. A file with unparseable
|
|
10
|
+
* JSON is reported as an error but does not throw from the remover
|
|
11
|
+
* — the uninstall command aggregates errors per artifact and
|
|
12
|
+
* surfaces them at the end. The user would need to fix or delete
|
|
13
|
+
* the file manually before re-running uninstall for this artifact.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
18
|
+
import { claudeMcpJson } from "../paths.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Delete `mcpServers.skillrepo` from `.mcp.json`.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {boolean} [options.dryRun=false] - Preview-only mode; returns
|
|
25
|
+
* `"would-remove"` without touching the file.
|
|
26
|
+
*
|
|
27
|
+
* @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; error?: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function removeClaudeMcp({ dryRun = false } = {}) {
|
|
30
|
+
const filePath = claudeMcpJson();
|
|
31
|
+
|
|
32
|
+
if (!existsSync(filePath)) {
|
|
33
|
+
return { path: ".mcp.json", action: "skipped" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
37
|
+
let config;
|
|
38
|
+
try {
|
|
39
|
+
config = JSON.parse(raw);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return {
|
|
42
|
+
path: ".mcp.json",
|
|
43
|
+
action: "skipped",
|
|
44
|
+
error: `Cannot parse .mcp.json: ${err.message}. Fix or delete the file and re-run uninstall.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
!config ||
|
|
50
|
+
typeof config !== "object" ||
|
|
51
|
+
!config.mcpServers ||
|
|
52
|
+
typeof config.mcpServers !== "object" ||
|
|
53
|
+
!("skillrepo" in config.mcpServers)
|
|
54
|
+
) {
|
|
55
|
+
return { path: ".mcp.json", action: "skipped" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (dryRun) {
|
|
59
|
+
return { path: ".mcp.json", action: "would-remove" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
delete config.mcpServers.skillrepo;
|
|
63
|
+
|
|
64
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
65
|
+
|
|
66
|
+
return { path: ".mcp.json", action: "removed" };
|
|
67
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor `.cursor/mcp.json` remover (#885).
|
|
3
|
+
*
|
|
4
|
+
* Identical shape to the Claude Code remover — Cursor uses the same
|
|
5
|
+
* `mcpServers.<key>` schema, only the path and env-var interpolation
|
|
6
|
+
* syntax differ. Kept as a separate module so each path has its own
|
|
7
|
+
* testable unit and so future divergence (Cursor adding a new
|
|
8
|
+
* config field, for instance) can land in this file without
|
|
9
|
+
* touching the Claude remover.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
14
|
+
import { cursorMcpJson } from "../paths.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {boolean} [options.dryRun=false]
|
|
19
|
+
*
|
|
20
|
+
* @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; error?: string }}
|
|
21
|
+
*/
|
|
22
|
+
export function removeCursorMcp({ dryRun = false } = {}) {
|
|
23
|
+
const filePath = cursorMcpJson();
|
|
24
|
+
|
|
25
|
+
if (!existsSync(filePath)) {
|
|
26
|
+
return { path: ".cursor/mcp.json", action: "skipped" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
30
|
+
let config;
|
|
31
|
+
try {
|
|
32
|
+
config = JSON.parse(raw);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return {
|
|
35
|
+
path: ".cursor/mcp.json",
|
|
36
|
+
action: "skipped",
|
|
37
|
+
error: `Cannot parse .cursor/mcp.json: ${err.message}. Fix or delete the file and re-run uninstall.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
!config ||
|
|
43
|
+
typeof config !== "object" ||
|
|
44
|
+
!config.mcpServers ||
|
|
45
|
+
typeof config.mcpServers !== "object" ||
|
|
46
|
+
!("skillrepo" in config.mcpServers)
|
|
47
|
+
) {
|
|
48
|
+
return { path: ".cursor/mcp.json", action: "skipped" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (dryRun) {
|
|
52
|
+
return { path: ".cursor/mcp.json", action: "would-remove" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
delete config.mcpServers.skillrepo;
|
|
56
|
+
|
|
57
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
58
|
+
|
|
59
|
+
return { path: ".cursor/mcp.json", action: "removed" };
|
|
60
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env.local` credential remover (#885).
|
|
3
|
+
*
|
|
4
|
+
* Strips every line whose prefix matches `SKILLREPO_ACCESS_KEY=`. Uses
|
|
5
|
+
* atomic write so a rename-mid-write crash cannot leave a partial
|
|
6
|
+
* credential value on disk.
|
|
7
|
+
*
|
|
8
|
+
* Prefix match (not substring) so the remover cannot inadvertently
|
|
9
|
+
* strip a user-authored comment or template line. The `.env.local`
|
|
10
|
+
* installer writes only the `KEY=value` shape, so "starts with
|
|
11
|
+
* SKILLREPO_ACCESS_KEY=" is exactly the set the installer produces.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
16
|
+
import { ENV_LOCAL_KEY_NAME } from "../artifact-registry.mjs";
|
|
17
|
+
import { envLocal } from "../paths.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Remove every `SKILLREPO_ACCESS_KEY=` line from `.env.local`.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} [options]
|
|
23
|
+
* @param {boolean} [options.dryRun=false] - Preview-only mode; see
|
|
24
|
+
* removeGitignore's JSDoc for rationale. Returns action
|
|
25
|
+
* `"would-remove"` without touching the file.
|
|
26
|
+
*
|
|
27
|
+
* @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; removed: number }}
|
|
28
|
+
*/
|
|
29
|
+
export function removeEnvLocal({ dryRun = false } = {}) {
|
|
30
|
+
const filePath = envLocal();
|
|
31
|
+
|
|
32
|
+
if (!existsSync(filePath)) {
|
|
33
|
+
return { path: ".env.local", action: "skipped", removed: 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const original = readFileSync(filePath, "utf-8");
|
|
37
|
+
const lineEnding = original.includes("\r\n") ? "\r\n" : "\n";
|
|
38
|
+
const lines = original.split(/\r?\n/);
|
|
39
|
+
|
|
40
|
+
const prefix = `${ENV_LOCAL_KEY_NAME}=`;
|
|
41
|
+
const survivors = lines.filter((l) => !l.startsWith(prefix));
|
|
42
|
+
const removed = lines.length - survivors.length;
|
|
43
|
+
|
|
44
|
+
if (removed === 0) {
|
|
45
|
+
return { path: ".env.local", action: "skipped", removed: 0 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
return { path: ".env.local", action: "would-remove", removed };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
writeFileAtomic(filePath, survivors.join(lineEnding));
|
|
53
|
+
|
|
54
|
+
return { path: ".env.local", action: "removed", removed };
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|