speclock 5.5.2 → 5.5.4

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.
@@ -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
+ }
@@ -89,7 +89,7 @@
89
89
  <div class="header">
90
90
  <div>
91
91
  <h1><span>SpecLock</span> Dashboard</h1>
92
- <div class="meta">v5.5.2 &mdash; Your AI has rules. SpecLock makes them unbreakable.</div>
92
+ <div class="meta">v5.5.4 &mdash; Your AI has rules. SpecLock makes them unbreakable.</div>
93
93
  </div>
94
94
  <div style="display:flex;align-items:center;gap:12px;">
95
95
  <span id="health-badge" class="status-badge healthy">Loading...</span>
@@ -182,7 +182,7 @@
182
182
  </div>
183
183
 
184
184
  <div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
185
- SpecLock v5.5.2 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
185
+ SpecLock v5.5.4 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
186
186
  </div>
187
187
 
188
188
  <script>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * SpecLock MCP HTTP Server — for Railway / remote deployment
3
- * Wraps the same 22 tools as the stdio server using Streamable HTTP transport.
3
+ * Wraps the same 51 tools as the stdio server using Streamable HTTP transport.
4
4
  * Developed by Sandeep Roy (https://github.com/sgroy10)
5
5
  */
6
6
 
@@ -113,7 +113,7 @@ import { fileURLToPath } from "url";
113
113
  import _path from "path";
114
114
 
115
115
  const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
116
- const VERSION = "5.5.2";
116
+ const VERSION = "5.5.4";
117
117
  const AUTHOR = "Sandeep Roy";
118
118
  const START_TIME = Date.now();
119
119
 
@@ -901,7 +901,7 @@ app.get("/", (req, res) => {
901
901
  name: "speclock",
902
902
  version: VERSION,
903
903
  author: AUTHOR,
904
- description: "Stop AI from breaking code you told it not to touch. Enforces .cursorrules, CLAUDE.md, and AGENTS.md — not just suggests. Zero-config Guardian Mode, Universal Rules Sync, AI Patch Firewall, Spec Compiler, Code Graph, Drift Score, HMAC audit chain, SOC 2/HIPAA compliance. 51 MCP tools. 976 tests.",
904
+ description: "Stop AI from breaking code you told it not to touch. Enforces .cursorrules, CLAUDE.md, and AGENTS.md — not just suggests. Zero-config Guardian Mode, Universal Rules Sync, AI Patch Firewall, Spec Compiler, Code Graph, Drift Score, HMAC audit chain, SOC 2/HIPAA compliance. 51 MCP tools. 991 tests.",
905
905
  tools: 51,
906
906
  mcp_endpoint: "/mcp",
907
907
  health_endpoint: "/health",
package/src/mcp/server.js CHANGED
@@ -126,7 +126,7 @@ const PROJECT_ROOT =
126
126
  args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
127
127
 
128
128
  // --- MCP Server ---
129
- const VERSION = "5.5.2";
129
+ const VERSION = "5.5.4";
130
130
  const AUTHOR = "Sandeep Roy";
131
131
 
132
132
  const server = new McpServer(