sparda-mcp 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +161 -40
  2. package/SKILL.md +144 -0
  3. package/demo-app/package.json +7 -0
  4. package/demo-app/src/app.js +28 -0
  5. package/demo-app/src/routes/users.js +8 -0
  6. package/package.json +17 -3
  7. package/src/commands/demo.js +154 -0
  8. package/src/commands/doctor.js +52 -18
  9. package/src/commands/hook.js +21 -5
  10. package/src/commands/init.js +137 -36
  11. package/src/commands/remove.js +46 -10
  12. package/src/commands/report.js +372 -0
  13. package/src/commands/sync.js +51 -9
  14. package/src/detect.js +109 -19
  15. package/src/generator/express.js +88 -29
  16. package/src/generator/fastapi.js +66 -20
  17. package/src/generator/manifest.js +20 -13
  18. package/src/generator/nextjs.js +113 -0
  19. package/src/index.js +22 -2
  20. package/src/parser/express.js +144 -44
  21. package/src/parser/fastapi.js +13 -4
  22. package/src/parser/nextjs.js +248 -0
  23. package/src/probe/express-shim-esm.mjs +2 -2
  24. package/src/probe/express-shim.cjs +65 -22
  25. package/src/probe/integrate.js +34 -12
  26. package/src/probe/probe.js +59 -33
  27. package/src/probe/reconcile.js +24 -22
  28. package/src/security/sanitize.js +5 -1
  29. package/src/server/condenser.js +34 -12
  30. package/src/server/confirmation.js +75 -19
  31. package/src/server/context-carrier.js +36 -31
  32. package/src/server/crystallize.js +32 -9
  33. package/src/server/engine.js +118 -38
  34. package/src/server/idle.js +20 -4
  35. package/src/server/persistence.js +16 -5
  36. package/src/server/stdio.js +491 -163
  37. package/src/ui/style.js +30 -18
  38. package/templates/fastapi-router.txt +41 -13
  39. package/templates/nextjs-router.txt +383 -0
@@ -7,7 +7,10 @@ import { detectStack } from '../detect.js';
7
7
  // gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
