speclock 5.5.3 → 5.5.5
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 +264 -336
- package/package.json +217 -1
- package/src/cli/index.js +858 -39
- package/src/core/auth.js +8 -0
- package/src/core/compliance.js +1 -1
- package/src/core/enforcer.js +7 -1
- package/src/core/guardian.js +78 -5
- package/src/core/hooks.js +5 -2
- package/src/core/lock-author.js +8 -0
- package/src/core/mcp-install.js +484 -0
- package/src/core/telemetry.js +685 -114
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +2 -2
- package/src/mcp/server.js +1 -1
- package/src/templates/rule-packs/fastapi.md +22 -0
- package/src/templates/rule-packs/nextjs.md +22 -0
- package/src/templates/rule-packs/node.md +22 -0
- package/src/templates/rule-packs/python.md +22 -0
- package/src/templates/rule-packs/rails.md +22 -0
- package/src/templates/rule-packs/react.md +22 -0
package/src/core/auth.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// SPECLOCK-GUARD — DO NOT MODIFY THIS FILE
|
|
3
|
+
// LOCKED: NEVER modify auth files
|
|
4
|
+
// THIS FILE IS LOCKED. DO NOT EDIT, CHANGE, OR REWRITE ANY PART OF IT.
|
|
5
|
+
// The user must say "unlock" before this file can be changed.
|
|
6
|
+
// A question is NOT permission. Asking about features is NOT permission.
|
|
7
|
+
// ONLY "unlock" or "remove the lock" is permission to edit this file.
|
|
8
|
+
// ============================================================
|
|
1
9
|
/**
|
|
2
10
|
* SpecLock API Key Authentication
|
|
3
11
|
* Provides API key generation, validation, rotation, and revocation.
|
package/src/core/compliance.js
CHANGED
package/src/core/enforcer.js
CHANGED
|
@@ -29,10 +29,16 @@ import { analyzeConflict } from "./semantics.js";
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Get enforcement config from brain, with defaults.
|
|
32
|
+
*
|
|
33
|
+
* Default mode is "advisory" (warn only). Users opt in to hard blocking
|
|
34
|
+
* with `speclock protect --strict`, `speclock enforce hard`, the --strict
|
|
35
|
+
* flag on audit commands, or SPECLOCK_STRICT=1 env var. The investor audit
|
|
36
|
+
* found hard-block-by-default caused uninstalls within an hour due to the
|
|
37
|
+
* heuristic false-positive rate on things like "Refactor login page".
|
|
32
38
|
*/
|
|
33
39
|
export function getEnforcementConfig(brain) {
|
|
34
40
|
const defaults = {
|
|
35
|
-
mode: "advisory", // "advisory" | "hard"
|
|
41
|
+
mode: "advisory", // "advisory" (warn — default) | "hard" (block)
|
|
36
42
|
blockThreshold: 70, // minimum confidence % to block in hard mode
|
|
37
43
|
allowOverride: true, // whether overrides are permitted
|
|
38
44
|
escalationLimit: 3, // overrides before auto-note
|
package/src/core/guardian.js
CHANGED
|
@@ -16,9 +16,46 @@ import { installHook, isHookInstalled } from "./hooks.js";
|
|
|
16
16
|
import { syncRules } from "./rules-sync.js";
|
|
17
17
|
import { generateContext } from "./context.js";
|
|
18
18
|
|
|
19
|
+
// --- Starter CLAUDE.md for greenfield projects ---
|
|
20
|
+
|
|
21
|
+
const STARTER_CLAUDE_MD = `# Project Rules
|
|
22
|
+
|
|
23
|
+
These rules are enforced by SpecLock — your AI coding assistant will respect them.
|
|
24
|
+
|
|
25
|
+
## Database & Storage
|
|
26
|
+
- NEVER delete user data without explicit confirmation
|
|
27
|
+
- NEVER modify production database schema without migration
|
|
28
|
+
|
|
29
|
+
## Authentication & Security
|
|
30
|
+
- NEVER modify authentication files without security review
|
|
31
|
+
- NEVER commit secrets, API keys, or credentials
|
|
32
|
+
- NEVER disable security checks "temporarily"
|
|
33
|
+
|
|
34
|
+
## Code Quality
|
|
35
|
+
- ALWAYS write tests for new features
|
|
36
|
+
- NEVER push directly to main branch
|
|
37
|
+
- NEVER skip code review on critical paths
|
|
38
|
+
|
|
39
|
+
## Edit these rules to match your project. Add your own with:
|
|
40
|
+
## speclock add-lock "Your rule here"
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a starter CLAUDE.md with safe defaults for greenfield projects.
|
|
45
|
+
* Used when `protect` is called on a project with no existing rule files.
|
|
46
|
+
*/
|
|
47
|
+
export function createStarterClaudeMd(root) {
|
|
48
|
+
const filePath = path.join(root, "CLAUDE.md");
|
|
49
|
+
if (fs.existsSync(filePath)) {
|
|
50
|
+
return { created: false, path: filePath, reason: "already exists" };
|
|
51
|
+
}
|
|
52
|
+
fs.writeFileSync(filePath, STARTER_CLAUDE_MD);
|
|
53
|
+
return { created: true, path: filePath };
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
// --- Rule file discovery ---
|
|
20
57
|
|
|
21
|
-
const RULE_FILES = [
|
|
58
|
+
export const RULE_FILES = [
|
|
22
59
|
{ file: ".cursorrules", tool: "Cursor" },
|
|
23
60
|
{ file: ".cursor/rules/rules.mdc", tool: "Cursor (MDC)" },
|
|
24
61
|
{ file: "CLAUDE.md", tool: "Claude Code" },
|
|
@@ -209,13 +246,29 @@ export function protect(root, options = {}) {
|
|
|
209
246
|
hookStatus: "",
|
|
210
247
|
synced: [],
|
|
211
248
|
errors: [],
|
|
249
|
+
starterCreated: false,
|
|
250
|
+
starterPath: null,
|
|
251
|
+
strict: options.strict === true,
|
|
212
252
|
};
|
|
213
253
|
|
|
214
254
|
// 1. Init
|
|
215
255
|
const brain = ensureInit(root);
|
|
216
256
|
|
|
217
257
|
// 2. Discover
|
|
218
|
-
|
|
258
|
+
let ruleFiles = discoverRuleFiles(root);
|
|
259
|
+
|
|
260
|
+
// 2b. Greenfield support: if no rule files found, auto-create a starter
|
|
261
|
+
// CLAUDE.md with safe defaults (unless explicitly disabled).
|
|
262
|
+
if (ruleFiles.length === 0 && !options.skipStarter) {
|
|
263
|
+
const starter = createStarterClaudeMd(root);
|
|
264
|
+
if (starter.created) {
|
|
265
|
+
report.starterCreated = true;
|
|
266
|
+
report.starterPath = "CLAUDE.md";
|
|
267
|
+
// Re-run discovery so the flow continues normally with the new file.
|
|
268
|
+
ruleFiles = discoverRuleFiles(root);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
219
272
|
report.discovered = ruleFiles.map((f) => ({
|
|
220
273
|
file: f.file,
|
|
221
274
|
tool: f.tool,
|
|
@@ -321,13 +374,20 @@ export function formatProtectReport(report) {
|
|
|
321
374
|
lines.push(" " + "=".repeat(50));
|
|
322
375
|
lines.push("");
|
|
323
376
|
|
|
377
|
+
// Starter CLAUDE.md was auto-created (greenfield support)
|
|
378
|
+
if (report.starterCreated) {
|
|
379
|
+
lines.push(" No rule files found.");
|
|
380
|
+
lines.push(` [+] Created starter CLAUDE.md with safe defaults — edit it to match your project.`);
|
|
381
|
+
lines.push("");
|
|
382
|
+
}
|
|
383
|
+
|
|
324
384
|
// Discovered files
|
|
325
385
|
if (report.discovered.length > 0) {
|
|
326
386
|
lines.push(" Rule files found:");
|
|
327
387
|
for (const f of report.discovered) {
|
|
328
388
|
lines.push(` [+] ${f.file} (${f.tool}, ${f.lines} lines)`);
|
|
329
389
|
}
|
|
330
|
-
} else {
|
|
390
|
+
} else if (!report.starterCreated) {
|
|
331
391
|
lines.push(" [!] No rule files found.");
|
|
332
392
|
}
|
|
333
393
|
lines.push("");
|
|
@@ -375,8 +435,21 @@ export function formatProtectReport(report) {
|
|
|
375
435
|
// Final message
|
|
376
436
|
const total = report.added.locks + report.added.skipped;
|
|
377
437
|
if (total > 0) {
|
|
378
|
-
|
|
379
|
-
|
|
438
|
+
if (report.strict) {
|
|
439
|
+
lines.push(" Your rules are now ENFORCED (strict mode).");
|
|
440
|
+
lines.push(" Commits that violate constraints will be BLOCKED.");
|
|
441
|
+
} else {
|
|
442
|
+
lines.push(" Your rules are now TRACKED (warning mode — default).");
|
|
443
|
+
lines.push(" Violations will be printed loudly, but commits will NOT be blocked.");
|
|
444
|
+
lines.push(" Opt in to hard enforcement any time with: speclock protect --strict");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Greenfield guidance — tell the user to edit the starter file
|
|
449
|
+
if (report.starterCreated) {
|
|
450
|
+
lines.push("");
|
|
451
|
+
lines.push(" Next: edit CLAUDE.md to add project-specific rules, then run:");
|
|
452
|
+
lines.push(' speclock check "your action here"');
|
|
380
453
|
}
|
|
381
454
|
lines.push("");
|
|
382
455
|
|
package/src/core/hooks.js
CHANGED
|
@@ -8,11 +8,14 @@ const HOOK_MARKER = "# SPECLOCK-HOOK";
|
|
|
8
8
|
|
|
9
9
|
const HOOK_SCRIPT = `#!/bin/sh
|
|
10
10
|
${HOOK_MARKER} — Do not remove this line
|
|
11
|
-
# SpecLock pre-commit hook:
|
|
11
|
+
# SpecLock pre-commit hook: runs semantic audit of staged diff + commit message
|
|
12
|
+
# against active locks. Unlike the legacy 'audit' subcommand, this one feeds
|
|
13
|
+
# the actual diff content AND the commit message through the semantic conflict
|
|
14
|
+
# engine — the same one used by 'speclock check'.
|
|
12
15
|
# Install: npx speclock hook install
|
|
13
16
|
# Remove: npx speclock hook remove
|
|
14
17
|
|
|
15
|
-
npx speclock audit
|
|
18
|
+
npx speclock audit-semantic --pre-commit
|
|
16
19
|
exit $?
|
|
17
20
|
`;
|
|
18
21
|
|
package/src/core/lock-author.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// SPECLOCK-GUARD — DO NOT MODIFY THIS FILE
|
|
3
|
+
// LOCKED: NEVER modify auth files
|
|
4
|
+
// THIS FILE IS LOCKED. DO NOT EDIT, CHANGE, OR REWRITE ANY PART OF IT.
|
|
5
|
+
// The user must say "unlock" before this file can be changed.
|
|
6
|
+
// A question is NOT permission. Asking about features is NOT permission.
|
|
7
|
+
// ONLY "unlock" or "remove the lock" is permission to edit this file.
|
|
8
|
+
// ============================================================
|
|
1
9
|
// ===================================================================
|
|
2
10
|
// SpecLock Smart Lock Authoring Engine
|
|
3
11
|
// Auto-rewrites user locks to prevent verb contamination.
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock MCP Autoinstaller
|
|
3
|
+
* One-command installer: wires SpecLock as an MCP server into any AI client.
|
|
4
|
+
*
|
|
5
|
+
* Usage (CLI):
|
|
6
|
+
* speclock mcp install <client> — claude-code|cursor|windsurf|cline|codex|all
|
|
7
|
+
* speclock mcp uninstall <client>
|
|
8
|
+
*
|
|
9
|
+
* The investor audit found the biggest manual friction was users having to
|
|
10
|
+
* hand-edit JSON to wire up SpecLock as an MCP server. This module removes
|
|
11
|
+
* that friction entirely — one command, any supported client, any OS.
|
|
12
|
+
*
|
|
13
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import os from "os";
|
|
19
|
+
|
|
20
|
+
// The stanza we inject. Kept in one place so every client stays in sync.
|
|
21
|
+
export const SPECLOCK_MCP_STANZA = {
|
|
22
|
+
command: "npx",
|
|
23
|
+
args: ["-y", "speclock", "serve"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const SUPPORTED_CLIENTS = [
|
|
27
|
+
"claude-code",
|
|
28
|
+
"cursor",
|
|
29
|
+
"windsurf",
|
|
30
|
+
"cline",
|
|
31
|
+
"codex",
|
|
32
|
+
"all",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the config file locations for a given client on the current OS.
|
|
37
|
+
* Returns { primary, project } where each is { path, format }.
|
|
38
|
+
* format is "json" | "toml" | "vscode-json".
|
|
39
|
+
*
|
|
40
|
+
* - primary = global/user-level config (always attempted)
|
|
41
|
+
* - project = project-scoped config (only written if --project flag used)
|
|
42
|
+
*/
|
|
43
|
+
export function getClientConfigPaths(client, projectRoot = process.cwd()) {
|
|
44
|
+
const home = os.homedir();
|
|
45
|
+
const platform = process.platform; // "win32" | "darwin" | "linux"
|
|
46
|
+
|
|
47
|
+
switch (client) {
|
|
48
|
+
case "claude-code": {
|
|
49
|
+
return {
|
|
50
|
+
primary: {
|
|
51
|
+
path: path.join(home, ".claude", "mcp.json"),
|
|
52
|
+
format: "json",
|
|
53
|
+
label: "Claude Code",
|
|
54
|
+
},
|
|
55
|
+
project: {
|
|
56
|
+
path: path.join(projectRoot, ".mcp.json"),
|
|
57
|
+
format: "json",
|
|
58
|
+
label: "Claude Code (project)",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "cursor": {
|
|
64
|
+
return {
|
|
65
|
+
primary: {
|
|
66
|
+
path: path.join(home, ".cursor", "mcp.json"),
|
|
67
|
+
format: "json",
|
|
68
|
+
label: "Cursor",
|
|
69
|
+
},
|
|
70
|
+
project: {
|
|
71
|
+
path: path.join(projectRoot, ".cursor", "mcp.json"),
|
|
72
|
+
format: "json",
|
|
73
|
+
label: "Cursor (project)",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case "windsurf": {
|
|
79
|
+
return {
|
|
80
|
+
primary: {
|
|
81
|
+
path: path.join(home, ".codeium", "windsurf", "mcp_config.json"),
|
|
82
|
+
format: "json",
|
|
83
|
+
label: "Windsurf",
|
|
84
|
+
},
|
|
85
|
+
project: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case "cline": {
|
|
90
|
+
// Cline lives inside VS Code User settings.json.
|
|
91
|
+
let settingsPath;
|
|
92
|
+
if (platform === "win32") {
|
|
93
|
+
settingsPath = path.join(
|
|
94
|
+
process.env.APPDATA || path.join(home, "AppData", "Roaming"),
|
|
95
|
+
"Code",
|
|
96
|
+
"User",
|
|
97
|
+
"settings.json"
|
|
98
|
+
);
|
|
99
|
+
} else if (platform === "darwin") {
|
|
100
|
+
settingsPath = path.join(
|
|
101
|
+
home,
|
|
102
|
+
"Library",
|
|
103
|
+
"Application Support",
|
|
104
|
+
"Code",
|
|
105
|
+
"User",
|
|
106
|
+
"settings.json"
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
settingsPath = path.join(home, ".config", "Code", "User", "settings.json");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
primary: {
|
|
113
|
+
path: settingsPath,
|
|
114
|
+
format: "vscode-json",
|
|
115
|
+
label: "Cline (VS Code settings)",
|
|
116
|
+
},
|
|
117
|
+
project: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "codex": {
|
|
122
|
+
return {
|
|
123
|
+
primary: {
|
|
124
|
+
path: path.join(home, ".codex", "config.toml"),
|
|
125
|
+
format: "toml",
|
|
126
|
+
label: "Codex",
|
|
127
|
+
},
|
|
128
|
+
project: null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
default:
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Unknown client "${client}". Supported: ${SUPPORTED_CLIENTS.join(", ")}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- JSON helpers ---
|
|
140
|
+
|
|
141
|
+
function readJsonSafe(filePath) {
|
|
142
|
+
if (!fs.existsSync(filePath)) return null;
|
|
143
|
+
try {
|
|
144
|
+
const raw = fs.readFileSync(filePath, "utf-8").trim();
|
|
145
|
+
if (!raw) return {};
|
|
146
|
+
return JSON.parse(raw);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
throw new Error(`Could not parse JSON at ${filePath}: ${e.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function writeJson(filePath, data) {
|
|
153
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
154
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Merge speclock into a plain JSON config that uses "mcpServers".
|
|
159
|
+
* Preserves all other servers and top-level keys.
|
|
160
|
+
*/
|
|
161
|
+
function injectJson(config) {
|
|
162
|
+
const next = { ...(config || {}) };
|
|
163
|
+
if (!next.mcpServers || typeof next.mcpServers !== "object") {
|
|
164
|
+
next.mcpServers = {};
|
|
165
|
+
}
|
|
166
|
+
next.mcpServers = {
|
|
167
|
+
...next.mcpServers,
|
|
168
|
+
speclock: { ...SPECLOCK_MCP_STANZA },
|
|
169
|
+
};
|
|
170
|
+
return next;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function removeJson(config) {
|
|
174
|
+
if (!config || typeof config !== "object") return { changed: false, config };
|
|
175
|
+
if (!config.mcpServers || !config.mcpServers.speclock) {
|
|
176
|
+
return { changed: false, config };
|
|
177
|
+
}
|
|
178
|
+
const next = { ...config, mcpServers: { ...config.mcpServers } };
|
|
179
|
+
delete next.mcpServers.speclock;
|
|
180
|
+
return { changed: true, config: next };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* VS Code settings.json uses JSONC (comments + trailing commas).
|
|
185
|
+
* We do a best-effort: if parse fails, we fall back to a safe string rewrite
|
|
186
|
+
* that touches only the "cline.mcpServers" block.
|
|
187
|
+
*/
|
|
188
|
+
function injectVsCodeJson(filePath) {
|
|
189
|
+
const exists = fs.existsSync(filePath);
|
|
190
|
+
let parsed = null;
|
|
191
|
+
let raw = "";
|
|
192
|
+
|
|
193
|
+
if (exists) {
|
|
194
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
195
|
+
try {
|
|
196
|
+
// Try a lenient parse: strip line/block comments and trailing commas.
|
|
197
|
+
const stripped = raw
|
|
198
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
199
|
+
.replace(/(^|[^:])\/\/.*$/gm, "$1")
|
|
200
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
201
|
+
parsed = stripped.trim() ? JSON.parse(stripped) : {};
|
|
202
|
+
} catch {
|
|
203
|
+
parsed = null; // fall back to string append below
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (parsed !== null) {
|
|
208
|
+
const next = { ...parsed };
|
|
209
|
+
// Cline reads either "cline.mcpServers" or "mcpServers". We write the
|
|
210
|
+
// Cline-specific key to avoid clashing with other VS Code extensions.
|
|
211
|
+
const existing = next["cline.mcpServers"] || {};
|
|
212
|
+
next["cline.mcpServers"] = {
|
|
213
|
+
...existing,
|
|
214
|
+
speclock: { ...SPECLOCK_MCP_STANZA },
|
|
215
|
+
};
|
|
216
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
217
|
+
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
218
|
+
return { mode: "parsed" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Fallback: file has comments or odd formatting. Append a marker block.
|
|
222
|
+
// This is safe: VS Code's JSONC parser accepts duplicate keys (last wins)
|
|
223
|
+
// but to avoid corruption we just warn the user instead of rewriting.
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Could not safely parse VS Code settings at ${filePath}. ` +
|
|
226
|
+
`Please add this manually:\n` +
|
|
227
|
+
` "cline.mcpServers": { "speclock": ${JSON.stringify(
|
|
228
|
+
SPECLOCK_MCP_STANZA
|
|
229
|
+
)} }`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function removeVsCodeJson(filePath) {
|
|
234
|
+
if (!fs.existsSync(filePath)) {
|
|
235
|
+
return { changed: false };
|
|
236
|
+
}
|
|
237
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
const stripped = raw
|
|
241
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
242
|
+
.replace(/(^|[^:])\/\/.*$/gm, "$1")
|
|
243
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
244
|
+
parsed = stripped.trim() ? JSON.parse(stripped) : {};
|
|
245
|
+
} catch {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Could not safely parse VS Code settings at ${filePath}. ` +
|
|
248
|
+
`Please remove "speclock" from "cline.mcpServers" manually.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const block = parsed["cline.mcpServers"];
|
|
252
|
+
if (!block || !block.speclock) return { changed: false };
|
|
253
|
+
const next = { ...parsed, "cline.mcpServers": { ...block } };
|
|
254
|
+
delete next["cline.mcpServers"].speclock;
|
|
255
|
+
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
256
|
+
return { changed: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- TOML helpers (Codex ~/.codex/config.toml) ---
|
|
260
|
+
//
|
|
261
|
+
// Codex uses an extremely small TOML dialect for MCP servers:
|
|
262
|
+
// [mcp_servers.speclock]
|
|
263
|
+
// command = "npx"
|
|
264
|
+
// args = ["-y", "speclock", "serve"]
|
|
265
|
+
//
|
|
266
|
+
// We do NOT pull in a TOML parser dependency. We implement a targeted
|
|
267
|
+
// inject/remove that leaves other [mcp_servers.*] tables untouched.
|
|
268
|
+
|
|
269
|
+
const CODEX_STANZA = [
|
|
270
|
+
"",
|
|
271
|
+
"[mcp_servers.speclock]",
|
|
272
|
+
'command = "npx"',
|
|
273
|
+
'args = ["-y", "speclock", "serve"]',
|
|
274
|
+
"",
|
|
275
|
+
].join("\n");
|
|
276
|
+
|
|
277
|
+
function injectToml(filePath) {
|
|
278
|
+
let existing = "";
|
|
279
|
+
if (fs.existsSync(filePath)) {
|
|
280
|
+
existing = fs.readFileSync(filePath, "utf-8");
|
|
281
|
+
if (existing.includes("[mcp_servers.speclock]")) {
|
|
282
|
+
return { changed: false, reason: "already present" };
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
286
|
+
}
|
|
287
|
+
const trimmed = existing.replace(/\s+$/, "");
|
|
288
|
+
const next = (trimmed ? trimmed + "\n" : "") + CODEX_STANZA;
|
|
289
|
+
fs.writeFileSync(filePath, next, "utf-8");
|
|
290
|
+
return { changed: true };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function removeToml(filePath) {
|
|
294
|
+
if (!fs.existsSync(filePath)) return { changed: false };
|
|
295
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
296
|
+
if (!raw.includes("[mcp_servers.speclock]")) {
|
|
297
|
+
return { changed: false };
|
|
298
|
+
}
|
|
299
|
+
// Remove the [mcp_servers.speclock] block up to the next [section] or EOF.
|
|
300
|
+
const cleaned = raw.replace(
|
|
301
|
+
/\n?\[mcp_servers\.speclock\][\s\S]*?(?=\n\[|\n*$)/,
|
|
302
|
+
""
|
|
303
|
+
);
|
|
304
|
+
fs.writeFileSync(filePath, cleaned.replace(/\s+$/, "") + "\n", "utf-8");
|
|
305
|
+
return { changed: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// --- Public API ---
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Install SpecLock MCP server into a single client.
|
|
312
|
+
* Returns { client, writes: [{ path, status, label }], errors: [] }.
|
|
313
|
+
*/
|
|
314
|
+
export function installForClient(client, projectRoot = process.cwd(), options = {}) {
|
|
315
|
+
const includeProject = options.includeProject !== false; // default: yes
|
|
316
|
+
const result = { client, writes: [], errors: [] };
|
|
317
|
+
|
|
318
|
+
let paths;
|
|
319
|
+
try {
|
|
320
|
+
paths = getClientConfigPaths(client, projectRoot);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
result.errors.push(e.message);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const targets = [paths.primary];
|
|
327
|
+
if (includeProject && paths.project) targets.push(paths.project);
|
|
328
|
+
|
|
329
|
+
for (const target of targets) {
|
|
330
|
+
if (!target) continue;
|
|
331
|
+
try {
|
|
332
|
+
let status;
|
|
333
|
+
if (target.format === "json") {
|
|
334
|
+
const current = readJsonSafe(target.path) || {};
|
|
335
|
+
const next = injectJson(current);
|
|
336
|
+
writeJson(target.path, next);
|
|
337
|
+
status = "installed";
|
|
338
|
+
} else if (target.format === "vscode-json") {
|
|
339
|
+
const out = injectVsCodeJson(target.path);
|
|
340
|
+
status = out.mode === "parsed" ? "installed" : "installed";
|
|
341
|
+
} else if (target.format === "toml") {
|
|
342
|
+
const out = injectToml(target.path);
|
|
343
|
+
status = out.changed ? "installed" : "already present";
|
|
344
|
+
} else {
|
|
345
|
+
throw new Error(`Unsupported format: ${target.format}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
result.writes.push({
|
|
349
|
+
path: target.path,
|
|
350
|
+
status,
|
|
351
|
+
label: target.label,
|
|
352
|
+
});
|
|
353
|
+
} catch (e) {
|
|
354
|
+
result.errors.push(`${target.label}: ${e.message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Uninstall SpecLock MCP server from a single client.
|
|
363
|
+
*/
|
|
364
|
+
export function uninstallForClient(client, projectRoot = process.cwd(), options = {}) {
|
|
365
|
+
const includeProject = options.includeProject !== false;
|
|
366
|
+
const result = { client, writes: [], errors: [] };
|
|
367
|
+
|
|
368
|
+
let paths;
|
|
369
|
+
try {
|
|
370
|
+
paths = getClientConfigPaths(client, projectRoot);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
result.errors.push(e.message);
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const targets = [paths.primary];
|
|
377
|
+
if (includeProject && paths.project) targets.push(paths.project);
|
|
378
|
+
|
|
379
|
+
for (const target of targets) {
|
|
380
|
+
if (!target) continue;
|
|
381
|
+
if (!fs.existsSync(target.path)) {
|
|
382
|
+
result.writes.push({
|
|
383
|
+
path: target.path,
|
|
384
|
+
status: "not installed",
|
|
385
|
+
label: target.label,
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
let changed = false;
|
|
392
|
+
if (target.format === "json") {
|
|
393
|
+
const current = readJsonSafe(target.path) || {};
|
|
394
|
+
const out = removeJson(current);
|
|
395
|
+
if (out.changed) {
|
|
396
|
+
writeJson(target.path, out.config);
|
|
397
|
+
changed = true;
|
|
398
|
+
}
|
|
399
|
+
} else if (target.format === "vscode-json") {
|
|
400
|
+
const out = removeVsCodeJson(target.path);
|
|
401
|
+
changed = out.changed;
|
|
402
|
+
} else if (target.format === "toml") {
|
|
403
|
+
const out = removeToml(target.path);
|
|
404
|
+
changed = out.changed;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
result.writes.push({
|
|
408
|
+
path: target.path,
|
|
409
|
+
status: changed ? "removed" : "not installed",
|
|
410
|
+
label: target.label,
|
|
411
|
+
});
|
|
412
|
+
} catch (e) {
|
|
413
|
+
result.errors.push(`${target.label}: ${e.message}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Install across all supported clients at once.
|
|
422
|
+
*/
|
|
423
|
+
export function installAll(projectRoot = process.cwd(), options = {}) {
|
|
424
|
+
const clients = SUPPORTED_CLIENTS.filter((c) => c !== "all");
|
|
425
|
+
const results = [];
|
|
426
|
+
for (const c of clients) {
|
|
427
|
+
results.push(installForClient(c, projectRoot, options));
|
|
428
|
+
}
|
|
429
|
+
return results;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function uninstallAll(projectRoot = process.cwd(), options = {}) {
|
|
433
|
+
const clients = SUPPORTED_CLIENTS.filter((c) => c !== "all");
|
|
434
|
+
const results = [];
|
|
435
|
+
for (const c of clients) {
|
|
436
|
+
results.push(uninstallForClient(c, projectRoot, options));
|
|
437
|
+
}
|
|
438
|
+
return results;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Format an install/uninstall result for console output.
|
|
443
|
+
*/
|
|
444
|
+
export function formatResult(result, action = "install") {
|
|
445
|
+
const lines = [];
|
|
446
|
+
const hasErrors = result.errors && result.errors.length > 0;
|
|
447
|
+
const verb = action === "install" ? "added to" : "removed from";
|
|
448
|
+
|
|
449
|
+
for (const w of result.writes) {
|
|
450
|
+
if (w.status === "installed") {
|
|
451
|
+
lines.push(` [OK] SpecLock ${verb} ${w.label} config at: ${w.path}`);
|
|
452
|
+
} else if (w.status === "removed") {
|
|
453
|
+
lines.push(` [OK] SpecLock ${verb} ${w.label} config at: ${w.path}`);
|
|
454
|
+
} else if (w.status === "already present") {
|
|
455
|
+
lines.push(` [--] SpecLock already present in ${w.label}: ${w.path}`);
|
|
456
|
+
} else if (w.status === "not installed") {
|
|
457
|
+
lines.push(` [--] SpecLock not present in ${w.label}: ${w.path}`);
|
|
458
|
+
} else {
|
|
459
|
+
lines.push(` [??] ${w.label}: ${w.status} — ${w.path}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (hasErrors) {
|
|
464
|
+
for (const e of result.errors) {
|
|
465
|
+
lines.push(` [!!] ${e}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Next-steps hint shown after a successful install.
|
|
474
|
+
*/
|
|
475
|
+
export function nextStepsFor(client) {
|
|
476
|
+
const hints = {
|
|
477
|
+
"claude-code": "Restart Claude Code to activate SpecLock.",
|
|
478
|
+
cursor: "Restart Cursor (Cmd/Ctrl+Shift+P → Reload Window) to activate SpecLock.",
|
|
479
|
+
windsurf: "Restart Windsurf to activate SpecLock.",
|
|
480
|
+
cline: "Reload VS Code (Cmd/Ctrl+Shift+P → Developer: Reload Window) to activate SpecLock in Cline.",
|
|
481
|
+
codex: "Restart Codex CLI to activate SpecLock.",
|
|
482
|
+
};
|
|
483
|
+
return hints[client] || "Restart your AI client to activate SpecLock.";
|
|
484
|
+
}
|