sparda-mcp 0.1.0 → 0.3.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/LICENSE +89 -0
- package/README.md +50 -7
- package/package.json +6 -4
- package/src/commands/doctor.js +45 -7
- package/src/commands/hook.js +29 -0
- package/src/commands/init.js +26 -7
- package/src/commands/remove.js +48 -3
- package/src/commands/sync.js +49 -0
- package/src/detect.js +98 -2
- package/src/generator/express.js +46 -8
- package/src/generator/fastapi.js +211 -0
- package/src/generator/manifest.js +23 -0
- package/src/index.js +22 -3
- package/src/parser/express.js +27 -7
- package/src/parser/fastapi.js +26 -0
- package/src/parser/fastapi_extract.py +443 -0
- package/src/server/stdio.js +271 -29
- package/templates/express-router.txt +57 -4
- package/templates/fastapi-router.txt +198 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: Residual Labs (residual-labs.fr)
|
|
6
|
+
Licensed Work: SPARDA (sparda-mcp)
|
|
7
|
+
The Licensed Work is (c) 2026 Residual Labs.
|
|
8
|
+
Additional Use Grant: You may use the Licensed Work in production, free of
|
|
9
|
+
charge, except to provide a commercial offering (paid
|
|
10
|
+
service, hosted or distributed product) whose value
|
|
11
|
+
derives, entirely or substantially, from the
|
|
12
|
+
functionality of the Licensed Work.
|
|
13
|
+
Change Date: 2030-06-11
|
|
14
|
+
Change License: Apache License, Version 2.0
|
|
15
|
+
|
|
16
|
+
For information about alternative licensing arrangements for the Licensed
|
|
17
|
+
Work, please contact Residual Labs (residual-labs.fr).
|
|
18
|
+
|
|
19
|
+
Notice
|
|
20
|
+
|
|
21
|
+
License text copyright © 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
22
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
23
|
+
|
|
24
|
+
Terms
|
|
25
|
+
|
|
26
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
27
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
28
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
29
|
+
production use.
|
|
30
|
+
|
|
31
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
32
|
+
available distribution of a specific version of the Licensed Work under this
|
|
33
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
34
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
35
|
+
above terminate.
|
|
36
|
+
|
|
37
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
38
|
+
currently in effect as described in this License, you must purchase a
|
|
39
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
40
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
41
|
+
|
|
42
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
43
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
44
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
45
|
+
for each version of the Licensed Work released by Licensor.
|
|
46
|
+
|
|
47
|
+
You must conspicuously display this License on each original or modified copy
|
|
48
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
49
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
50
|
+
License apply to your use of that work.
|
|
51
|
+
|
|
52
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
53
|
+
terminate your rights under this License for the current and all other
|
|
54
|
+
versions of the Licensed Work.
|
|
55
|
+
|
|
56
|
+
This License does not grant you any right in any trademark or logo of
|
|
57
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
58
|
+
Licensor as expressly required by this License).
|
|
59
|
+
|
|
60
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
61
|
+
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
62
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
63
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
64
|
+
TITLE.
|
|
65
|
+
|
|
66
|
+
MariaDB hereby grants you permission to use this License’s text to license
|
|
67
|
+
your works, and to refer to it using the trademark “Business Source License”,
|
|
68
|
+
as long as you comply with the Covenants of Licensor below.
|
|
69
|
+
|
|
70
|
+
Covenants of Licensor
|
|
71
|
+
|
|
72
|
+
In consideration of the right to use this License’s text and the “Business
|
|
73
|
+
Source License” name and trademark, Licensor covenants to MariaDB, and to all
|
|
74
|
+
other recipients of the licensed work to be provided by Licensor:
|
|
75
|
+
|
|
76
|
+
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
|
77
|
+
or a license that is compatible with GPL Version 2.0 or a later version,
|
|
78
|
+
where “compatible” means that software provided under the Change License can
|
|
79
|
+
be included in a program with software provided under GPL Version 2.0 or a
|
|
80
|
+
later version. Licensor may specify additional Change Licenses without
|
|
81
|
+
limitation.
|
|
82
|
+
|
|
83
|
+
2. To either: (a) specify an additional grant of rights to use that does not
|
|
84
|
+
impose any additional restriction on the right granted in this License, as
|
|
85
|
+
the Additional Use Grant; or (b) insert the text “None”.
|
|
86
|
+
|
|
87
|
+
3. To specify a Change Date.
|
|
88
|
+
|
|
89
|
+
4. Not to modify this License in any other way.
|
package/README.md
CHANGED
|
@@ -1,26 +1,69 @@
|
|
|
1
1
|
# SPARDA
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Your AI can write code. It still can't operate your app.**
|
|
4
|
+
|
|
5
|
+
Claude, Cursor & friends read your *files* — not your *running product*. They can refactor a controller, but they can't create an order, check a real user, or see why production is failing. And today, giving an AI real access to your API means: write an OpenAPI spec, build an MCP server, host it, secure it, keep it in sync with every route change — and pray it never `DELETE`s the wrong row. Days of glue code, per project, forever drifting.
|
|
6
|
+
|
|
7
|
+
SPARDA deletes that work:
|
|
4
8
|
|
|
5
9
|
```bash
|
|
6
|
-
npx sparda-mcp init # scan your Express app,
|
|
7
|
-
npx sparda-mcp dev #
|
|
10
|
+
npx sparda-mcp init # scan your Express/FastAPI app, inject the MCP router — 3 minutes
|
|
11
|
+
npx sparda-mcp dev # connect Claude Desktop / Claude Code. Done.
|
|
8
12
|
```
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
No OpenAPI spec. No account. No API key. No server to host. By [Residual Labs](https://residual-labs.fr).
|
|
15
|
+
|
|
16
|
+
## The promise — every word is backed by a test in CI
|
|
17
|
+
|
|
18
|
+
1. **Three minutes, one command.** AST scan, router generation, reversible injection — no config.
|
|
19
|
+
2. **Try it for free, leave for free.** `npx sparda-mcp remove` restores your code **byte-for-byte** (tested on JS, TS, Python, even Windows CRLF files). No trace, no lock-in.
|
|
20
|
+
3. **The AI cannot write until you say so.** Every POST/PUT/DELETE is disabled by default; you enable per tool, and your choice survives every re-run.
|
|
21
|
+
4. **Your app defends itself.** A route failing 3 times in a row is quarantined — the AI can't hammer your broken production. Latency anomalies are flagged. Zero LLM needed.
|
|
22
|
+
5. **Nothing leaves your machine.** No telemetry to us, no cloud, local key auth, 4 exact-pinned dependencies.
|
|
23
|
+
6. **What it learns is never lost.** Diagnoses, descriptions, settings — versioned with your git, surviving every re-init.
|
|
24
|
+
|
|
25
|
+
What we *don't* promise: see the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
|
|
11
26
|
|
|
12
27
|
## How it works
|
|
13
28
|
1. `npx sparda-mcp init` parses your codebase (AST), extracts every route, and injects a tiny marked router (`/mcp`) into your app — fully reversible with `npx sparda-mcp remove`.
|
|
14
29
|
2. Tool calls run **inside your live app process** — warm DB pools, real auth chain, real data.
|
|
15
|
-
3. Write tools (POST/PUT/DELETE) are **disabled by default** (write-safety). You opt in per tool in `sparda.json
|
|
30
|
+
3. Write tools (POST/PUT/DELETE) are **disabled by default** (write-safety). You opt in per tool in `sparda.json` — your choices survive re-runs.
|
|
16
31
|
4. Suspicious docstrings are purged before they ever reach the AI (prompt-injection defense).
|
|
17
32
|
|
|
33
|
+
## v0.2 — the trust layer
|
|
34
|
+
- **Free semantic layer, zero API key.** On first connection, your AI client's own model (via MCP sampling) rewrites tool descriptions in business terms and proposes multi-tool workflows — cached in `sparda.json`, exposed as MCP prompts. Nothing to configure, nothing to pay.
|
|
35
|
+
- **Write confirmation in your AI's UI.** When the client supports MCP elicitation, every write asks you for approval before touching your live app.
|
|
36
|
+
- **Proof-after-write.** Each successful write is followed by a read-back of the same path, so the AI sees the real effect of its action.
|
|
37
|
+
- **Your app reports its own errors.** The router records upstream failures and uncaught exceptions; the bridge streams them to your AI as live notifications. `GET /mcp/stats` and `/mcp/events` expose per-tool telemetry.
|
|
38
|
+
- **Never out of sync.** `npx sparda-mcp sync` re-syncs the router when routes change; `npx sparda-mcp hook` makes it automatic after every commit.
|
|
39
|
+
|
|
40
|
+
## v0.3 — the immune system
|
|
41
|
+
Your app learns what "self" looks like — and defends itself:
|
|
42
|
+
- **Innate immunity (zero LLM).** The router learns each route's latency baseline and status profile; deviations (latency ×10, 5xx bursts) are flagged locally in a few lines of math.
|
|
43
|
+
- **Quarantine.** A tool that fails 3 consecutive 5xx is quarantined (503 + reason + retry delay), then re-tried after a cooldown (half-open). The AI can no longer hammer a broken route of your live app.
|
|
44
|
+
- **Adaptive immunity.** On a new failure signature, the bridge wakes your AI client's own model (MCP sampling) to diagnose it — costly intelligence is summoned only when the body has a fever.
|
|
45
|
+
- **Immune memory.** Diagnoses are cached as antibodies in `sparda.json` (git-versioned): the same failure later is diagnosed instantly, zero tokens. Your app accumulates antibodies as it lives — cloning the code doesn't clone its immune system.
|
|
46
|
+
- **`sparda_get_context`.** One tool call gives the AI the full living context: tools, workflows, runtime telemetry, quarantine state, immune memory. Every AI session resumes where the last one stopped.
|
|
47
|
+
|
|
48
|
+
Where this is going: [ROADMAP.md](./ROADMAP.md).
|
|
49
|
+
|
|
18
50
|
## v0 supports
|
|
19
|
-
Express 4/5 (JS/TS, ESM/CJS)
|
|
51
|
+
Express 4/5 (JS/TS, ESM/CJS) and FastAPI. Vote for your next framework in the pinned issue.
|
|
20
52
|
|
|
21
53
|
## Security posture (v0, honest)
|
|
22
54
|
- 4 runtime dependencies, exact-pinned.
|
|
23
55
|
- Local key on every router call; loop protection; 30s timeouts; 8KB output truncation.
|
|
24
56
|
- AST-positioned injection with backup + post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
|
|
25
57
|
|
|
26
|
-
|
|
58
|
+
## Documentation
|
|
59
|
+
The full project bible lives in [`docs/`](./docs/README.md) — architecture,
|
|
60
|
+
decision log, error knowledge base, security model, testing guide, and the
|
|
61
|
+
session handoff protocol. The product vision and build order: [`ROADMAP.md`](./ROADMAP.md).
|
|
62
|
+
AI contributors start at [`CLAUDE.md`](./CLAUDE.md).
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
[Business Source License 1.1](./LICENSE) — free to use, including in production.
|
|
66
|
+
You may not resell SPARDA or offer it as a competing commercial service.
|
|
67
|
+
Each version converts to Apache 2.0 four years after its release.
|
|
68
|
+
|
|
69
|
+
By [Residual Labs](https://residual-labs.fr)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
14
|
"templates",
|
|
15
|
-
"README.md"
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
16
17
|
],
|
|
17
18
|
"engines": {
|
|
18
19
|
"node": ">=18"
|
|
@@ -27,14 +28,15 @@
|
|
|
27
28
|
"codegen"
|
|
28
29
|
],
|
|
29
30
|
"author": "Residual Labs (residual-labs.fr)",
|
|
30
|
-
"license": "
|
|
31
|
+
"license": "BUSL-1.1",
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@babel/parser": "7.26.5",
|
|
33
34
|
"@babel/traverse": "7.26.5",
|
|
34
35
|
"@clack/prompts": "0.9.1",
|
|
35
|
-
"@modelcontextprotocol/sdk": "1.
|
|
36
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
39
|
+
"express": "4.21.2",
|
|
38
40
|
"vitest": "^3.2.6"
|
|
39
41
|
}
|
|
40
42
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -3,32 +3,70 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { detectStack } from '../detect.js';
|
|
5
5
|
|
|
6
|
+
// Returns { healthy } so the CLI can exit non-zero on any ✗ — scripts/CI can
|
|
7
|
+
// gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
|
|
6
8
|
export async function runDoctor(opts) {
|
|
9
|
+
let healthy = true;
|
|
10
|
+
const fail = (msg) => { healthy = false; console.log(msg); };
|
|
11
|
+
|
|
7
12
|
console.log('SPARDA doctor');
|
|
8
13
|
const node = process.versions.node;
|
|
9
|
-
|
|
14
|
+
if (Number(node.split('.')[0]) >= 18) console.log(` ✓ Node ${node} (≥18 required)`);
|
|
15
|
+
else fail(` ✗ Node ${node} (≥18 required)`);
|
|
10
16
|
let stack = null;
|
|
11
17
|
try {
|
|
12
18
|
stack = detectStack(opts.cwd);
|
|
13
19
|
console.log(` ✓ Framework: ${stack.framework} — entry: ${stack.entryFile} — port: ${stack.port}`);
|
|
14
20
|
} catch (e) {
|
|
15
|
-
|
|
21
|
+
fail(` ✗ ${e.message}`);
|
|
16
22
|
}
|
|
17
23
|
const manifest = path.join(opts.cwd, 'sparda.json');
|
|
18
24
|
if (fs.existsSync(manifest)) {
|
|
19
25
|
try {
|
|
20
26
|
const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
21
|
-
|
|
27
|
+
const tools = Object.values(m.tools ?? {});
|
|
28
|
+
console.log(` ✓ sparda.json valid (${tools.length} tools, ${tools.filter((t) => t.enabled).length} enabled)`);
|
|
29
|
+
|
|
30
|
+
if (m.semantic) {
|
|
31
|
+
console.log(` ✓ Semantic cache: ${Object.keys(m.semantic.descriptions ?? {}).length} descriptions, ${(m.semantic.workflows ?? []).length} workflows`);
|
|
32
|
+
} else {
|
|
33
|
+
console.log(' · Semantic cache: empty — fills on first AI connection (needs MCP sampling)');
|
|
34
|
+
}
|
|
35
|
+
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');
|
|
39
|
+
|
|
22
40
|
if (stack) {
|
|
41
|
+
const headers = { 'x-sparda-key': m.localKey };
|
|
23
42
|
try {
|
|
24
|
-
const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, { headers
|
|
25
|
-
|
|
43
|
+
const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, { headers, signal: AbortSignal.timeout(1500) });
|
|
44
|
+
if (!r.ok) {
|
|
45
|
+
fail(` ✗ Host app on :${m.port} answered ${r.status}`);
|
|
46
|
+
} 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);
|
|
49
|
+
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`);
|
|
52
|
+
const quarantined = Object.entries(s.quarantine ?? {});
|
|
53
|
+
if (quarantined.length) {
|
|
54
|
+
for (const [tool, q] of quarantined) fail(` ✗ Quarantined: ${tool} (${q.reason}) — immune system is protecting this route`);
|
|
55
|
+
} else {
|
|
56
|
+
console.log(' ✓ Quarantine: empty — no route is currently sick');
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
console.log(` ✓ Host app reachable on :${m.port}, router responding (stats unavailable — router predates v0.3, re-run \`npx sparda-mcp init\`)`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
26
62
|
} catch {
|
|
27
|
-
|
|
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}`);
|
|
28
65
|
}
|
|
29
66
|
}
|
|
30
|
-
} catch {
|
|
67
|
+
} catch { fail(' ✗ sparda.json invalid JSON'); }
|
|
31
68
|
} else {
|
|
32
69
|
console.log(' · sparda.json not found (run `npx sparda-mcp init`)');
|
|
33
70
|
}
|
|
71
|
+
return { healthy };
|
|
34
72
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// commands/hook.js — install the git sentinel (post-commit sync)
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MARKER = '# sparda-sentinel';
|
|
6
|
+
|
|
7
|
+
export async function runHook(opts) {
|
|
8
|
+
const gitDir = path.join(opts.cwd, '.git');
|
|
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).' });
|
|
11
|
+
}
|
|
12
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
13
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
14
|
+
const hookPath = path.join(hooksDir, 'post-commit');
|
|
15
|
+
|
|
16
|
+
const line = `${MARKER}\nnpx --no-install sparda-mcp sync --quiet || true\n`;
|
|
17
|
+
if (fs.existsSync(hookPath)) {
|
|
18
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
19
|
+
if (existing.includes(MARKER)) {
|
|
20
|
+
console.error('[sparda] sentinel already installed (post-commit).');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
fs.appendFileSync(hookPath, `\n${line}`);
|
|
24
|
+
} else {
|
|
25
|
+
fs.writeFileSync(hookPath, `#!/bin/sh\n${line}`);
|
|
26
|
+
}
|
|
27
|
+
fs.chmodSync(hookPath, 0o755);
|
|
28
|
+
console.error('[sparda] sentinel installed: routes re-sync after every commit (post-commit hook).');
|
|
29
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -4,21 +4,38 @@ import fs from 'node:fs';
|
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
5
|
import { detectStack } from '../detect.js';
|
|
6
6
|
import { parseExpressProject } from '../parser/express.js';
|
|
7
|
+
import { parseFastAPIProject } from '../parser/fastapi.js';
|
|
7
8
|
import { sanitizeDescription } from '../security/sanitize.js';
|
|
8
9
|
import { generateExpress } from '../generator/express.js';
|
|
10
|
+
import { generateFastAPI } from '../generator/fastapi.js';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
9
14
|
|
|
10
15
|
export async function runInit(opts) {
|
|
11
16
|
const t0 = Date.now();
|
|
12
|
-
p.intro(
|
|
17
|
+
p.intro(`SPARDA v${VERSION}`);
|
|
13
18
|
|
|
14
19
|
const s = p.spinner();
|
|
15
20
|
s.start('Scanning project...');
|
|
16
21
|
const stack = detectStack(opts.cwd);
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
|
|
23
|
+
let routes, skipped, entryAppVars;
|
|
24
|
+
if (stack.framework === 'express') {
|
|
25
|
+
const res = parseExpressProject(opts.cwd, stack.entryFile);
|
|
26
|
+
routes = res.routes;
|
|
27
|
+
skipped = res.skipped;
|
|
28
|
+
s.stop(`Stack detected: Express (${stack.moduleType.toUpperCase()}) — entry: ${stack.entryFile}, port: ${stack.port}`);
|
|
29
|
+
} else if (stack.framework === 'fastapi') {
|
|
30
|
+
const res = parseFastAPIProject(opts.cwd, stack.entryFile, stack.pythonCmd);
|
|
31
|
+
routes = res.routes;
|
|
32
|
+
skipped = res.skipped;
|
|
33
|
+
entryAppVars = res.entryAppVars;
|
|
34
|
+
s.stop(`Stack detected: FastAPI — entry: ${stack.entryFile}, port: ${stack.port}`);
|
|
35
|
+
}
|
|
19
36
|
|
|
20
37
|
if (routes.length === 0) {
|
|
21
|
-
throw Object.assign(new Error('0 routes extracted.'), { code: 'USER', hint:
|
|
38
|
+
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.` });
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
let flaggedCount = 0;
|
|
@@ -52,7 +69,9 @@ export async function runInit(opts) {
|
|
|
52
69
|
inject = answer;
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
const result =
|
|
72
|
+
const result = stack.framework === 'express'
|
|
73
|
+
? generateExpress({ cwd: opts.cwd, entryFile: stack.entryFile, moduleType: stack.moduleType, port: stack.port, routes })
|
|
74
|
+
: generateFastAPI({ cwd: opts.cwd, entryFile: stack.entryFile, port: stack.port, routes, entryAppVars, pythonCmd: stack.pythonCmd });
|
|
56
75
|
|
|
57
76
|
fs.mkdirSync(path.join(opts.cwd, '.sparda'), { recursive: true });
|
|
58
77
|
fs.writeFileSync(path.join(opts.cwd, '.sparda', 'scan-report.json'), JSON.stringify({ routes, skipped }, null, 2));
|
|
@@ -60,7 +79,7 @@ export async function runInit(opts) {
|
|
|
60
79
|
p.log.success(`Generated ${result.routerFile}`);
|
|
61
80
|
if (inject && result.injection.injected) p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
|
|
62
81
|
else if (result.injection.manual) {
|
|
63
|
-
p.note(result.injection.manual.join('\n'), `Add these
|
|
82
|
+
p.note(result.injection.manual.join('\n'), `Add these lines to ${stack.entryFile} (after FastAPI/express instantiation)`);
|
|
64
83
|
}
|
|
65
84
|
p.log.success('Wrote sparda.json');
|
|
66
85
|
|
|
@@ -68,7 +87,7 @@ export async function runInit(opts) {
|
|
|
68
87
|
p.outro(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.
|
|
69
88
|
|
|
70
89
|
Next steps:
|
|
71
|
-
1. Start your app: npm run dev
|
|
90
|
+
1. Start your app: ${stack.framework === 'express' ? 'npm run dev' : 'fastapi dev'}
|
|
72
91
|
2. Start the MCP bridge: npx sparda-mcp dev
|
|
73
92
|
3. Add to Claude Desktop config (claude_desktop_config.json):
|
|
74
93
|
|
package/src/commands/remove.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
|
-
import { removeInjection } from '../generator/express.js';
|
|
5
|
+
import { removeInjection as removeExpress } from '../generator/express.js';
|
|
6
|
+
import { removeInjection as removeFastAPI } from '../generator/fastapi.js';
|
|
7
|
+
import { detectStack } from '../detect.js';
|
|
6
8
|
|
|
7
9
|
export async function runRemove(opts) {
|
|
8
10
|
const manifestPath = path.join(opts.cwd, 'sparda.json');
|
|
@@ -12,11 +14,26 @@ export async function runRemove(opts) {
|
|
|
12
14
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
13
15
|
|
|
14
16
|
if (!opts.yes) {
|
|
17
|
+
// clack crashes with uv_tty_init EBADF when stdin is not a TTY (CI, pipes)
|
|
18
|
+
if (!process.stdin.isTTY) {
|
|
19
|
+
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.' });
|
|
20
|
+
}
|
|
15
21
|
const ok = await p.confirm({ message: 'Remove SPARDA from this project? (deletes generated files + injected block)' });
|
|
16
22
|
if (p.isCancel(ok) || !ok) { p.cancel('Aborted.'); process.exit(1); }
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
let pythonCmd = 'python';
|
|
26
|
+
try {
|
|
27
|
+
const stack = detectStack(opts.cwd);
|
|
28
|
+
if (stack.pythonCmd) pythonCmd = stack.pythonCmd;
|
|
29
|
+
} catch {
|
|
30
|
+
// ignore
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const results = manifest.framework === 'fastapi'
|
|
34
|
+
? removeFastAPI(opts.cwd, manifest, pythonCmd)
|
|
35
|
+
: removeExpress(opts.cwd, manifest);
|
|
36
|
+
|
|
20
37
|
for (const r of results) {
|
|
21
38
|
if (r.ok) console.log(`✓ Removed injection from ${r.file} (file still parses)`);
|
|
22
39
|
else console.log(`✗ Could not safely remove from ${r.file} — restore from .sparda/backup/`);
|
|
@@ -25,8 +42,36 @@ export async function runRemove(opts) {
|
|
|
25
42
|
const abs = path.resolve(opts.cwd, f);
|
|
26
43
|
if (fs.existsSync(abs)) { fs.rmSync(abs); console.log(`✓ Deleted ${f}`); }
|
|
27
44
|
}
|
|
45
|
+
if (revertGitignore(opts.cwd, manifest.gitignore)) console.log('✓ Reverted .gitignore edit');
|
|
28
46
|
fs.rmSync(manifestPath);
|
|
29
47
|
fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });
|
|
30
48
|
console.log('✓ Deleted sparda.json and .sparda/');
|
|
31
|
-
console.log('\nSPARDA removed. `git diff` should be clean
|
|
49
|
+
console.log('\nSPARDA removed. `git diff` should be clean.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Undo exactly what init's ensureGitignore did (recorded in the manifest),
|
|
53
|
+
// so init→remove leaves a byte-for-byte clean tree (hard rule #4, E-010).
|
|
54
|
+
// Manifests written before v0.3.1 carry no `gitignore` field → no-op, as before.
|
|
55
|
+
function revertGitignore(cwd, action) {
|
|
56
|
+
if (!action) return false;
|
|
57
|
+
const gi = path.join(cwd, '.gitignore');
|
|
58
|
+
if (!fs.existsSync(gi)) return false;
|
|
59
|
+
const content = fs.readFileSync(gi, 'utf8');
|
|
60
|
+
const created = `.sparda/\n`;
|
|
61
|
+
const appended = `\n.sparda/\n`;
|
|
62
|
+
if (action === 'created' && content === created) {
|
|
63
|
+
fs.rmSync(gi);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (content.endsWith(appended)) {
|
|
67
|
+
fs.writeFileSync(gi, content.slice(0, -appended.length));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
// user edited around our line since init — best effort: drop the bare line
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
const idx = lines.indexOf('.sparda/');
|
|
73
|
+
if (idx === -1) return false;
|
|
74
|
+
lines.splice(idx, 1);
|
|
75
|
+
fs.writeFileSync(gi, lines.join('\n'));
|
|
76
|
+
return true;
|
|
32
77
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// commands/sync.js — sentinel: keep the generated router in sync with the routes
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { detectStack } from '../detect.js';
|
|
5
|
+
import { parseExpressProject } from '../parser/express.js';
|
|
6
|
+
import { parseFastAPIProject } from '../parser/fastapi.js';
|
|
7
|
+
import { sanitizeDescription } from '../security/sanitize.js';
|
|
8
|
+
import { generateExpress } from '../generator/express.js';
|
|
9
|
+
import { generateFastAPI } from '../generator/fastapi.js';
|
|
10
|
+
|
|
11
|
+
export async function runSync(opts) {
|
|
12
|
+
const log = (m) => { if (!opts.quiet) console.error(m); };
|
|
13
|
+
const manifestPath = path.join(opts.cwd, 'sparda.json');
|
|
14
|
+
if (!fs.existsSync(manifestPath)) {
|
|
15
|
+
throw Object.assign(new Error('sparda.json not found.'), { code: 'USER', hint: 'Run `npx sparda-mcp init` first.' });
|
|
16
|
+
}
|
|
17
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
18
|
+
const stack = detectStack(opts.cwd);
|
|
19
|
+
|
|
20
|
+
let routes, entryAppVars;
|
|
21
|
+
if (stack.framework === 'express') {
|
|
22
|
+
({ routes } = parseExpressProject(opts.cwd, stack.entryFile));
|
|
23
|
+
} else {
|
|
24
|
+
({ routes, entryAppVars } = parseFastAPIProject(opts.cwd, stack.entryFile, stack.pythonCmd));
|
|
25
|
+
}
|
|
26
|
+
for (const r of routes) {
|
|
27
|
+
r.description = sanitizeDescription(r.description, `${r.method.toUpperCase()} ${r.path}`).text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const current = new Set(Object.values(manifest.tools ?? {}).map((t) => `${t.method} ${t.path}`));
|
|
31
|
+
const next = new Set(routes.map((r) => `${r.method.toUpperCase()} ${r.path}`));
|
|
32
|
+
const added = [...next].filter((x) => !current.has(x));
|
|
33
|
+
const removed = [...current].filter((x) => !next.has(x));
|
|
34
|
+
|
|
35
|
+
if (added.length === 0 && removed.length === 0) {
|
|
36
|
+
log('[sparda] in sync — no route changes.');
|
|
37
|
+
return { changed: false, added, removed };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// regenerate; carry-over keeps enabled overrides, semantic cache and localKey
|
|
41
|
+
stack.framework === 'express'
|
|
42
|
+
? generateExpress({ cwd: opts.cwd, entryFile: stack.entryFile, moduleType: stack.moduleType, port: manifest.port ?? stack.port, routes })
|
|
43
|
+
: generateFastAPI({ cwd: opts.cwd, entryFile: stack.entryFile, port: manifest.port ?? stack.port, routes, entryAppVars, pythonCmd: stack.pythonCmd });
|
|
44
|
+
|
|
45
|
+
for (const a of added) log(`[sparda] + ${a}`);
|
|
46
|
+
for (const r of removed) log(`[sparda] - ${r}`);
|
|
47
|
+
log(`[sparda] router regenerated (${added.length} added, ${removed.length} removed). Restart your app or let hot-reload pick it up.`);
|
|
48
|
+
return { changed: true, added, removed };
|
|
49
|
+
}
|
package/src/detect.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// detect.js — framework, entry file, port detection (spec: blueprint 03-PARSING §A)
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
|
|
5
6
|
const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
|
|
6
7
|
|
|
@@ -21,10 +22,102 @@ export function detectStack(cwd) {
|
|
|
21
22
|
for (const f of ['requirements.txt', 'pyproject.toml']) {
|
|
22
23
|
const p = path.join(cwd, f);
|
|
23
24
|
if (fs.existsSync(p) && fs.readFileSync(p, 'utf8').toLowerCase().includes('fastapi')) {
|
|
24
|
-
|
|
25
|
+
const entryFile = findFastAPIEntry(cwd);
|
|
26
|
+
const port = detectFastAPIPort(cwd, entryFile);
|
|
27
|
+
const pythonCmd = detectPython();
|
|
28
|
+
return { framework: 'fastapi', entryFile, port, pythonCmd };
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
|
-
throw err('No supported framework found (Express, FastAPI).', 'Run sparda inside your project root, next to package.json.');
|
|
31
|
+
throw err('No supported framework found (Express, FastAPI).', 'Run sparda-mcp inside your project root, next to package.json.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detectPython() {
|
|
35
|
+
const cmds = ['python3', 'python', 'py'];
|
|
36
|
+
for (const cmd of cmds) {
|
|
37
|
+
const args = cmd === 'py' ? ['-3', '--version'] : ['--version'];
|
|
38
|
+
try {
|
|
39
|
+
const res = spawnSync(cmd, args, { encoding: 'utf8', timeout: 2000 });
|
|
40
|
+
if (res.status === 0) {
|
|
41
|
+
const out = (res.stdout || res.stderr || '').trim();
|
|
42
|
+
const m = out.match(/Python\s+(\d+\.\d+\.\d+)/i);
|
|
43
|
+
if (m) {
|
|
44
|
+
const ver = m[1];
|
|
45
|
+
const [major, minor] = ver.split('.').map(Number);
|
|
46
|
+
if (major === 3 && minor >= 9) {
|
|
47
|
+
return cmd;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// ignore
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
throw err('Python 3 (>= 3.9) introuvable dans le PATH.', 'Installe Python >= 3.9 pour utiliser SPARDA sur un projet FastAPI.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findFastAPIEntry(cwd) {
|
|
59
|
+
const candidates = [
|
|
60
|
+
'main.py', 'app.py', 'src/main.py', 'app/main.py'
|
|
61
|
+
];
|
|
62
|
+
for (const rel of candidates) {
|
|
63
|
+
const abs = path.resolve(cwd, rel);
|
|
64
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
65
|
+
const src = fs.readFileSync(abs, 'utf8');
|
|
66
|
+
if (src.includes('FastAPI(')) {
|
|
67
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const entry = searchPyFiles(cwd, cwd);
|
|
73
|
+
if (entry) {
|
|
74
|
+
return path.relative(cwd, entry).split(path.sep).join('/');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw err('Could not locate your FastAPI entry file (the one calling FastAPI()).', 'Specify it manually, or make sure FastAPI() is declared in one of your python files.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function searchPyFiles(dir, root, countRef = { val: 0 }) {
|
|
81
|
+
const EXCLUDE = new Set(['node_modules', '.git', 'venv', '.venv', 'tests', '__pycache__']);
|
|
82
|
+
let items;
|
|
83
|
+
try {
|
|
84
|
+
items = fs.readdirSync(dir);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
const abs = path.join(dir, item);
|
|
91
|
+
let stat;
|
|
92
|
+
try {
|
|
93
|
+
stat = fs.statSync(abs);
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (stat.isDirectory()) {
|
|
99
|
+
if (EXCLUDE.has(item)) continue;
|
|
100
|
+
const found = searchPyFiles(abs, root, countRef);
|
|
101
|
+
if (found) return found;
|
|
102
|
+
} else if (stat.isFile() && item.endsWith('.py')) {
|
|
103
|
+
countRef.val++;
|
|
104
|
+
if (countRef.val > 200) return null;
|
|
105
|
+
const src = fs.readFileSync(abs, 'utf8');
|
|
106
|
+
if (src.includes('FastAPI(')) {
|
|
107
|
+
return abs;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function detectFastAPIPort(cwd, entryFile) {
|
|
115
|
+
const abs = path.resolve(cwd, entryFile);
|
|
116
|
+
if (!fs.existsSync(abs)) return 8000;
|
|
117
|
+
const src = fs.readFileSync(abs, 'utf8');
|
|
118
|
+
const m = src.match(/uvicorn\.run\([^)]*port\s*=\s*(\d+)/s);
|
|
119
|
+
if (m) return Number(m[1]);
|
|
120
|
+
return 8000;
|
|
28
121
|
}
|
|
29
122
|
|
|
30
123
|
function findExpressEntry(cwd, pkg) {
|
|
@@ -77,6 +170,9 @@ function detectExpressPort(cwd, entryFile) {
|
|
|
77
170
|
}
|
|
78
171
|
}
|
|
79
172
|
}
|
|
173
|
+
// env fallback literal, wrapped or not: Number(process.env.PORT ?? 4477), process.env.APP_PORT || 4488
|
|
174
|
+
m = src.match(/process\.env\.\w*PORT\w*\s*(?:\?\?|\|\|)\s*(\d{2,5})/i);
|
|
175
|
+
if (m) return Number(m[1]);
|
|
80
176
|
// PORT env var or const PORT = 3000 pattern
|
|
81
177
|
m = src.match(/(?:PORT|port)\s*=\s*(?:process\.env\.\w+\s*(?:\|\||\?\?)\s*)?(\d{2,5})/);
|
|
82
178
|
if (m) return Number(m[1]);
|