8
8
  export async function runDoctor(opts) {
9
9
  let healthy = true;
10
- const fail = (msg) => { healthy = false; console.log(msg); };
10
+ const fail = (msg) => {
11
+ healthy = false;
12
+ console.log(msg);
13
+ };
11
14
 
12
15
  console.log('SPARDA doctor');
13
16
  const node = process.versions.node;
@@ -16,7 +19,9 @@ export async function runDoctor(opts) {
16
19
  let stack = null;
17
20
  try {
18
21
  stack = detectStack(opts.cwd);
19
- console.log(` ✓ Framework: ${stack.framework} — entry: ${stack.entryFile} — port: ${stack.port}`);
22
+ console.log(
23
+ ` ✓ Framework: ${stack.framework} — entry: ${stack.entryFile} — port: ${stack.port}`,
24
+ );
20
25
  } catch (e) {
21
26
  fail(` ✗ ${e.message}`);
22
27
  }
@@ -25,46 +30,75 @@ export async function runDoctor(opts) {
25
30
  try {
26
31
  const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
27
32
  const tools = Object.values(m.tools ?? {});
28
- console.log(` ✓ sparda.json valid (${tools.length} tools, ${tools.filter((t) => t.enabled).length} enabled)`);
33
+ console.log(
34
+ ` ✓ sparda.json valid (${tools.length} tools, ${tools.filter((t) => t.enabled).length} enabled)`,
35
+ );
29
36
 
30
37
  if (m.semantic) {
31
- console.log(` ✓ Semantic cache: ${Object.keys(m.semantic.descriptions ?? {}).length} descriptions, ${(m.semantic.workflows ?? []).length} workflows`);
38
+ console.log(
39
+ ` ✓ Semantic cache: ${Object.keys(m.semantic.descriptions ?? {}).length} descriptions, ${(m.semantic.workflows ?? []).length} workflows`,
40
+ );
32
41
  } else {
33
- console.log(' · Semantic cache: empty — fills on first AI connection (needs MCP sampling)');
42
+ console.log(
43
+ ' · Semantic cache: empty — fills on first AI connection (needs MCP sampling)',
44
+ );
34
45
  }
35
46
  const antibodies = Object.keys(m.immune?.antibodies ?? {}).length;
36
- console.log(antibodies
37
- ? ` ✓ Immune memory: ${antibodies} antibod${antibodies > 1 ? 'ies' : 'y'} (cached diagnoses, zero-token on recurrence)`
38
- : ' · Immune memory: empty antibodies grow as new failures get diagnosed');
47
+ console.log(
48
+ antibodies
49
+ ? ` Immune memory: ${antibodies} antibod${antibodies > 1 ? 'ies' : 'y'} (cached diagnoses, zero-token on recurrence)`
50
+ : ' · Immune memory: empty — antibodies grow as new failures get diagnosed',
51
+ );
39
52
 
40
53
  if (stack) {
41
54
  const headers = { 'x-sparda-key': m.localKey };
42
55
  try {
43
- const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, { headers, signal: AbortSignal.timeout(1500) });
56
+ const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, {
57
+ headers,
58
+ signal: AbortSignal.timeout(1500),
59
+ });
44
60
  if (!r.ok) {
45
61
  fail(` ✗ Host app on :${m.port} answered ${r.status}`);
46
62
  } else {
47
- const s = await fetch(`http://127.0.0.1:${m.port}/mcp/stats`, { headers, signal: AbortSignal.timeout(1500) })
48
- .then((x) => (x.ok ? x.json() : null)).catch(() => null);
63
+ const s = await fetch(`http://127.0.0.1:${m.port}/mcp/stats`, {
64
+ headers,
65
+ signal: AbortSignal.timeout(1500),
66
+ })
67
+ .then((x) => (x.ok ? x.json() : null))
68
+ .catch(() => null);
49
69
  if (s) {
50
- const totals = Object.values(s.stats ?? {}).reduce((a, t) => ({ calls: a.calls + t.calls, errors: a.errors + t.errors }), { calls: 0, errors: 0 });
51
- console.log(` ✓ Host app reachable on :${m.port} uptime ${s.uptimeSec}s, ${totals.calls} calls, ${totals.errors} errors`);
70
+ const totals = Object.values(s.stats ?? {}).reduce(
71
+ (a, t) => ({ calls: a.calls + t.calls, errors: a.errors + t.errors }),
72
+ { calls: 0, errors: 0 },
73
+ );
74
+ console.log(
75
+ ` ✓ Host app reachable on :${m.port} — uptime ${s.uptimeSec}s, ${totals.calls} calls, ${totals.errors} errors`,
76
+ );
52
77
  const quarantined = Object.entries(s.quarantine ?? {});
53
78
  if (quarantined.length) {
54
- for (const [tool, q] of quarantined) fail(` ✗ Quarantined: ${tool} (${q.reason}) — immune system is protecting this route`);
79
+ for (const [tool, q] of quarantined)
80
+ fail(
81
+ ` ✗ Quarantined: ${tool} (${q.reason}) — immune system is protecting this route`,
82
+ );
55
83
  } else {
56
84
  console.log(' ✓ Quarantine: empty — no route is currently sick');
57
85
  }
58
86
  } else {
59
- console.log(` ✓ Host app reachable on :${m.port}, router responding (stats unavailable — router predates v0.3, re-run \`npx sparda-mcp init\`)`);
87
+ console.log(
88
+ ` ✓ Host app reachable on :${m.port}, router responding (stats unavailable — router predates v0.3, re-run \`npx sparda-mcp init\`)`,
89
+ );
60
90
  }
61
91
  }
62
92
  } catch {
63
- const startCmd = m.framework === 'express' ? 'npm run dev' : 'fastapi dev';
64
- fail(` ✗ Host app on :${m.port} — NOT reachable\n → start it with: ${startCmd}`);
93
+ const startCmd = m.framework === 'fastapi' ? 'fastapi dev' : 'npm run dev';
94
+ fail(
95
+ ` ✗ Host app on :${m.port} — NOT reachable\n → start it with: ${startCmd}`,
96
+ );
65
97
  }
66
98
  }
67
- } catch { fail(' ✗ sparda.json invalid JSON'); }
99
+ } catch {
100
+ fail(' ✗ sparda.json invalid JSON');
101
+ }
68
102
  } else {
69
103
  console.log(' · sparda.json not found (run `npx sparda-mcp init`)');
70
104
  }
