skillrepo 3.0.0 → 3.1.1
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 +74 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +184 -19
- 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 +305 -0
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +90 -9
- package/src/lib/mergers/session-hook.mjs +378 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/platform.mjs +124 -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/lib/sync.mjs +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +428 -4
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +352 -0
- package/src/test/commands/uninstall.test.mjs +774 -0
- package/src/test/commands/update.test.mjs +168 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +1175 -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 +296 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
|
@@ -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
|
+
}
|
package/src/lib/sync.mjs
CHANGED
|
@@ -27,6 +27,22 @@
|
|
|
27
27
|
* @property {number} updated - Skills overwritten on disk
|
|
28
28
|
* @property {number} removed - Tombstones applied
|
|
29
29
|
* @property {boolean} notModified - True if 304 short-circuit fired
|
|
30
|
+
* @property {boolean | null} fullSync - True if this was a full (non-delta)
|
|
31
|
+
* sync — i.e. no prior `.last-sync` state
|
|
32
|
+
* existed so no `since` was sent. False if
|
|
33
|
+
* this was a delta sync. `null` only ever
|
|
34
|
+
* appears in SYNTHESIZED failure summaries
|
|
35
|
+
* (e.g. `init.mjs` when runSync threw) to
|
|
36
|
+
* mark the state as genuinely unknown —
|
|
37
|
+
* consumers should never see `null` from
|
|
38
|
+
* runSync itself. A full sync returning
|
|
39
|
+
* zero skills genuinely means "empty
|
|
40
|
+
* library"; a delta sync returning zero
|
|
41
|
+
* means "nothing changed since last sync".
|
|
42
|
+
* Consumers must distinguish these to
|
|
43
|
+
* render accurate user messages (see
|
|
44
|
+
* init.mjs step 7).
|
|
45
|
+
* @property {string} [syncedAt]
|
|
30
46
|
* @property {string} syncedAt - ISO timestamp from the server response
|
|
31
47
|
*
|
|
32
48
|
* @typedef {Object} SyncStateFile
|
|
@@ -182,6 +198,14 @@ export async function runSync(options) {
|
|
|
182
198
|
if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
|
|
183
199
|
if (lastSync?.syncedAt) opts.since = lastSync.syncedAt;
|
|
184
200
|
|
|
201
|
+
// Track whether this is a full or delta sync BEFORE the network
|
|
202
|
+
// call, for the returned summary's `fullSync` field. A "full" sync
|
|
203
|
+
// is one where no `since` was sent — which is exactly when no
|
|
204
|
+
// prior last-sync state existed. The distinction matters to
|
|
205
|
+
// consumers (init.mjs) that need to tell "empty library" from
|
|
206
|
+
// "nothing changed since last sync" in the zero-counters case.
|
|
207
|
+
const fullSync = !lastSync?.syncedAt;
|
|
208
|
+
|
|
185
209
|
const result = await getLibrary(serverUrl, apiKey, opts);
|
|
186
210
|
|
|
187
211
|
// Step 4: 304 short-circuit
|
|
@@ -191,6 +215,7 @@ export async function runSync(options) {
|
|
|
191
215
|
updated: 0,
|
|
192
216
|
removed: 0,
|
|
193
217
|
notModified: true,
|
|
218
|
+
fullSync,
|
|
194
219
|
syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
|
|
195
220
|
};
|
|
196
221
|
}
|
|
@@ -201,6 +226,7 @@ export async function runSync(options) {
|
|
|
201
226
|
updated: 0,
|
|
202
227
|
removed: 0,
|
|
203
228
|
notModified: false,
|
|
229
|
+
fullSync,
|
|
204
230
|
syncedAt: result.syncedAt,
|
|
205
231
|
};
|
|
206
232
|
|
|
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
|
13
13
|
import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
|
|
14
14
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
15
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
|
+
import {
|
|
17
|
+
captureHome,
|
|
18
|
+
setSandboxHome,
|
|
19
|
+
restoreHome,
|
|
20
|
+
} from "../helpers/sandbox-home.mjs";
|
|
16
21
|
|
|
17
22
|
let sandbox;
|
|
18
23
|
let server;
|
|
19
24
|
let serverUrl;
|
|
20
25
|
let originalCwd;
|
|
21
|
-
|
|
26
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
27
|
+
let originalHomeEnv;
|
|
22
28
|
let stdout;
|
|
23
29
|
const VALID_KEY = "sk_live_test";
|
|
24
30
|
|
|
@@ -46,9 +52,9 @@ async function setup() {
|
|
|
46
52
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
47
53
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
48
54
|
originalCwd = process.cwd();
|
|
49
|
-
|
|
55
|
+
originalHomeEnv = captureHome();
|
|
50
56
|
process.chdir(join(sandbox, "project"));
|
|
51
|
-
|
|
57
|
+
setSandboxHome(join(sandbox, "home"));
|
|
52
58
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
53
59
|
|
|
54
60
|
server = createMockServer({});
|
|
@@ -61,7 +67,7 @@ async function setup() {
|
|
|
61
67
|
async function teardown() {
|
|
62
68
|
if (server) await server.stop();
|
|
63
69
|
process.chdir(originalCwd);
|
|
64
|
-
|
|
70
|
+
restoreHome(originalHomeEnv);
|
|
65
71
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
66
72
|
server = null;
|
|
67
73
|
}
|
|
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
|
13
13
|
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
14
14
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
15
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
|
+
import {
|
|
17
|
+
captureHome,
|
|
18
|
+
setSandboxHome,
|
|
19
|
+
restoreHome,
|
|
20
|
+
} from "../helpers/sandbox-home.mjs";
|
|
16
21
|
|
|
17
22
|
let sandbox;
|
|
18
23
|
let server;
|
|
19
24
|
let serverUrl;
|
|
20
25
|
let originalCwd;
|
|
21
|
-
|
|
26
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
27
|
+
let originalHomeEnv;
|
|
22
28
|
let stdout;
|
|
23
29
|
const VALID_KEY = "sk_live_test";
|
|
24
30
|
|
|
@@ -53,9 +59,9 @@ async function setup() {
|
|
|
53
59
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
54
60
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
55
61
|
originalCwd = process.cwd();
|
|
56
|
-
|
|
62
|
+
originalHomeEnv = captureHome();
|
|
57
63
|
process.chdir(join(sandbox, "project"));
|
|
58
|
-
|
|
64
|
+
setSandboxHome(join(sandbox, "home"));
|
|
59
65
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
60
66
|
delete process.env.SKILLREPO_URL;
|
|
61
67
|
|
|
@@ -69,7 +75,7 @@ async function setup() {
|
|
|
69
75
|
async function teardown() {
|
|
70
76
|
if (server) await server.stop();
|
|
71
77
|
process.chdir(originalCwd);
|
|
72
|
-
|
|
78
|
+
restoreHome(originalHomeEnv);
|
|
73
79
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
74
80
|
server = null;
|
|
75
81
|
}
|