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/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.
@@ -9,7 +9,7 @@
9
9
  import { readBrain, readEvents } from "./storage.js";
10
10
  import { verifyAuditChain } from "./audit.js";
11
11
 
12
- const VERSION = "5.5.3";
12
+ const VERSION = "5.5.5";
13
13
 
14
14
  // PHI-related keywords for HIPAA filtering
15
15
  const PHI_KEYWORDS = [
@@ -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
@@ -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
- const ruleFiles = discoverRuleFiles(root);
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
- lines.push(" Your rules are now ENFORCED, not just suggested.");
379
- lines.push(" AI agents that violate constraints will be blocked.");
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: checks staged files against active locks
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
 
@@ -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
+ }