@@ -7,7 +7,10 @@ const MARKER = '# sparda-sentinel';
7
7
  export async function runHook(opts) {
8
8
  const gitDir = path.join(opts.cwd, '.git');
9
9
  if (!fs.existsSync(gitDir)) {
10
- throw Object.assign(new Error('Not a git repository.'), { code: 'USER', hint: 'Run this from your project root (where .git lives).' });
10
+ throw Object.assign(new Error('Not a git repository.'), {
11
+ code: 'USER',
12
+ hint: 'Run this from your project root (where .git lives).',
13
+ });
11
14
  }
12
15
  const hooksDir = path.join(gitDir, 'hooks');
13
16
  fs.mkdirSync(hooksDir, { recursive: true });
@@ -25,7 +28,9 @@ export async function runHook(opts) {
25
28
  fs.writeFileSync(hookPath, `#!/bin/sh\n${line}`);
26
29
  }
27
30
  fs.chmodSync(hookPath, 0o755);
28
- console.error('[sparda] sentinel installed: routes re-sync after every commit (post-commit hook).');
31
+ console.error(
32
+ '[sparda] sentinel installed: routes re-sync after every commit (post-commit hook).',
33
+ );
29
34
  }
30
35
 
31
36
  // Uninstall exactly what runHook installed (hard rule #4 applied to .git/hooks):
