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
package/src/generator/express.js
CHANGED
|
@@ -13,14 +13,18 @@ const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
|
|
|
13
13
|
const MARK_END = '// <<< sparda-injection <<<';
|
|
14
14
|
|
|
15
15
|
export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
16
|
-
const taken = new Set([
|
|
16
|
+
const taken = new Set([
|
|
17
|
+
'sparda_info',
|
|
18
|
+
'sparda_list_disabled_tools',
|
|
19
|
+
'sparda_get_context',
|
|
20
|
+
]);
|
|
17
21
|
const tools = {};
|
|
18
22
|
for (const r of routes) {
|
|
19
23
|
const name = toolNameFor(r, taken);
|
|
20
24
|
tools[name] = {
|
|
21
25
|
method: r.method.toUpperCase(),
|
|
22
26
|
path: r.path,
|
|
23
|
-
enabled: !r.mutating,
|
|
27
|
+
enabled: !r.mutating, // write-safety: mutating tools off by default
|
|
24
28
|
pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
|
|
25
29
|
description: r.description,
|
|
26
30
|
params: r.params,
|
|
@@ -44,7 +48,9 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
44
48
|
tool: name,
|
|
45
49
|
decision: 'audit',
|
|
46
50
|
risk: 'medium',
|
|
47
|
-
reasons: [
|
|
51
|
+
reasons: [
|
|
52
|
+
`route structure modified (fingerprint changed from ${oldFp} to ${fp})`,
|
|
53
|
+
],
|
|
48
54
|
});
|
|
49
55
|
if (sparding.events.length > 100) sparding.events.shift();
|
|
50
56
|
}
|
|
@@ -55,12 +61,21 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
55
61
|
const localKey = prev?.localKey ?? crypto.randomUUID();
|
|
56
62
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
57
63
|
const entryDir = path.dirname(entryAbs);
|
|
58
|
-
const ext = entryFile.endsWith('.ts')
|
|
64
|
+
const ext = entryFile.endsWith('.ts')
|
|
65
|
+
? '.ts'
|
|
66
|
+
: entryFile.endsWith('.mjs')
|
|
67
|
+
? '.mjs'
|
|
68
|
+
: entryFile.endsWith('.cjs')
|
|
69
|
+
? '.cjs'
|
|
70
|
+
: '.js';
|
|
59
71
|
const routerFileName = `sparda-router${ext}`;
|
|
60
72
|
const routerAbs = path.join(entryDir, routerFileName);
|
|
61
73
|
|
|
62
74
|
// --- render router from template
|
|
63
|
-
let tpl = fs.readFileSync(
|
|
75
|
+
let tpl = fs.readFileSync(
|
|
76
|
+
path.join(__dirname, '..', '..', 'templates', 'express-router.txt'),
|
|
77
|
+
'utf8',
|
|
78
|
+
);
|
|
64
79
|
const isESM = moduleType === 'esm';
|
|
65
80
|
const isTS = ext === '.ts';
|
|
66
81
|
|
|
@@ -76,19 +91,25 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
76
91
|
if (isESM) {
|
|
77
92
|
importLine = "import express, { Request, Response, NextFunction } from 'express';";
|
|
78
93
|
} else {
|
|
79
|
-
importLine =
|
|
94
|
+
importLine =
|
|
95
|
+
"import type { Request, Response, NextFunction } from 'express';\nconst express = require('express');";
|
|
80
96
|
}
|
|
81
97
|
} else {
|
|
82
|
-
importLine = isESM
|
|
98
|
+
importLine = isESM
|
|
99
|
+
? "import express from 'express';"
|
|
100
|
+
: "const express = require('express');";
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
tpl = tpl
|
|
86
104
|
.replace('__IMPORT_LINE__', importLine)
|
|
87
105
|
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
88
|
-
.replace(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
.replace(
|
|
107
|
+
'__ROUTER_DECL__',
|
|
108
|
+
isESM
|
|
109
|
+
? 'export const spardaRouter = express.Router();'
|
|
110
|
+
: 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };',
|
|
111
|
+
)
|
|
112
|
+
.replace('__JSON_MW__', "express.json({ limit: '64kb' })")
|
|
92
113
|
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
93
114
|
.replace('__LOCAL_KEY__', localKey)
|
|
94
115
|
.replace('__PORT__', String(port))
|
|
@@ -117,7 +138,12 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
117
138
|
generatedFiles: [path.relative(cwd, routerAbs).split(path.sep).join('/')],
|
|
118
139
|
injectedFiles: injection.injected ? [entryFile] : [],
|
|
119
140
|
createdAt: new Date().toISOString(),
|
|
120
|
-
tools: Object.fromEntries(
|
|
141
|
+
tools: Object.fromEntries(
|
|
142
|
+
Object.entries(tools).map(([k, v]) => [
|
|
143
|
+
k,
|
|
144
|
+
{ method: v.method, path: v.path, enabled: v.enabled },
|
|
145
|
+
]),
|
|
146
|
+
),
|
|
121
147
|
...(gitignore ? { gitignore } : {}),
|
|
122
148
|
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
123
149
|
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
@@ -126,7 +152,12 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
126
152
|
};
|
|
127
153
|
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
128
154
|
|
|
129
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
tools,
|
|
157
|
+
manifest,
|
|
158
|
+
routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'),
|
|
159
|
+
injection,
|
|
160
|
+
};
|
|
130
161
|
}
|
|
131
162
|
|
|
132
163
|
function injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd }) {
|
|
@@ -136,19 +167,31 @@ function injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd }) {
|
|
|
136
167
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
137
168
|
fs.writeFileSync(path.join(backupDir, `${path.basename(entryAbs)}.${Date.now()}`), src);
|
|
138
169
|
|
|
139
|
-
const importSpec =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
const importSpec =
|
|
171
|
+
`./${routerFileName.replace(/\.(ts|js|mjs|cjs)$/, moduleType === 'esm' && routerFileName.endsWith('.js') ? '.js' : '')}`.replace(
|
|
172
|
+
/\.$/,
|
|
173
|
+
'',
|
|
174
|
+
);
|
|
175
|
+
const importLine =
|
|
176
|
+
moduleType === 'esm'
|
|
177
|
+
? `import { spardaRouter } from '${importSpec}';`
|
|
178
|
+
: `const { spardaRouter } = require('${importSpec}');`;
|
|
143
179
|
|
|
144
180
|
// strip previous injection blocks (idempotence)
|
|
145
181
|
let body = src;
|
|
146
|
-
const blockRx = new RegExp(
|
|
182
|
+
const blockRx = new RegExp(
|
|
183
|
+
`\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\n?`,
|
|
184
|
+
'g',
|
|
185
|
+
);
|
|
147
186
|
body = body.replace(blockRx, '\n');
|
|
148
187
|
|
|
149
188
|
let ast;
|
|
150
189
|
try {
|
|
151
|
-
ast = parse(body, {
|
|
190
|
+
ast = parse(body, {
|
|
191
|
+
sourceType: 'unambiguous',
|
|
192
|
+
plugins: ['typescript', 'jsx'],
|
|
193
|
+
attachComment: false,
|
|
194
|
+
});
|
|
152
195
|
} catch {
|
|
153
196
|
return manualFallback(importLine);
|
|
154
197
|
}
|
|
@@ -160,7 +203,8 @@ function injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd }) {
|
|
|
160
203
|
let appName = 'app';
|
|
161
204
|
|
|
162
205
|
for (const node of ast.program.body) {
|
|
163
|
-
if (node.type === 'ImportDeclaration')
|
|
206
|
+
if (node.type === 'ImportDeclaration')
|
|
207
|
+
lastImportLine = Math.max(lastImportLine, node.loc.end.line);
|
|
164
208
|
if (node.type === 'VariableDeclaration') {
|
|
165
209
|
for (const d of node.declarations) {
|
|
166
210
|
if (d.init?.type === 'CallExpression' && d.init.callee?.name === 'express') {
|
|
@@ -172,18 +216,27 @@ function injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd }) {
|
|
|
172
216
|
}
|
|
173
217
|
}
|
|
174
218
|
}
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
if (
|
|
220
|
+
node.type === 'ExpressionStatement' &&
|
|
221
|
+
node.expression.type === 'CallExpression' &&
|
|
222
|
+
node.expression.callee?.property?.name === 'listen'
|
|
223
|
+
) {
|
|
178
224
|
listenLine = node.loc.start.line;
|
|
179
225
|
}
|
|
180
226
|
}
|
|
181
227
|
|
|
182
|
-
const useBlock = [
|
|
228
|
+
const useBlock = [
|
|
229
|
+
MARK_START,
|
|
230
|
+
importLine,
|
|
231
|
+
`${appName}.use('/mcp', spardaRouter);`,
|
|
232
|
+
MARK_END,
|
|
233
|
+
];
|
|
183
234
|
|
|
184
235
|
let insertAt; // 0-based index AFTER which we DON'T insert; we splice before index
|
|
185
|
-
if (appAssignLine !== null)
|
|
186
|
-
|
|
236
|
+
if (appAssignLine !== null)
|
|
237
|
+
insertAt = appAssignLine; // after app = express()
|
|
238
|
+
else if (listenLine !== null)
|
|
239
|
+
insertAt = listenLine - 1; // before app.listen
|
|
187
240
|
else return manualFallback(importLine);
|
|
188
241
|
|
|
189
242
|
lines.splice(insertAt, 0, ...useBlock);
|
|
@@ -212,7 +265,10 @@ export function removeInjection(cwd, manifest) {
|
|
|
212
265
|
const abs = path.resolve(cwd, relFile);
|
|
213
266
|
if (!fs.existsSync(abs)) continue;
|
|
214
267
|
const src = fs.readFileSync(abs, 'utf8');
|
|
215
|
-
const blockRx = new RegExp(
|
|
268
|
+
const blockRx = new RegExp(
|
|
269
|
+
`\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`,
|
|
270
|
+
'g',
|
|
271
|
+
);
|
|
216
272
|
const out = src.replace(blockRx, '');
|
|
217
273
|
try {
|
|
218
274
|
parse(out, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
|
|
@@ -240,4 +296,6 @@ function ensureGitignore(cwd) {
|
|
|
240
296
|
return 'created';
|
|
241
297
|
}
|
|
242
298
|
|
|
243
|
-
function escapeRx(s) {
|
|
299
|
+
function escapeRx(s) {
|
|
300
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
301
|
+
}
|
package/src/generator/fastapi.js
CHANGED
|
@@ -12,15 +12,26 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
12
12
|
const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
|
|
13
13
|
const MARK_END = '# <<< sparda-injection <<<';
|
|
14
14
|
|
|
15
|
-
export function generateFastAPI({
|
|
16
|
-
|
|
15
|
+
export function generateFastAPI({
|
|
16
|
+
cwd,
|
|
17
|
+
entryFile,
|
|
18
|
+
port,
|
|
19
|
+
routes,
|
|
20
|
+
entryAppVars,
|
|
21
|
+
pythonCmd = 'python',
|
|
22
|
+
}) {
|
|
23
|
+
const taken = new Set([
|
|
24
|
+
'sparda_info',
|
|
25
|
+
'sparda_list_disabled_tools',
|
|
26
|
+
'sparda_get_context',
|
|
27
|
+
]);
|
|
17
28
|
const tools = {};
|
|
18
29
|
for (const r of routes) {
|
|
19
30
|
const name = toolNameFor(r, taken);
|
|
20
31
|
tools[name] = {
|
|
21
32
|
method: r.method.toUpperCase(),
|
|
22
33
|
path: r.path,
|
|
23
|
-
enabled: !r.mutating,
|
|
34
|
+
enabled: !r.mutating, // write-safety: mutating tools off by default
|
|
24
35
|
pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
|
|
25
36
|
description: r.description,
|
|
26
37
|
params: r.params,
|
|
@@ -44,7 +55,9 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
44
55
|
tool: name,
|
|
45
56
|
decision: 'audit',
|
|
46
57
|
risk: 'medium',
|
|
47
|
-
reasons: [
|
|
58
|
+
reasons: [
|
|
59
|
+
`route structure modified (fingerprint changed from ${oldFp} to ${fp})`,
|
|
60
|
+
],
|
|
48
61
|
});
|
|
49
62
|
if (sparding.events.length > 100) sparding.events.shift();
|
|
50
63
|
}
|
|
@@ -59,19 +72,31 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
59
72
|
const routerAbs = path.join(entryDir, routerFileName);
|
|
60
73
|
|
|
61
74
|
// --- render router from template
|
|
62
|
-
let tpl = fs.readFileSync(
|
|
75
|
+
let tpl = fs.readFileSync(
|
|
76
|
+
path.join(__dirname, '..', '..', 'templates', 'fastapi-router.txt'),
|
|
77
|
+
'utf8',
|
|
78
|
+
);
|
|
63
79
|
tpl = tpl
|
|
64
80
|
// double-stringify: a JSON string literal is also a valid Python string literal,
|
|
65
81
|
// and json.loads() in the template turns it into real Python values (E-009)
|
|
66
82
|
.replace('__TOOLS_JSON__', JSON.stringify(JSON.stringify(tools)))
|
|
67
|
-
.replace(
|
|
83
|
+
.replace(
|
|
84
|
+
'__SPARDING_POLICIES__',
|
|
85
|
+
JSON.stringify(JSON.stringify(sparding.policies ?? {})),
|
|
86
|
+
)
|
|
68
87
|
.replace('__LOCAL_KEY__', localKey)
|
|
69
88
|
.replace('__PORT__', String(port));
|
|
70
89
|
|
|
71
90
|
atomicWrite(routerAbs, tpl);
|
|
72
91
|
|
|
73
92
|
// --- inject into entry file (regex-based with compilation safety check)
|
|
74
|
-
const injection = injectIntoEntry({
|
|
93
|
+
const injection = injectIntoEntry({
|
|
94
|
+
entryAbs,
|
|
95
|
+
routerFileName,
|
|
96
|
+
cwd,
|
|
97
|
+
entryAppVars,
|
|
98
|
+
pythonCmd,
|
|
99
|
+
});
|
|
75
100
|
|
|
76
101
|
// record what init did to .gitignore so `remove` can revert it exactly (hard rule #4, E-010)
|
|
77
102
|
const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
|
|
@@ -86,7 +111,12 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
86
111
|
generatedFiles: [path.relative(cwd, routerAbs).split(path.sep).join('/')],
|
|
87
112
|
injectedFiles: injection.injected ? [entryFile] : [],
|
|
88
113
|
createdAt: new Date().toISOString(),
|
|
89
|
-
tools: Object.fromEntries(
|
|
114
|
+
tools: Object.fromEntries(
|
|
115
|
+
Object.entries(tools).map(([k, v]) => [
|
|
116
|
+
k,
|
|
117
|
+
{ method: v.method, path: v.path, enabled: v.enabled },
|
|
118
|
+
]),
|
|
119
|
+
),
|
|
90
120
|
...(gitignore ? { gitignore } : {}),
|
|
91
121
|
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
92
122
|
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
@@ -96,12 +126,17 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
96
126
|
|
|
97
127
|
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
98
128
|
|
|
99
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
tools,
|
|
131
|
+
manifest,
|
|
132
|
+
routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'),
|
|
133
|
+
injection,
|
|
134
|
+
};
|
|
100
135
|
}
|
|
101
136
|
|
|
102
|
-
function injectIntoEntry({ entryAbs,
|
|
137
|
+
function injectIntoEntry({ entryAbs, cwd, pythonCmd }) {
|
|
103
138
|
const src = fs.readFileSync(entryAbs, 'utf8');
|
|
104
|
-
|
|
139
|
+
|
|
105
140
|
// backup first
|
|
106
141
|
const backupDir = path.join(cwd, '.sparda', 'backup');
|
|
107
142
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
@@ -119,7 +154,10 @@ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCm
|
|
|
119
154
|
|
|
120
155
|
// strip previous injection blocks (idempotence)
|
|
121
156
|
let body = src;
|
|
122
|
-
const blockRx = new RegExp(
|
|
157
|
+
const blockRx = new RegExp(
|
|
158
|
+
`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\r?\\n?`,
|
|
159
|
+
'g',
|
|
160
|
+
);
|
|
123
161
|
body = body.replace(blockRx, eol);
|
|
124
162
|
|
|
125
163
|
// Let's find the FastAPI() call assignment to inject right after it
|
|
@@ -128,7 +166,7 @@ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCm
|
|
|
128
166
|
if (!match) {
|
|
129
167
|
return {
|
|
130
168
|
injected: false,
|
|
131
|
-
manual: [importLine, 'app.include_router(sparda_router, prefix="/mcp")']
|
|
169
|
+
manual: [importLine, 'app.include_router(sparda_router, prefix="/mcp")'],
|
|
132
170
|
};
|
|
133
171
|
}
|
|
134
172
|
|
|
@@ -140,7 +178,7 @@ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCm
|
|
|
140
178
|
MARK_START,
|
|
141
179
|
`${indent}${importLine}`,
|
|
142
180
|
`${indent}${appName}.include_router(sparda_router, prefix="/mcp")`,
|
|
143
|
-
MARK_END
|
|
181
|
+
MARK_END,
|
|
144
182
|
];
|
|
145
183
|
|
|
146
184
|
// We find where the matching line starts and ends in order to insert right after it.
|
|
@@ -157,7 +195,7 @@ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCm
|
|
|
157
195
|
if (insertAt === -1) {
|
|
158
196
|
return {
|
|
159
197
|
injected: false,
|
|
160
|
-
manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
|
|
198
|
+
manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`],
|
|
161
199
|
};
|
|
162
200
|
}
|
|
163
201
|
|
|
@@ -168,7 +206,7 @@ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCm
|
|
|
168
206
|
if (!verifyPythonSyntax(entryAbs, out, pythonCmd)) {
|
|
169
207
|
return {
|
|
170
208
|
injected: false,
|
|
171
|
-
manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
|
|
209
|
+
manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`],
|
|
172
210
|
};
|
|
173
211
|
}
|
|
174
212
|
|
|
@@ -182,9 +220,12 @@ export function removeInjection(cwd, manifest, pythonCmd = 'python') {
|
|
|
182
220
|
const abs = path.resolve(cwd, relFile);
|
|
183
221
|
if (!fs.existsSync(abs)) continue;
|
|
184
222
|
const src = fs.readFileSync(abs, 'utf8');
|
|
185
|
-
const blockRx = new RegExp(
|
|
223
|
+
const blockRx = new RegExp(
|
|
224
|
+
`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`,
|
|
225
|
+
'g',
|
|
226
|
+
);
|
|
186
227
|
const out = src.replace(blockRx, '');
|
|
187
|
-
|
|
228
|
+
|
|
188
229
|
if (verifyPythonSyntax(abs, out, pythonCmd)) {
|
|
189
230
|
atomicWrite(abs, out);
|
|
190
231
|
results.push({ file: relFile, ok: true });
|
|
@@ -199,7 +240,10 @@ function verifyPythonSyntax(filePath, content, pythonCmd) {
|
|
|
199
240
|
const tmpFile = `${filePath}.syntax-check.py`;
|
|
200
241
|
try {
|
|
201
242
|
fs.writeFileSync(tmpFile, content);
|
|
202
|
-
const args =
|
|
243
|
+
const args =
|
|
244
|
+
pythonCmd === 'py'
|
|
245
|
+
? ['-3', '-m', 'py_compile', tmpFile]
|
|
246
|
+
: ['-m', 'py_compile', tmpFile];
|
|
203
247
|
// py_compile cold-starts slowly on Windows; a 2s budget falsely failed clean
|
|
204
248
|
// removals (rule #4) and post-injection checks under load. 5s matches the
|
|
205
249
|
// test-side syntax budget and only bounds the worst case, never the happy path.
|
|
@@ -231,4 +275,6 @@ function ensureGitignore(cwd) {
|
|
|
231
275
|
return 'created';
|
|
232
276
|
}
|
|
233
277
|
|
|
234
|
-
function escapeRx(s) {
|
|
278
|
+
function escapeRx(s) {
|
|
279
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
280
|
+
}
|
|
@@ -16,7 +16,12 @@ export function carryOverManifest(cwd, tools) {
|
|
|
16
16
|
}
|
|
17
17
|
for (const [name, t] of Object.entries(tools)) {
|
|
18
18
|
const old = prev?.tools?.[name];
|
|
19
|
-
if (
|
|
19
|
+
if (
|
|
20
|
+
old &&
|
|
21
|
+
typeof old.enabled === 'boolean' &&
|
|
22
|
+
old.method === t.method &&
|
|
23
|
+
old.path === t.path
|
|
24
|
+
) {
|
|
20
25
|
t.enabled = old.enabled;
|
|
21
26
|
}
|
|
22
27
|
}
|
|
@@ -24,16 +29,18 @@ export function carryOverManifest(cwd, tools) {
|
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export function defaultSpardingMemory(prev) {
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
return (
|
|
33
|
+
prev?.sparding ?? {
|
|
34
|
+
version: 1,
|
|
35
|
+
policies: {
|
|
36
|
+
reads: 'allow',
|
|
37
|
+
writes: 'require_human',
|
|
38
|
+
deletes: 'block',
|
|
39
|
+
requireProofAfterWrite: true,
|
|
40
|
+
},
|
|
41
|
+
events: [],
|
|
42
|
+
failures: {},
|
|
43
|
+
toolFingerprints: {},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
39
46
|
}
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// SPARDA — Turn any codebase into an MCP server. Residual Labs.
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
|
|
5
|
-
const VERSION = JSON.parse(
|
|
5
|
+
const VERSION = JSON.parse(
|
|
6
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
7
|
+
).version;
|
|
6
8
|
const [, , cmd, ...rest] = process.argv;
|
|
7
9
|
|
|
8
10
|
const flags = new Set(rest.filter((a) => a.startsWith('--')));
|
|
@@ -16,12 +18,19 @@ const opts = {
|
|
|
16
18
|
verbose: flags.has('--verbose'),
|
|
17
19
|
quiet: flags.has('--quiet'),
|
|
18
20
|
probe: flags.has('--probe'),
|
|
21
|
+
html: flags.has('--html'),
|
|
22
|
+
json: flags.has('--json'),
|
|
19
23
|
port: getOpt('port', null),
|
|
20
24
|
cwd: process.cwd(),
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
try {
|
|
24
28
|
switch (cmd) {
|
|
29
|
+
case 'demo': {
|
|
30
|
+
const { runDemo } = await import('./commands/demo.js');
|
|
31
|
+
await runDemo(opts);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
25
34
|
case 'init': {
|
|
26
35
|
const { runInit } = await import('./commands/init.js');
|
|
27
36
|
await runInit(opts);
|
|
@@ -53,19 +62,27 @@ try {
|
|
|
53
62
|
await runHook(opts);
|
|
54
63
|
break;
|
|
55
64
|
}
|
|
65
|
+
case 'report': {
|
|
66
|
+
const { runReport } = await import('./commands/report.js');
|
|
67
|
+
await runReport(opts);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
56
70
|
default:
|
|
57
71
|
console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
|
|
58
72
|
|
|
59
73
|
Usage:
|
|
74
|
+
npx sparda-mcp demo Guided tour on a bundled app — no setup, nothing touched
|
|
60
75
|
npx sparda-mcp init Scan project, generate & inject the MCP router
|
|
61
76
|
npx sparda-mcp dev Run the MCP stdio bridge (connect Claude Desktop)
|
|
62
77
|
npx sparda-mcp sync Re-sync the router after route changes (no prompts)
|
|
63
78
|
npx sparda-mcp hook Install the git sentinel (auto-sync after commits)
|
|
64
79
|
npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
|
|
65
80
|
npx sparda-mcp doctor Diagnose your setup
|
|
81
|
+
npx sparda-mcp report The black box: what AI agents did to this app
|
|
66
82
|
|
|
67
83
|
Flags: --yes (skip prompts) --port <n> --quiet --verbose
|
|
68
84
|
--probe (init: also run the app to discover dynamic routes the AST missed)
|
|
85
|
+
--html / --json (report: write .sparda/report.html / print raw JSON)
|
|
69
86
|
|
|
70
87
|
By Residual Labs — residual-labs.fr`);
|
|
71
88
|
process.exit(cmd ? 1 : 0);
|
|
@@ -74,6 +91,9 @@ By Residual Labs — residual-labs.fr`);
|
|
|
74
91
|
console.error(`\n✗ ${err?.message ?? err}`);
|
|
75
92
|
if (err?.hint) console.error(` → ${err.hint}`);
|
|
76
93
|
if (opts.verbose && err?.stack) console.error(err.stack);
|
|
77
|
-
else
|
|
94
|
+
else
|
|
95
|
+
console.error(
|
|
96
|
+
' (run with --verbose for details — or open an issue: github.com/zyx77550/sparda/issues)',
|
|
97
|
+
);
|
|
78
98
|
process.exit(err?.code === 'USER' ? 1 : 2);
|
|
79
99
|
}
|