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.
- package/README.md +161 -40
- package/SKILL.md +144 -0
- package/demo-app/package.json +7 -0
- package/demo-app/src/app.js +28 -0
- package/demo-app/src/routes/users.js +8 -0
- package/package.json +17 -3
- package/src/commands/demo.js +154 -0
- package/src/commands/doctor.js +52 -18
- package/src/commands/hook.js +21 -5
- package/src/commands/init.js +137 -36
- package/src/commands/remove.js +46 -10
- package/src/commands/report.js +372 -0
- package/src/commands/sync.js +51 -9
- package/src/detect.js +109 -19
- package/src/generator/express.js +88 -29
- package/src/generator/fastapi.js +66 -20
- package/src/generator/manifest.js +20 -13
- package/src/generator/nextjs.js +113 -0
- package/src/index.js +22 -2
- package/src/parser/express.js +144 -44
- package/src/parser/fastapi.js +13 -4
- package/src/parser/nextjs.js +248 -0
- package/src/probe/express-shim-esm.mjs +2 -2
- package/src/probe/express-shim.cjs +65 -22
- package/src/probe/integrate.js +34 -12
- package/src/probe/probe.js +59 -33
- package/src/probe/reconcile.js +24 -22
- package/src/security/sanitize.js +5 -1
- package/src/server/condenser.js +34 -12
- package/src/server/confirmation.js +75 -19
- package/src/server/context-carrier.js +36 -31
- package/src/server/crystallize.js +32 -9
- package/src/server/engine.js +118 -38
- package/src/server/idle.js +20 -4
- package/src/server/persistence.js +16 -5
- package/src/server/stdio.js +491 -163
- package/src/ui/style.js +30 -18
- package/templates/fastapi-router.txt +41 -13
- package/templates/nextjs-router.txt +383 -0
package/src/commands/doctor.js
CHANGED
|
@@ -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) => {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
37
|
-
|
|
38
|
-
|
|
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`, {
|
|
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`, {
|
|
48
|
-
|
|
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(
|
|
51
|
-
|
|
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)
|
|
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(
|
|
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 === '
|
|
64
|
-
fail(
|
|
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 {
|
|
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
|
}
|
package/src/commands/hook.js
CHANGED
|
@@ -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.'), {
|
|
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(
|
|
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 {
|
|
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}`)
|
|
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
|
|
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;
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
50
|
-
entryFile:
|
|
51
|
-
projectRoot:
|
|
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(
|
|
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(
|
|
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.'), {
|
|
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(
|
|
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(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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)) {
|
|
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 =
|
|
102
|
-
|
|
103
|
-
|
|
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;
|
|
108
|
-
fs.writeFileSync(
|
|
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 (
|
|
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(
|
|
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(
|
|
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 === '
|
|
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)
|
|
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('.')}`);
|
package/src/commands/remove.js
CHANGED
|
@@ -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.'), {
|
|
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.'), {
|
|
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 =
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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)) {
|
|
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))
|
|
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 });
|