@@ -35,16 +40,27 @@ export async function runHook(opts) {
35
40
  export function removeHook(cwd) {
36
41
  const hookPath = path.join(cwd, '.git', 'hooks', 'post-commit');
37
42
  let content;
38
- try { content = fs.readFileSync(hookPath, 'utf8'); } catch { return false; }
43
+ try {
44
+ content = fs.readFileSync(hookPath, 'utf8');
45
+ } catch {
46
+ return false;
47
+ }
39
48
  if (!content.includes(MARKER)) return false;
40
49
  const block = `${MARKER}\nnpx --no-install sparda-mcp sync --quiet || true\n`;
41
50
  if (content === `#!/bin/sh\n${block}`) {
42
51
  fs.rmSync(hookPath);
43
52
  return true;
44
53
  }
45
- let out = content.includes(`\n${block}`) ? content.replace(`\n${block}`, '') : content.replace(block, '');
54
+ let out = content.includes(`\n${block}`)
55
+ ? content.replace(`\n${block}`, '')
56
+ : content.replace(block, '');
46
57
  if (out.includes(MARKER)) {
47
- out = out.split('\n').filter((l) => l !== MARKER && l !== 'npx --no-install sparda-mcp sync --quiet || true').join('\n');
58
+ out = out
59
+ .split('\n')
60
+ .filter(
61
+ (l) => l !== MARKER && l !== 'npx --no-install sparda-mcp sync --quiet || true',
62
+ )
63
+ .join('\n');
48
64
  }
49
65
  fs.writeFileSync(hookPath, out);
50
66
  return true;
@@ -5,13 +5,16 @@ import * as p from '@clack/prompts';
5
5
  import { detectStack } from '../detect.js';
6
6
  import { parseExpressProject } from '../parser/express.js';
7
7
  import { parseFastAPIProject } from '../parser/fastapi.js';
8
+ import { parseNextProject } from '../parser/nextjs.js';
8
9
  import { sanitizeDescription } from '../security/sanitize.js';
9
10
  import { generateExpress } from '../generator/express.js';
10
11
  import { generateFastAPI } from '../generator/fastapi.js';
12
+ import { generateNext } from '../generator/nextjs.js';
11
13
  import { c, gradient, colorizeJson } from '../ui/style.js';
12
14
 
13
-
14
- const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
15
+ const VERSION = JSON.parse(
16
+ fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
17
+ ).version;
15
18
 
16
19
  export async function runInit(opts) {
17
20
  const t0 = Date.now();
@@ -20,20 +23,31 @@ export async function runInit(opts) {
20
23
  const s = p.spinner();
21
24
  s.start('Scanning project...');
22
25
  const stack = detectStack(opts.cwd);
23
-
26
+
24
27
  let routes, skipped, entryAppVars;
25
28
  let dynamicGaps = [];
26
29
  if (stack.framework === 'express') {
27
30
  const res = parseExpressProject(opts.cwd, stack.entryFile);
28
31
  routes = res.routes;
29
32
  skipped = res.skipped;
30
- s.stop(`Stack detected: ${c.cyan(`Express (${stack.moduleType.toUpperCase()})`)} — entry: ${stack.entryFile}, port: ${stack.port}`);
33
+ s.stop(
34
+ `Stack detected: ${c.cyan(`Express (${stack.moduleType.toUpperCase()})`)} — entry: ${stack.entryFile}, port: ${stack.port}`,
35
+ );
31
36
  } else if (stack.framework === 'fastapi') {
32
37
  const res = parseFastAPIProject(opts.cwd, stack.entryFile, stack.pythonCmd);
33
38
  routes = res.routes;
34
39
  skipped = res.skipped;
35
40
  entryAppVars = res.entryAppVars;
36
- s.stop(`Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`);
41
+ s.stop(
42
+ `Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`,
43
+ );
44
+ } else if (stack.framework === 'nextjs') {
45
+ const res = parseNextProject(opts.cwd, stack.entryFile);
46
+ routes = res.routes;
47
+ skipped = res.skipped;
48
+ s.stop(
49
+ `Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
50
+ );
37
51
  }
38
52
 
39
53
  // Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
@@ -41,22 +55,32 @@ export async function runInit(opts) {
41
55
  // behavior byte-identical. Executing the host app has side-effects, so it is
42
56
  // gated behind --probe, warned on stderr, and degrades to static-only on any
43
57
  // failure — a probe error must never break init (R1).
44
- if (opts.probe) {
45
- p.log.warn('--probe runs your app to observe routes the static scan missed. Use only on code you trust (it triggers your app\'s import side-effects).');
58
+ if (opts.probe && stack.framework === 'nextjs') {
59
+ p.log.warn(
60
+ '--probe is not supported on Next.js (file-based routing needs no runtime probe).',
61
+ );
62
+ } else if (opts.probe) {
63
+ p.log.warn(
64
+ "--probe runs your app to observe routes the static scan missed. Use only on code you trust (it triggers your app's import side-effects).",
65
+ );
46
66
  try {
47
67
  const { discoverDynamicRoutes } = await import('../probe/integrate.js');
48
68
  const { added, gaps, probedCount } = await discoverDynamicRoutes({
49
- framework: stack.framework,
50
- entryFile: path.resolve(opts.cwd, stack.entryFile),
51
- projectRoot: opts.cwd,
69
+ framework: stack.framework,
70
+ entryFile: path.resolve(opts.cwd, stack.entryFile),
71
+ projectRoot: opts.cwd,
52
72
  staticRoutes: routes,
53
73
  });
54
74
  dynamicGaps = gaps;
55
75
  if (added.length) {
56
76
  routes.push(...added);
57
- p.log.success(`Probe added ${added.length} dynamic route(s) the static scan missed (${probedCount} observed at runtime).`);
77
+ p.log.success(
78
+ `Probe added ${added.length} dynamic route(s) the static scan missed (${probedCount} observed at runtime).`,
79
+ );
58
80
  } else {
59
- p.log.info(`Probe observed ${probedCount} route(s); the static scan already covered them.`);
81
+ p.log.info(
82
+ `Probe observed ${probedCount} route(s); the static scan already covered them.`,
83
+ );
60
84
  }
61
85
  } catch (err) {
62
86
  p.log.warn(`Probe failed (${err.message}); continuing with static routes only.`);
@@ -64,65 +88,142 @@ export async function runInit(opts) {
64
88
  }
65
89
 
66
90
  if (routes.length === 0) {
67
- throw Object.assign(new Error('0 routes extracted.'), { code: 'USER', hint: `SPARDA found ${stack.framework === 'express' ? 'Express' : 'FastAPI'} but no literal-path routes. Dynamic routing is not supported in v0.` });
91
+ throw Object.assign(new Error('0 routes extracted.'), {
92
+ code: 'USER',
93
+ hint:
94
+ stack.framework === 'nextjs'
95
+ ? `SPARDA found Next.js but no route.js handlers under ${stack.entryFile}/. Page components are not API routes — SPARDA exposes route handlers only.`
96
+ : `SPARDA found ${stack.framework === 'express' ? 'Express' : 'FastAPI'} but no literal-path routes. Dynamic routing is not supported in v0.`,
97
+ });
68
98
  }
69
99
 
70
100
  let flaggedCount = 0;
71
101
  for (const r of routes) {
72
- const { text, flagged } = sanitizeDescription(r.description, `${r.method.toUpperCase()} ${r.path}`);
102
+ const { text, flagged } = sanitizeDescription(
103
+ r.description,
104
+ `${r.method.toUpperCase()} ${r.path}`,
105
+ );
73
106
  r.description = text;
74
107
  if (flagged) flaggedCount++;
75
108
  }
76
109
 
77
110
  const high = routes.filter((r) => r.confidence === 'high').length;
78
- p.log.info(`${routes.length} routes found — ${high} high confidence, ${routes.length - high} partial`);
79
-
80
- const preview = routes.slice(0, 8).map((r) =>
81
- `${r.mutating ? c.red('✗') : c.green('✓')} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? c.dim(' (disabled: write-safety)') : ''}`
82
- ).join('\n');
83
- p.note(preview + (routes.length > 8 ? c.dim(`\n... (+${routes.length - 8} more)`) : ''), 'TOOLS TO GENERATE');
84
-
85
- if (flaggedCount) p.log.warn(`${flaggedCount} suspicious docstring(s) purged (prompt-injection defense)`);
86
- if (skipped.length) p.log.warn(`${skipped.length} route(s) skipped details in .sparda/scan-report.json`);
111
+ p.log.info(
112
+ `${routes.length} routes found — ${high} high confidence, ${routes.length - high} partial`,
113
+ );
114
+
115
+ const preview = routes
116
+ .slice(0, 8)
117
+ .map(
118
+ (r) =>
119
+ `${r.mutating ? c.red('✗') : c.green('✓')} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? c.dim(' (disabled: write-safety)') : ''}`,
120
+ )
121
+ .join('\n');
122
+ p.note(
123
+ preview + (routes.length > 8 ? c.dim(`\n... (+${routes.length - 8} more)`) : ''),
124
+ 'TOOLS TO GENERATE',
125
+ );
126
+
127
+ if (flaggedCount)
128
+ p.log.warn(
129
+ `${flaggedCount} suspicious docstring(s) purged (prompt-injection defense)`,
130
+ );
131
+ if (skipped.length)
132
+ p.log.warn(
133
+ `${skipped.length} route(s) skipped — details in .sparda/scan-report.json`,
134
+ );
87
135
 
88
136
  let inject = true;
89
- if (!opts.yes) {
137
+ // Next.js is file-based: SPARDA adds one route-handler file and touches
138
+ // nothing else — there is no injection decision to make.
139
+ if (!opts.yes && stack.framework !== 'nextjs') {
90
140
  const answer = await p.select({
91
141
  message: `Inject the MCP router into ${stack.entryFile}?`,
92
142
  options: [
93
- { value: true, label: 'Yes, inject (adds a marked block, reversible via `sparda remove`)' },
143
+ {
144
+ value: true,
145
+ label: 'Yes, inject (adds a marked block, reversible via `sparda remove`)',
146
+ },
94
147
  { value: false, label: 'No, generate files only (I will add 2 lines manually)' },
95
148
  ],
96
149
  });
97
- if (p.isCancel(answer)) { p.cancel('Aborted.'); process.exit(1); }
150
+ if (p.isCancel(answer)) {
151
+ p.cancel('Aborted.');
152
+ process.exit(1);
153
+ }
98
154
  inject = answer;
99
155
  }
100
156
 
101
- const result = stack.framework === 'express'
102
- ? generateExpress({ cwd: opts.cwd, entryFile: stack.entryFile, moduleType: stack.moduleType, port: stack.port, routes })
103
- : generateFastAPI({ cwd: opts.cwd, entryFile: stack.entryFile, port: stack.port, routes, entryAppVars, pythonCmd: stack.pythonCmd });
157
+ const result =
158
+ stack.framework === 'express'
159
+ ? generateExpress({
160
+ cwd: opts.cwd,
161
+ entryFile: stack.entryFile,
162
+ moduleType: stack.moduleType,
163
+ port: stack.port,
164
+ routes,
165
+ })
166
+ : stack.framework === 'nextjs'
167
+ ? generateNext({
168
+ cwd: opts.cwd,
169
+ appDir: stack.entryFile,
170
+ port: stack.port,
171
+ routes,
172
+ })
173
+ : generateFastAPI({
174
+ cwd: opts.cwd,
175
+ entryFile: stack.entryFile,
176
+ port: stack.port,
177
+ routes,
178
+ entryAppVars,
179
+ pythonCmd: stack.pythonCmd,
180
+ });
104
181
 
105
182
  fs.mkdirSync(path.join(opts.cwd, '.sparda'), { recursive: true });
106
183
  const scanReport = { routes, skipped };
107
- if (opts.probe) scanReport.dynamicGaps = dynamicGaps; // provenance, only when probed
108
- fs.writeFileSync(path.join(opts.cwd, '.sparda', 'scan-report.json'), JSON.stringify(scanReport, null, 2));
184
+ if (opts.probe) scanReport.dynamicGaps = dynamicGaps; // provenance, only when probed
185
+ fs.writeFileSync(
186
+ path.join(opts.cwd, '.sparda', 'scan-report.json'),
187
+ JSON.stringify(scanReport, null, 2),
188
+ );
109
189
 
110
190
  p.log.success(`Generated ${result.routerFile}`);
111
- if (inject && result.injection.injected) p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
191
+ if (result.injection.fileBased)
192
+ p.log.success(
193
+ 'File-based injection: your code was not modified (remove deletes the file)',
194
+ );
195
+ else if (inject && result.injection.injected)
196
+ p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
112
197
  else if (result.injection.manual) {
113
- p.note(result.injection.manual.join('\n'), `Add these lines to ${stack.entryFile} (after FastAPI/express instantiation)`);
198
+ p.note(
199
+ result.injection.manual.join('\n'),
200
+ `Add these lines to ${stack.entryFile} (after FastAPI/express instantiation)`,
201
+ );
114
202
  }
115
203
  p.log.success('Wrote sparda.json');
116
204
 
117
- const cfg = JSON.stringify({ [path.basename(opts.cwd)]: { command: 'npx', args: ['sparda-mcp', 'dev'], cwd: opts.cwd } }, null, 2);
205
+ const cfg = JSON.stringify(
206
+ {
207
+ [path.basename(opts.cwd)]: {
208
+ command: 'npx',
209
+ args: ['sparda-mcp', 'dev'],
210
+ cwd: opts.cwd,
211
+ },
212
+ },
213
+ null,
214
+ 2,
215
+ );
118
216
  p.outro(`${c.green(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)}
119
217
 
120
218
  Next steps:
121
- 1. Start your app: ${c.cyan(stack.framework === 'express' ? 'npm run dev' : 'fastapi dev')}
219
+ 1. Start your app: ${c.cyan(stack.framework === 'fastapi' ? 'fastapi dev' : 'npm run dev')}
122
220
  2. Start the MCP bridge: ${c.cyan('npx sparda-mcp dev')}
123
221
  3. Add to Claude Desktop config (claude_desktop_config.json):
124
222
 
125
- ${colorizeJson(cfg).split('\n').map((l) => ' ' + l).join('\n')}
223
+ ${colorizeJson(cfg)
224
+ .split('\n')
225
+ .map((l) => ' ' + l)
226
+ .join('\n')}
126
227
 
127
228
  ${c.dim('Write tools (POST/PUT/DELETE) are disabled by default.')}
128
229
  ${c.dim('Enable them in sparda.json, then re-run')} ${c.cyan('`npx sparda-mcp init --yes`')}${c.dim('.')}`);
@@ -10,17 +10,28 @@ import { removeHook } from './hook.js';
10
10
  export async function runRemove(opts) {
11
11
  const manifestPath = path.join(opts.cwd, 'sparda.json');
12
12
  if (!fs.existsSync(manifestPath)) {
13
- throw Object.assign(new Error('sparda.json not found — nothing to remove.'), { code: 'USER' });
13
+ throw Object.assign(new Error('sparda.json not found — nothing to remove.'), {
14
+ code: 'USER',
15
+ });
14
16
  }
15
17
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
16
18
 
17
19
  if (!opts.yes) {
18
20
  // clack crashes with uv_tty_init EBADF when stdin is not a TTY (CI, pipes)
19
21
  if (!process.stdin.isTTY) {
20
- throw Object.assign(new Error('Cannot ask for confirmation: stdin is not a TTY.'), { code: 'USER', hint: 'Re-run with --yes to skip the prompt.' });
22
+ throw Object.assign(new Error('Cannot ask for confirmation: stdin is not a TTY.'), {
23
+ code: 'USER',
24
+ hint: 'Re-run with --yes to skip the prompt.',
25
+ });
26
+ }
27
+ const ok = await p.confirm({
28
+ message:
29
+ 'Remove SPARDA from this project? (deletes generated files + injected block)',
30
+ });
31
+ if (p.isCancel(ok) || !ok) {
32
+ p.cancel('Aborted.');
33
+ process.exit(1);
21
34
  }
22
- const ok = await p.confirm({ message: 'Remove SPARDA from this project? (deletes generated files + injected block)' });
23
- if (p.isCancel(ok) || !ok) { p.cancel('Aborted.'); process.exit(1); }
24
35
  }
25
36
 
26
37
  let pythonCmd = 'python';
@@ -31,19 +42,44 @@ export async function runRemove(opts) {
31
42
  // ignore
32
43
  }
33
44
 
34
- const results = manifest.framework === 'fastapi'
35
- ? removeFastAPI(opts.cwd, manifest, pythonCmd)
36
- : removeExpress(opts.cwd, manifest);
45
+ const results =
46
+ manifest.framework === 'fastapi'
47
+ ? removeFastAPI(opts.cwd, manifest, pythonCmd)
48
+ : removeExpress(opts.cwd, manifest);
37
49
 
38
50
  for (const r of results) {
39
51
  if (r.ok) console.log(`✓ Removed injection from ${r.file} (file still parses)`);
40
- else console.log(`✗ Could not safely remove from ${r.file} — restore from .sparda/backup/`);
52
+ else
53
+ console.log(
54
+ `✗ Could not safely remove from ${r.file} — restore from .sparda/backup/`,
55
+ );
41
56
  }
42
57
  for (const f of manifest.generatedFiles ?? []) {
43
58
  const abs = path.resolve(opts.cwd, f);
44
- if (fs.existsSync(abs)) { fs.rmSync(abs); console.log(`✓ Deleted ${f}`); }
59
+ if (fs.existsSync(abs)) {
60
+ fs.rmSync(abs);
61
+ console.log(`✓ Deleted ${f}`);
62
+ }
63
+ }
64
+ // nextjs: the generated route lives in its own directory chain
65
+ // (app/mcp/[...sparda]/) — prune the now-empty dirs up to the app root so
66
+ // the tree is byte-identical, not just the git diff.
67
+ if (manifest.framework === 'nextjs') {
68
+ const stopAt = path.resolve(opts.cwd, manifest.entryFile ?? '.');
69
+ for (const f of manifest.generatedFiles ?? []) {
70
+ let dir = path.dirname(path.resolve(opts.cwd, f));
71
+ while (dir.startsWith(stopAt) && dir !== stopAt) {
72
+ try {
73
+ fs.rmdirSync(dir); // throws if not empty — that is the stop condition
74
+ } catch {
75
+ break;
76
+ }
77
+ dir = path.dirname(dir);
78
+ }
79
+ }
45
80
  }
46
- if (revertGitignore(opts.cwd, manifest.gitignore)) console.log('✓ Reverted .gitignore edit');
81
+ if (revertGitignore(opts.cwd, manifest.gitignore))
82
+ console.log('✓ Reverted .gitignore edit');
47
83
  if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
48
84
  fs.rmSync(manifestPath);
49
85
  fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });