sparda-mcp 0.5.2 → 0.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.
- package/README.md +158 -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 +25 -3
- package/src/commands/demo.js +154 -0
- package/src/commands/doctor.js +51 -17
- package/src/commands/hook.js +21 -5
- package/src/commands/init.js +105 -33
- package/src/commands/remove.js +29 -10
- package/src/commands/report.js +372 -0
- package/src/commands/sync.js +37 -8
- package/src/detect.js +68 -18
- package/src/generator/express.js +86 -28
- package/src/generator/fastapi.js +66 -20
- package/src/generator/manifest.js +20 -13
- package/src/index.js +22 -2
- package/src/parser/express.js +144 -44
- package/src/parser/fastapi.js +13 -4
- 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 +490 -162
- package/src/ui/style.js +30 -18
- package/templates/fastapi-router.txt +41 -13
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// commands/report.js — the readable black box.
|
|
2
|
+
// Renders everything the organism already remembers (sparda.json) plus, when
|
|
3
|
+
// the host is up, the live gauges (/mcp/stats) — as a terminal report, a
|
|
4
|
+
// self-contained HTML file, or raw JSON. Deterministic, zero new deps, and
|
|
5
|
+
// read-only: it never mutates the manifest and never touches the host routes.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { c, gradient } from '../ui/style.js';
|
|
9
|
+
|
|
10
|
+
// ── pure: manifest (+ optional live stats) → report data ──────────────────
|
|
11
|
+
|
|
12
|
+
export function buildReport(manifest, live = null) {
|
|
13
|
+
const tools = Object.entries(manifest.tools ?? {}).map(([name, t]) => ({
|
|
14
|
+
name,
|
|
15
|
+
method: (t.method ?? 'GET').toUpperCase(),
|
|
16
|
+
path: t.path ?? '',
|
|
17
|
+
enabled: Boolean(t.enabled),
|
|
18
|
+
}));
|
|
19
|
+
const writes = tools.filter((t) => t.method !== 'GET');
|
|
20
|
+
|
|
21
|
+
const events = manifest.sparding?.events ?? [];
|
|
22
|
+
const byDecision = {};
|
|
23
|
+
for (const e of events) byDecision[e.decision] = (byDecision[e.decision] ?? 0) + 1;
|
|
24
|
+
|
|
25
|
+
const failures = Object.entries(manifest.sparding?.failures ?? {})
|
|
26
|
+
.map(([sig, f]) => ({ sig, count: f.count ?? 0, lesson: f.lesson ?? '' }))
|
|
27
|
+
.sort((a, b) => b.count - a.count);
|
|
28
|
+
|
|
29
|
+
const antibodies = Object.entries(manifest.immune?.antibodies ?? {})
|
|
30
|
+
.map(([sig, a]) => ({
|
|
31
|
+
sig,
|
|
32
|
+
diagnosis: a.diagnosis ?? '',
|
|
33
|
+
hits: a.hits ?? 0,
|
|
34
|
+
lastSeen: a.lastSeen ?? null,
|
|
35
|
+
}))
|
|
36
|
+
.sort((a, b) => b.hits - a.hits);
|
|
37
|
+
const antibodyHits = antibodies.reduce((n, a) => n + a.hits, 0);
|
|
38
|
+
|
|
39
|
+
const circuits = Object.entries(manifest.labs?.circuits ?? {}).map(([key, cir]) => ({
|
|
40
|
+
key,
|
|
41
|
+
seen: cir.seen ?? 0,
|
|
42
|
+
composite: cir.composite
|
|
43
|
+
? { name: cir.composite.name, description: cir.composite.description ?? '' }
|
|
44
|
+
: null,
|
|
45
|
+
}));
|
|
46
|
+
const composites = circuits.filter((x) => x.composite);
|
|
47
|
+
|
|
48
|
+
let liveSection = null;
|
|
49
|
+
if (live) {
|
|
50
|
+
const perTool = Object.entries(live.stats ?? {}).map(([name, s]) => ({
|
|
51
|
+
name,
|
|
52
|
+
calls: s.calls ?? 0,
|
|
53
|
+
errors: s.errors ?? 0,
|
|
54
|
+
avgMs: s.calls ? Math.round((s.totalMs ?? 0) / s.calls) : 0,
|
|
55
|
+
}));
|
|
56
|
+
const totals = perTool.reduce(
|
|
57
|
+
(a, t) => ({ calls: a.calls + t.calls, errors: a.errors + t.errors }),
|
|
58
|
+
{ calls: 0, errors: 0 },
|
|
59
|
+
);
|
|
60
|
+
const purityCount = {};
|
|
61
|
+
for (const p of Object.values(live.purity ?? {}))
|
|
62
|
+
purityCount[p.class ?? 'unknown'] = (purityCount[p.class ?? 'unknown'] ?? 0) + 1;
|
|
63
|
+
liveSection = {
|
|
64
|
+
uptimeSec: live.uptimeSec ?? 0,
|
|
65
|
+
totals,
|
|
66
|
+
topTools: perTool.sort((a, b) => b.calls - a.calls).slice(0, 5),
|
|
67
|
+
quarantine: Object.entries(live.quarantine ?? {}).map(([tool, q]) => ({
|
|
68
|
+
tool,
|
|
69
|
+
reason: q.reason ?? '',
|
|
70
|
+
})),
|
|
71
|
+
recycle: live.recycle ?? null,
|
|
72
|
+
purityCount,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
generatedAt: new Date().toISOString(),
|
|
78
|
+
meta: {
|
|
79
|
+
framework: manifest.framework ?? 'unknown',
|
|
80
|
+
entryFile: manifest.entryFile ?? '',
|
|
81
|
+
port: manifest.port ?? null,
|
|
82
|
+
createdAt: manifest.createdAt ?? null,
|
|
83
|
+
},
|
|
84
|
+
tools: {
|
|
85
|
+
total: tools.length,
|
|
86
|
+
enabled: tools.filter((t) => t.enabled).length,
|
|
87
|
+
writes: writes.length,
|
|
88
|
+
writesEnabled: writes.filter((t) => t.enabled).length,
|
|
89
|
+
},
|
|
90
|
+
semantic: {
|
|
91
|
+
descriptions: Object.keys(manifest.semantic?.descriptions ?? {}).length,
|
|
92
|
+
workflows: (manifest.semantic?.workflows ?? []).length,
|
|
93
|
+
},
|
|
94
|
+
proof: {
|
|
95
|
+
events: events.length,
|
|
96
|
+
byDecision,
|
|
97
|
+
lastEventAt: events.length ? events[events.length - 1].ts : null,
|
|
98
|
+
failures: failures.slice(0, 3),
|
|
99
|
+
failureCount: failures.length,
|
|
100
|
+
},
|
|
101
|
+
immunity: {
|
|
102
|
+
antibodies: antibodies.length,
|
|
103
|
+
hits: antibodyHits,
|
|
104
|
+
top: antibodies.slice(0, 3),
|
|
105
|
+
},
|
|
106
|
+
organs: {
|
|
107
|
+
circuits: circuits.length,
|
|
108
|
+
composites: composites.map((x) => x.composite),
|
|
109
|
+
},
|
|
110
|
+
live: liveSection,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── pure: report → terminal string (colors auto-disable off-TTY) ──────────
|
|
115
|
+
|
|
116
|
+
export function renderTerminal(r) {
|
|
117
|
+
const L = [];
|
|
118
|
+
const empty = (msg) => c.dim(` · ${msg}`);
|
|
119
|
+
L.push('');
|
|
120
|
+
L.push(` ${gradient('SPARDA')} ${c.dim('— black box report')}`);
|
|
121
|
+
L.push(
|
|
122
|
+
c.dim(
|
|
123
|
+
` ${r.meta.framework} · ${r.meta.entryFile}${r.meta.port ? ` · :${r.meta.port}` : ''} · ${r.generatedAt}`,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
L.push('');
|
|
127
|
+
|
|
128
|
+
L.push(c.bold(' Tools'));
|
|
129
|
+
L.push(
|
|
130
|
+
` ✓ ${r.tools.enabled}/${r.tools.total} exposed to AI — ${r.tools.writesEnabled}/${r.tools.writes} write tools opted in (writes are off by default)`,
|
|
131
|
+
);
|
|
132
|
+
if (r.semantic.descriptions || r.semantic.workflows)
|
|
133
|
+
L.push(
|
|
134
|
+
` ✓ semantic memory: ${r.semantic.descriptions} enriched descriptions, ${r.semantic.workflows} workflows`,
|
|
135
|
+
);
|
|
136
|
+
L.push('');
|
|
137
|
+
|
|
138
|
+
L.push(c.bold(' Proof journal') + c.dim(' (last 100 agent actions)'));
|
|
139
|
+
if (r.proof.events) {
|
|
140
|
+
const parts = Object.entries(r.proof.byDecision)
|
|
141
|
+
.map(([d, n]) => `${n} ${d}`)
|
|
142
|
+
.join(', ');
|
|
143
|
+
L.push(` ✓ ${r.proof.events} proofed invocations — ${parts}`);
|
|
144
|
+
if (r.proof.failureCount) {
|
|
145
|
+
L.push(
|
|
146
|
+
` ⚠ ${r.proof.failureCount} failure signature${r.proof.failureCount > 1 ? 's' : ''} with lessons:`,
|
|
147
|
+
);
|
|
148
|
+
for (const f of r.proof.failures)
|
|
149
|
+
L.push(c.dim(` ${f.sig} ×${f.count}${f.lesson ? ` — ${f.lesson}` : ''}`));
|
|
150
|
+
}
|
|
151
|
+
} else
|
|
152
|
+
L.push(
|
|
153
|
+
empty('no agent activity recorded yet — the journal fills as AI drives the app'),
|
|
154
|
+
);
|
|
155
|
+
L.push('');
|
|
156
|
+
|
|
157
|
+
L.push(c.bold(' Immune memory'));
|
|
158
|
+
if (r.immunity.antibodies) {
|
|
159
|
+
L.push(
|
|
160
|
+
` ✓ ${r.immunity.antibodies} antibod${r.immunity.antibodies > 1 ? 'ies' : 'y'} — ${r.immunity.hits} diagnoses served from memory (zero tokens)`,
|
|
161
|
+
);
|
|
162
|
+
for (const a of r.immunity.top)
|
|
163
|
+
L.push(
|
|
164
|
+
c.dim(
|
|
165
|
+
` ${a.sig} ×${a.hits}${a.diagnosis ? ` — ${a.diagnosis.slice(0, 80)}` : ''}`,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
} else L.push(empty('no antibodies yet — they grow when failures get diagnosed'));
|
|
169
|
+
L.push('');
|
|
170
|
+
|
|
171
|
+
L.push(c.bold(' Emergent organs') + c.dim(' (Labs)'));
|
|
172
|
+
if (r.organs.circuits) {
|
|
173
|
+
L.push(
|
|
174
|
+
` ✓ ${r.organs.circuits} circuit${r.organs.circuits > 1 ? 's' : ''} observed`,
|
|
175
|
+
);
|
|
176
|
+
for (const comp of r.organs.composites)
|
|
177
|
+
L.push(
|
|
178
|
+
` ✓ composite tool born: ${c.cyan(comp.name)}${comp.description ? c.dim(` — ${comp.description.slice(0, 70)}`) : ''}`,
|
|
179
|
+
);
|
|
180
|
+
} else
|
|
181
|
+
L.push(
|
|
182
|
+
empty(
|
|
183
|
+
'no circuits observed — enable labs.recordSequences to condense usage into tools',
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
L.push('');
|
|
187
|
+
|
|
188
|
+
L.push(c.bold(' Live gauges'));
|
|
189
|
+
if (r.live) {
|
|
190
|
+
L.push(
|
|
191
|
+
` ✓ host up ${r.live.uptimeSec}s — ${r.live.totals.calls} calls, ${r.live.totals.errors} errors`,
|
|
192
|
+
);
|
|
193
|
+
for (const t of r.live.topTools)
|
|
194
|
+
L.push(
|
|
195
|
+
c.dim(` ${t.name}: ${t.calls} calls · ${t.errors} errors · ~${t.avgMs}ms`),
|
|
196
|
+
);
|
|
197
|
+
if (r.live.recycle)
|
|
198
|
+
L.push(
|
|
199
|
+
` ✓ recycling: ${r.live.recycle.ratePct ?? 0}% served by the circle (${r.live.recycle.servedByCircle ?? 0} recycled vs ${r.live.recycle.paidFull ?? 0} full-price)`,
|
|
200
|
+
);
|
|
201
|
+
const pc = Object.entries(r.live.purityCount ?? {});
|
|
202
|
+
if (pc.length)
|
|
203
|
+
L.push(c.dim(` route purity: ${pc.map(([k, n]) => `${n} ${k}`).join(', ')}`));
|
|
204
|
+
if (r.live.quarantine.length)
|
|
205
|
+
for (const q of r.live.quarantine)
|
|
206
|
+
L.push(` ⚠ quarantined: ${q.tool} (${q.reason})`);
|
|
207
|
+
else L.push(' ✓ quarantine empty — no route is currently sick');
|
|
208
|
+
} else
|
|
209
|
+
L.push(
|
|
210
|
+
empty(
|
|
211
|
+
'host not running — showing persisted memory only (start the app for live gauges)',
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
L.push('');
|
|
215
|
+
return L.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── pure: report → self-contained HTML (no external assets, no scripts) ───
|
|
219
|
+
|
|
220
|
+
function esc(s) {
|
|
221
|
+
return String(s)
|
|
222
|
+
.replaceAll('&', '&')
|
|
223
|
+
.replaceAll('<', '<')
|
|
224
|
+
.replaceAll('>', '>')
|
|
225
|
+
.replaceAll('"', '"');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function renderHtml(r) {
|
|
229
|
+
const stat = (n, label) =>
|
|
230
|
+
`<div class="stat"><div class="n">${esc(n)}</div><div class="l">${esc(label)}</div></div>`;
|
|
231
|
+
const rows = (items, cols) =>
|
|
232
|
+
items
|
|
233
|
+
.map((it) => `<tr>${cols.map((k) => `<td>${esc(it[k] ?? '')}</td>`).join('')}</tr>`)
|
|
234
|
+
.join('');
|
|
235
|
+
|
|
236
|
+
const liveBlock = r.live
|
|
237
|
+
? `<h2>Live gauges</h2>
|
|
238
|
+
<div class="grid">
|
|
239
|
+
${stat(r.live.totals.calls, 'calls')}${stat(r.live.totals.errors, 'errors')}${stat(
|
|
240
|
+
r.live.recycle ? `${r.live.recycle.ratePct ?? 0}%` : '—',
|
|
241
|
+
'compute recycled',
|
|
242
|
+
)}${stat(r.live.quarantine.length, 'quarantined')}
|
|
243
|
+
</div>
|
|
244
|
+
<table><thead><tr><th>tool</th><th>calls</th><th>errors</th><th>avg ms</th></tr></thead>
|
|
245
|
+
<tbody>${rows(r.live.topTools, ['name', 'calls', 'errors', 'avgMs'])}</tbody></table>`
|
|
246
|
+
: `<h2>Live gauges</h2><p class="dim">Host not running — persisted memory only.</p>`;
|
|
247
|
+
|
|
248
|
+
return `<!doctype html>
|
|
249
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
250
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
251
|
+
<title>SPARDA report — ${esc(r.meta.entryFile)}</title>
|
|
252
|
+
<style>
|
|
253
|
+
:root{color-scheme:dark}
|
|
254
|
+
body{margin:0;padding:48px 24px;background:#0b0b10;color:#e8e8f0;
|
|
255
|
+
font:15px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace}
|
|
256
|
+
main{max-width:760px;margin:0 auto}
|
|
257
|
+
h1{font-size:28px;margin:0 0 4px;
|
|
258
|
+
background:linear-gradient(90deg,#c084fc,#67e8f9);
|
|
259
|
+
-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
260
|
+
h2{font-size:15px;margin:36px 0 12px;color:#c084fc;text-transform:uppercase;letter-spacing:.08em}
|
|
261
|
+
.dim{color:#8a8aa0}
|
|
262
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}
|
|
263
|
+
.stat{border:1px solid #26263a;border-radius:10px;padding:14px 16px;background:#12121c}
|
|
264
|
+
.stat .n{font-size:26px;font-weight:700;color:#67e8f9}
|
|
265
|
+
.stat .l{font-size:12px;color:#8a8aa0}
|
|
266
|
+
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:13px}
|
|
267
|
+
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid #1d1d2e}
|
|
268
|
+
th{color:#8a8aa0;font-weight:400}
|
|
269
|
+
footer{margin-top:48px;font-size:12px;color:#8a8aa0}
|
|
270
|
+
</style></head><body><main>
|
|
271
|
+
<h1>SPARDA</h1>
|
|
272
|
+
<p class="dim">black box report · ${esc(r.meta.framework)} · ${esc(r.meta.entryFile)} · ${esc(r.generatedAt)}</p>
|
|
273
|
+
|
|
274
|
+
<h2>Exposure</h2>
|
|
275
|
+
<div class="grid">
|
|
276
|
+
${stat(`${r.tools.enabled}/${r.tools.total}`, 'tools exposed to AI')}${stat(
|
|
277
|
+
`${r.tools.writesEnabled}/${r.tools.writes}`,
|
|
278
|
+
'write tools opted in',
|
|
279
|
+
)}${stat(r.semantic.workflows, 'workflows learned')}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<h2>Proof journal (last 100 actions)</h2>
|
|
283
|
+
<div class="grid">
|
|
284
|
+
${stat(r.proof.events, 'proofed invocations')}${Object.entries(r.proof.byDecision)
|
|
285
|
+
.map(([d, n]) => stat(n, d))
|
|
286
|
+
.join('')}${stat(r.proof.failureCount, 'failure lessons')}
|
|
287
|
+
</div>
|
|
288
|
+
${
|
|
289
|
+
r.proof.failures.length
|
|
290
|
+
? `<table><thead><tr><th>signature</th><th>count</th><th>lesson</th></tr></thead>
|
|
291
|
+
<tbody>${rows(r.proof.failures, ['sig', 'count', 'lesson'])}</tbody></table>`
|
|
292
|
+
: ''
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
<h2>Immune memory</h2>
|
|
296
|
+
<div class="grid">
|
|
297
|
+
${stat(r.immunity.antibodies, 'antibodies')}${stat(r.immunity.hits, 'zero-token diagnoses')}
|
|
298
|
+
</div>
|
|
299
|
+
${
|
|
300
|
+
r.immunity.top.length
|
|
301
|
+
? `<table><thead><tr><th>signature</th><th>hits</th><th>diagnosis</th></tr></thead>
|
|
302
|
+
<tbody>${rows(r.immunity.top, ['sig', 'hits', 'diagnosis'])}</tbody></table>`
|
|
303
|
+
: ''
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
<h2>Emergent organs</h2>
|
|
307
|
+
<div class="grid">
|
|
308
|
+
${stat(r.organs.circuits, 'circuits observed')}${stat(r.organs.composites.length, 'composite tools born')}
|
|
309
|
+
</div>
|
|
310
|
+
${
|
|
311
|
+
r.organs.composites.length
|
|
312
|
+
? `<table><thead><tr><th>composite</th><th>description</th></tr></thead>
|
|
313
|
+
<tbody>${rows(r.organs.composites, ['name', 'description'])}</tbody></table>`
|
|
314
|
+
: ''
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
${liveBlock}
|
|
318
|
+
|
|
319
|
+
<footer>Generated locally by <b>sparda-mcp report</b> — no data left this machine.
|
|
320
|
+
SPARDA by Residual Labs · residual-labs.fr</footer>
|
|
321
|
+
</main></body></html>`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── the command ────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export async function runReport(opts) {
|
|
327
|
+
const manifestPath = path.join(opts.cwd, 'sparda.json');
|
|
328
|
+
if (!fs.existsSync(manifestPath)) {
|
|
329
|
+
throw Object.assign(new Error('sparda.json not found — nothing to report on.'), {
|
|
330
|
+
code: 'USER',
|
|
331
|
+
hint: 'run `npx sparda-mcp init` first',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
let manifest;
|
|
335
|
+
try {
|
|
336
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
337
|
+
} catch {
|
|
338
|
+
throw Object.assign(new Error('sparda.json is not valid JSON.'), {
|
|
339
|
+
code: 'USER',
|
|
340
|
+
hint: 'restore it from git or re-run `npx sparda-mcp init`',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let live = null;
|
|
345
|
+
if (manifest.port && manifest.localKey) {
|
|
346
|
+
live = await fetch(`http://127.0.0.1:${manifest.port}/mcp/stats`, {
|
|
347
|
+
headers: { 'x-sparda-key': manifest.localKey },
|
|
348
|
+
signal: AbortSignal.timeout(1500),
|
|
349
|
+
})
|
|
350
|
+
.then((x) => (x.ok ? x.json() : null))
|
|
351
|
+
.catch(() => null);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const report = buildReport(manifest, live);
|
|
355
|
+
|
|
356
|
+
if (opts.json) {
|
|
357
|
+
console.log(JSON.stringify(report, null, 2));
|
|
358
|
+
return { report };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log(renderTerminal(report));
|
|
362
|
+
|
|
363
|
+
if (opts.html) {
|
|
364
|
+
const outDir = path.join(opts.cwd, '.sparda');
|
|
365
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
366
|
+
const outFile = path.join(outDir, 'report.html');
|
|
367
|
+
fs.writeFileSync(outFile, renderHtml(report), 'utf8');
|
|
368
|
+
console.log(` ✓ HTML report written: ${path.relative(opts.cwd, outFile)}`);
|
|
369
|
+
console.log('');
|
|
370
|
+
}
|
|
371
|
+
return { report };
|
|
372
|
+
}
|
package/src/commands/sync.js
CHANGED
|
@@ -9,10 +9,15 @@ import { generateExpress } from '../generator/express.js';
|
|
|
9
9
|
import { generateFastAPI } from '../generator/fastapi.js';
|
|
10
10
|
|
|
11
11
|
export async function runSync(opts) {
|
|
12
|
-
const log = (m) => {
|
|
12
|
+
const log = (m) => {
|
|
13
|
+
if (!opts.quiet) console.error(m);
|
|
14
|
+
};
|
|
13
15
|
const manifestPath = path.join(opts.cwd, 'sparda.json');
|
|
14
16
|
if (!fs.existsSync(manifestPath)) {
|
|
15
|
-
throw Object.assign(new Error('sparda.json not found.'), {
|
|
17
|
+
throw Object.assign(new Error('sparda.json not found.'), {
|
|
18
|
+
code: 'USER',
|
|
19
|
+
hint: 'Run `npx sparda-mcp init` first.',
|
|
20
|
+
});
|
|
16
21
|
}
|
|
17
22
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
18
23
|
const stack = detectStack(opts.cwd);
|
|
@@ -21,13 +26,22 @@ export async function runSync(opts) {
|
|
|
21
26
|
if (stack.framework === 'express') {
|
|
22
27
|
({ routes } = parseExpressProject(opts.cwd, stack.entryFile));
|
|
23
28
|
} else {
|
|
24
|
-
({ routes, entryAppVars } = parseFastAPIProject(
|
|
29
|
+
({ routes, entryAppVars } = parseFastAPIProject(
|
|
30
|
+
opts.cwd,
|
|
31
|
+
stack.entryFile,
|
|
32
|
+
stack.pythonCmd,
|
|
33
|
+
));
|
|
25
34
|
}
|
|
26
35
|
for (const r of routes) {
|
|
27
|
-
r.description = sanitizeDescription(
|
|
36
|
+
r.description = sanitizeDescription(
|
|
37
|
+
r.description,
|
|
38
|
+
`${r.method.toUpperCase()} ${r.path}`,
|
|
39
|
+
).text;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
const current = new Set(
|
|
42
|
+
const current = new Set(
|
|
43
|
+
Object.values(manifest.tools ?? {}).map((t) => `${t.method} ${t.path}`),
|
|
44
|
+
);
|
|
31
45
|
const next = new Set(routes.map((r) => `${r.method.toUpperCase()} ${r.path}`));
|
|
32
46
|
const added = [...next].filter((x) => !current.has(x));
|
|
33
47
|
const removed = [...current].filter((x) => !next.has(x));
|
|
@@ -39,11 +53,26 @@ export async function runSync(opts) {
|
|
|
39
53
|
|
|
40
54
|
// regenerate; carry-over keeps enabled overrides, semantic cache and localKey
|
|
41
55
|
stack.framework === 'express'
|
|
42
|
-
? generateExpress({
|
|
43
|
-
|
|
56
|
+
? generateExpress({
|
|
57
|
+
cwd: opts.cwd,
|
|
58
|
+
entryFile: stack.entryFile,
|
|
59
|
+
moduleType: stack.moduleType,
|
|
60
|
+
port: manifest.port ?? stack.port,
|
|
61
|
+
routes,
|
|
62
|
+
})
|
|
63
|
+
: generateFastAPI({
|
|
64
|
+
cwd: opts.cwd,
|
|
65
|
+
entryFile: stack.entryFile,
|
|
66
|
+
port: manifest.port ?? stack.port,
|
|
67
|
+
routes,
|
|
68
|
+
entryAppVars,
|
|
69
|
+
pythonCmd: stack.pythonCmd,
|
|
70
|
+
});
|
|
44
71
|
|
|
45
72
|
for (const a of added) log(`[sparda] + ${a}`);
|
|
46
73
|
for (const r of removed) log(`[sparda] - ${r}`);
|
|
47
|
-
log(
|
|
74
|
+
log(
|
|
75
|
+
`[sparda] router regenerated (${added.length} added, ${removed.length} removed). Restart your app or let hot-reload pick it up.`,
|
|
76
|
+
);
|
|
48
77
|
return { changed: true, added, removed };
|
|
49
78
|
}
|
package/src/detect.js
CHANGED
|
@@ -14,21 +14,37 @@ export function detectStack(cwd) {
|
|
|
14
14
|
const entryFile = findExpressEntry(cwd, pkg);
|
|
15
15
|
const moduleType = detectModuleType(cwd, pkg, entryFile);
|
|
16
16
|
const port = detectExpressPort(cwd, entryFile);
|
|
17
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
framework: 'express',
|
|
19
|
+
entryFile,
|
|
20
|
+
moduleType,
|
|
21
|
+
port,
|
|
22
|
+
expressVersion: deps.express,
|
|
23
|
+
};
|
|
18
24
|
}
|
|
19
25
|
const known = ['@nestjs/core', 'next', 'fastify', 'koa'].find((d) => deps[d]);
|
|
20
|
-
if (known)
|
|
26
|
+
if (known)
|
|
27
|
+
throw err(
|
|
28
|
+
`${known} detected — not supported yet. Express & FastAPI only in v0.`,
|
|
29
|
+
'+1 the framework vote: github.com/zyx77550/sparda/issues/1',
|
|
30
|
+
);
|
|
21
31
|
}
|
|
22
32
|
for (const f of ['requirements.txt', 'pyproject.toml']) {
|
|
23
33
|
const p = path.join(cwd, f);
|
|
24
|
-
if (
|
|
34
|
+
if (
|
|
35
|
+
fs.existsSync(p) &&
|
|
36
|
+
fs.readFileSync(p, 'utf8').toLowerCase().includes('fastapi')
|
|
37
|
+
) {
|
|
25
38
|
const entryFile = findFastAPIEntry(cwd);
|
|
26
39
|
const port = detectFastAPIPort(cwd, entryFile);
|
|
27
40
|
const pythonCmd = detectPython();
|
|
28
41
|
return { framework: 'fastapi', entryFile, port, pythonCmd };
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
|
-
throw err(
|
|
44
|
+
throw err(
|
|
45
|
+
'No supported framework found (Express, FastAPI).',
|
|
46
|
+
'Run sparda-mcp inside your project root, next to package.json.',
|
|
47
|
+
);
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
function detectPython() {
|
|
@@ -52,13 +68,14 @@ function detectPython() {
|
|
|
52
68
|
// ignore
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
|
-
throw err(
|
|
71
|
+
throw err(
|
|
72
|
+
'Python 3 (>= 3.9) introuvable dans le PATH.',
|
|
73
|
+
'Installe Python >= 3.9 pour utiliser SPARDA sur un projet FastAPI.',
|
|
74
|
+
);
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
function findFastAPIEntry(cwd) {
|
|
59
|
-
const candidates = [
|
|
60
|
-
'main.py', 'app.py', 'src/main.py', 'app/main.py'
|
|
61
|
-
];
|
|
78
|
+
const candidates = ['main.py', 'app.py', 'src/main.py', 'app/main.py'];
|
|
62
79
|
for (const rel of candidates) {
|
|
63
80
|
const abs = path.resolve(cwd, rel);
|
|
64
81
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
@@ -68,17 +85,27 @@ function findFastAPIEntry(cwd) {
|
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
|
-
|
|
88
|
+
|
|
72
89
|
const entry = searchPyFiles(cwd, cwd);
|
|
73
90
|
if (entry) {
|
|
74
91
|
return path.relative(cwd, entry).split(path.sep).join('/');
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
throw err(
|
|
94
|
+
throw err(
|
|
95
|
+
'Could not locate your FastAPI entry file (the one calling FastAPI()).',
|
|
96
|
+
'Specify it manually, or make sure FastAPI() is declared in one of your python files.',
|
|
97
|
+
);
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
function searchPyFiles(dir, root, countRef = { val: 0 }) {
|
|
81
|
-
const EXCLUDE = new Set([
|
|
101
|
+
const EXCLUDE = new Set([
|
|
102
|
+
'node_modules',
|
|
103
|
+
'.git',
|
|
104
|
+
'venv',
|
|
105
|
+
'.venv',
|
|
106
|
+
'tests',
|
|
107
|
+
'__pycache__',
|
|
108
|
+
]);
|
|
82
109
|
let items;
|
|
83
110
|
try {
|
|
84
111
|
items = fs.readdirSync(dir);
|
|
@@ -129,18 +156,34 @@ function findExpressEntry(cwd, pkg) {
|
|
|
129
156
|
if (m) candidates.push(m[1]);
|
|
130
157
|
}
|
|
131
158
|
candidates.push(
|
|
132
|
-
'src/app.ts',
|
|
133
|
-
'src/
|
|
134
|
-
'src/
|
|
159
|
+
'src/app.ts',
|
|
160
|
+
'src/server.ts',
|
|
161
|
+
'src/index.ts',
|
|
162
|
+
'app.ts',
|
|
163
|
+
'server.ts',
|
|
164
|
+
'index.ts',
|
|
165
|
+
'src/app.js',
|
|
166
|
+
'src/server.js',
|
|
167
|
+
'src/index.js',
|
|
168
|
+
'app.js',
|
|
169
|
+
'server.js',
|
|
170
|
+
'index.js',
|
|
171
|
+
'src/app.mjs',
|
|
172
|
+
'app.mjs',
|
|
173
|
+
'index.mjs',
|
|
135
174
|
);
|
|
136
175
|
for (const rel of candidates) {
|
|
137
176
|
const abs = path.resolve(cwd, rel);
|
|
138
177
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
139
178
|
const src = fs.readFileSync(abs, 'utf8');
|
|
140
|
-
if (/express\s*\(/.test(src))
|
|
179
|
+
if (/express\s*\(/.test(src))
|
|
180
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
141
181
|
}
|
|
142
182
|
}
|
|
143
|
-
throw err(
|
|
183
|
+
throw err(
|
|
184
|
+
'Could not locate your Express entry file (the one calling express()).',
|
|
185
|
+
'Re-run from the project root, or open an issue with your layout.',
|
|
186
|
+
);
|
|
144
187
|
}
|
|
145
188
|
|
|
146
189
|
function detectModuleType(cwd, pkg, entryFile) {
|
|
@@ -148,7 +191,11 @@ function detectModuleType(cwd, pkg, entryFile) {
|
|
|
148
191
|
if (entryFile.endsWith('.cjs')) return 'cjs';
|
|
149
192
|
if (pkg.type === 'module' || entryFile.endsWith('.ts')) {
|
|
150
193
|
const src = fs.readFileSync(path.join(cwd, entryFile), 'utf8');
|
|
151
|
-
if (
|
|
194
|
+
if (
|
|
195
|
+
/^\s*(const|let|var)\s+\w+\s*=\s*require\(/m.test(src) &&
|
|
196
|
+
!/^\s*import\s/m.test(src)
|
|
197
|
+
)
|
|
198
|
+
return 'cjs';
|
|
152
199
|
return 'esm';
|
|
153
200
|
}
|
|
154
201
|
const src = fs.readFileSync(path.join(cwd, entryFile), 'utf8');
|
|
@@ -163,7 +210,10 @@ function detectExpressPort(cwd, entryFile) {
|
|
|
163
210
|
if (m) {
|
|
164
211
|
const envPath = path.join(cwd, '.env');
|
|
165
212
|
if (fs.existsSync(envPath)) {
|
|
166
|
-
const line = fs
|
|
213
|
+
const line = fs
|
|
214
|
+
.readFileSync(envPath, 'utf8')
|
|
215
|
+
.split(/\r?\n/)
|
|
216
|
+
.find((l) => l.startsWith(`${m[1]}=`));
|
|
167
217
|
if (line) {
|
|
168
218
|
const v = Number(line.split('=')[1].trim());
|
|
169
219
|
if (v) return v;
|