@musashishao/folderforge 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +181 -0
  2. package/dist/adapters/child-mcp/client.js +114 -0
  3. package/dist/adapters/child-mcp/registry.js +66 -0
  4. package/dist/audit/audit-log.js +45 -0
  5. package/dist/audit/event-types.js +1 -0
  6. package/dist/core/config.js +211 -0
  7. package/dist/core/container.js +51 -0
  8. package/dist/core/errors.js +37 -0
  9. package/dist/core/logger.js +8 -0
  10. package/dist/core/types.js +4 -0
  11. package/dist/dashboard/server.js +191 -0
  12. package/dist/lsp/protocol.js +116 -0
  13. package/dist/main.js +190 -0
  14. package/dist/managers/db-manager.js +161 -0
  15. package/dist/managers/lsp-manager.js +269 -0
  16. package/dist/managers/process-manager.js +140 -0
  17. package/dist/policy/approvals.js +143 -0
  18. package/dist/policy/command-policy.js +99 -0
  19. package/dist/policy/glob-match.js +61 -0
  20. package/dist/policy/path-policy.js +73 -0
  21. package/dist/policy/policy-engine.js +156 -0
  22. package/dist/policy/rate-limiter.js +96 -0
  23. package/dist/policy/risk.js +112 -0
  24. package/dist/policy/secret-policy.js +132 -0
  25. package/dist/server/mcp-server.js +144 -0
  26. package/dist/server/transports/http.js +133 -0
  27. package/dist/server/transports/stdio.js +14 -0
  28. package/dist/tools/adapter-tools.js +62 -0
  29. package/dist/tools/browser-tools.js +76 -0
  30. package/dist/tools/build-tools.js +78 -0
  31. package/dist/tools/code-tools.js +250 -0
  32. package/dist/tools/coverage-tools.js +135 -0
  33. package/dist/tools/db-tools.js +130 -0
  34. package/dist/tools/diff-util.js +45 -0
  35. package/dist/tools/error-parser.js +57 -0
  36. package/dist/tools/file-tools.js +319 -0
  37. package/dist/tools/format-tools.js +118 -0
  38. package/dist/tools/git-tools.js +371 -0
  39. package/dist/tools/index.js +63 -0
  40. package/dist/tools/memory-tools.js +54 -0
  41. package/dist/tools/output-schemas.js +100 -0
  42. package/dist/tools/pagination.js +92 -0
  43. package/dist/tools/pkg-tools.js +260 -0
  44. package/dist/tools/process-tools.js +128 -0
  45. package/dist/tools/registry.js +194 -0
  46. package/dist/tools/schema-lock.js +152 -0
  47. package/dist/tools/search-tools.js +176 -0
  48. package/dist/tools/security-tools.js +147 -0
  49. package/dist/tools/terminal-tools.js +57 -0
  50. package/dist/tools/workspace-tools.js +186 -0
  51. package/dist/workspace/memory-store.js +67 -0
  52. package/dist/workspace/onboarding.js +46 -0
  53. package/dist/workspace/project-detector.js +95 -0
  54. package/dist/workspace/workspace-manager.js +106 -0
  55. package/docs/adapters.md +76 -0
  56. package/docs/architecture.md +66 -0
  57. package/docs/roadmap.md +172 -0
  58. package/docs/security.md +94 -0
  59. package/docs/tools.md +129 -0
  60. package/examples/claude-desktop.json +18 -0
  61. package/examples/codex.toml +18 -0
  62. package/examples/config.basic.yaml +37 -0
  63. package/examples/config.full.yaml +120 -0
  64. package/package.json +74 -0
@@ -0,0 +1,78 @@
1
+ import { execa } from 'execa';
2
+ import { defineTool } from './registry.js';
3
+ import { detectCommands } from '../workspace/project-detector.js';
4
+ import { parseErrors } from './error-parser.js';
5
+ import { RUN_SCRIPT_OUTPUT_SCHEMA } from './output-schemas.js';
6
+ async function runScript(ctx, key) {
7
+ const cmds = detectCommands(ctx.projectRoot);
8
+ const command = cmds.scripts[key];
9
+ if (!command)
10
+ return { ok: false, error: `No ${key} command detected for this project.` };
11
+ const sub = await execa(ctx.config.terminal.shell, ['-lc', command], {
12
+ cwd: ctx.projectRoot,
13
+ timeout: ctx.config.terminal.defaultTimeoutMs,
14
+ reject: false,
15
+ maxBuffer: ctx.config.terminal.maxOutputBytes * 4,
16
+ });
17
+ const max = ctx.config.terminal.maxOutputBytes;
18
+ const stdout = ctx.container.policy.secret.redact((sub.stdout ?? '').slice(0, max));
19
+ const stderr = ctx.container.policy.secret.redact((sub.stderr ?? '').slice(0, max));
20
+ const errors = parseErrors(stdout + '\n' + stderr);
21
+ return {
22
+ ok: sub.exitCode === 0,
23
+ data: { command, exitCode: sub.exitCode, stdout, stderr, errors },
24
+ };
25
+ }
26
+ export function buildTools() {
27
+ return [
28
+ defineTool({
29
+ name: 'project_detect_commands',
30
+ description: 'Detect package manager and dev/test/build/lint commands from project manifests.',
31
+ group: 'build',
32
+ mutates: false,
33
+ inputSchema: { type: 'object', properties: {} },
34
+ handler: async (_a, ctx) => ({ ok: true, data: detectCommands(ctx.projectRoot) }),
35
+ }),
36
+ defineTool({
37
+ name: 'run_test',
38
+ description: 'Run the project test suite and parse failures.',
39
+ group: 'build',
40
+ mutates: false,
41
+ inputSchema: { type: 'object', properties: {} },
42
+ outputSchema: RUN_SCRIPT_OUTPUT_SCHEMA,
43
+ handler: async (_a, ctx) => runScript(ctx, 'test'),
44
+ }),
45
+ defineTool({
46
+ name: 'run_lint',
47
+ description: 'Run the project linter.',
48
+ group: 'build',
49
+ mutates: false,
50
+ inputSchema: { type: 'object', properties: {} },
51
+ handler: async (_a, ctx) => runScript(ctx, 'lint'),
52
+ }),
53
+ defineTool({
54
+ name: 'run_typecheck',
55
+ description: 'Run the project type checker.',
56
+ group: 'build',
57
+ mutates: false,
58
+ inputSchema: { type: 'object', properties: {} },
59
+ handler: async (_a, ctx) => runScript(ctx, 'typecheck'),
60
+ }),
61
+ defineTool({
62
+ name: 'run_build',
63
+ description: 'Run the project build.',
64
+ group: 'build',
65
+ mutates: true,
66
+ inputSchema: { type: 'object', properties: {} },
67
+ handler: async (_a, ctx) => runScript(ctx, 'build'),
68
+ }),
69
+ defineTool({
70
+ name: 'parse_errors',
71
+ description: 'Parse a build/test output string into structured errors.',
72
+ group: 'build',
73
+ mutates: false,
74
+ inputSchema: { type: 'object', properties: { output: { type: 'string' } }, required: ['output'] },
75
+ handler: async (args) => ({ ok: true, data: { errors: parseErrors(String(args.output)) } }),
76
+ }),
77
+ ];
78
+ }
@@ -0,0 +1,250 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { defineTool } from './registry.js';
4
+ import { flattenSymbols, normalizeLocation, normalizeDiagnostics, } from '../managers/lsp-manager.js';
5
+ import { DIAGNOSTICS_OUTPUT_SCHEMA } from './output-schemas.js';
6
+ /**
7
+ * Semantic code tools. Resolution order (Gap 1, hybrid):
8
+ * 1. Native LSP - spawn the project's language server and speak JSON-RPC
9
+ * directly (LspManager). Authoritative, no external MCP needed.
10
+ * 2. Serena child-MCP - if the LSP binary is unavailable but the Serena
11
+ * adapter is enabled, route there.
12
+ * 3. Regex fallback - `search_ast` / `search_text` style structural search,
13
+ * always available, lower fidelity.
14
+ *
15
+ * Each tool reports `source` in its data so callers know which backend answered.
16
+ */
17
+ const SERENA_MAP = {
18
+ code_symbols_overview: 'get_symbols_overview',
19
+ code_find_symbol: 'find_symbol',
20
+ code_find_references: 'find_referencing_symbols',
21
+ code_find_definition: 'find_symbol',
22
+ code_find_implementations: 'find_symbol',
23
+ code_diagnostics: 'get_diagnostics',
24
+ code_replace_symbol_body: 'replace_symbol_body',
25
+ code_insert_before_symbol: 'insert_before_symbol',
26
+ code_insert_after_symbol: 'insert_after_symbol',
27
+ code_rename_symbol: 'rename_symbol',
28
+ };
29
+ async function routeToSerena(ctx, toolName, args) {
30
+ if (!ctx.container.adapters.isEnabled('serena'))
31
+ return null;
32
+ try {
33
+ const client = await ctx.container.adapters.ensure('serena');
34
+ const serenaTool = SERENA_MAP[toolName] ?? toolName;
35
+ const result = await client.callTool(serenaTool, args);
36
+ return { ok: true, data: { source: 'serena', result } };
37
+ }
38
+ catch (err) {
39
+ return { ok: false, error: `Serena call failed: ${String(err)}` };
40
+ }
41
+ }
42
+ /** Resolve a usable LSP connection for a file, or null if unavailable. */
43
+ async function lspForFile(ctx, relativePath) {
44
+ const mgr = ctx.container.lsp;
45
+ if (!mgr?.isEnabled?.())
46
+ return null;
47
+ const def = mgr.serverForPath(relativePath);
48
+ if (!def)
49
+ return null;
50
+ return mgr.ensure(def, ctx.projectRoot);
51
+ }
52
+ /** 0-based {line, character} position from a 1-based {line, column} request. */
53
+ function toLspPosition(args) {
54
+ const line = Math.max(0, Number(args.line ?? 1) - 1);
55
+ const character = Math.max(0, Number(args.column ?? 1) - 1);
56
+ return { line, character };
57
+ }
58
+ /**
59
+ * Find the first declaration line of a named symbol in a file, so callers can
60
+ * use the simpler `namePath` ergonomics (Serena-style) instead of supplying raw
61
+ * line/column. Returns a 0-based position or null.
62
+ */
63
+ function locateSymbol(absPath, name) {
64
+ let text;
65
+ try {
66
+ text = readFileSync(absPath, 'utf8');
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ const leaf = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
72
+ const re = new RegExp(`\\b${leaf.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`);
73
+ const lines = text.split('\n');
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const m = re.exec(lines[i]);
76
+ if (m)
77
+ return { line: i, character: m.index };
78
+ }
79
+ return null;
80
+ }
81
+ function absOf(ctx, relativePath) {
82
+ return isAbsolute(relativePath) ? relativePath : join(ctx.projectRoot, relativePath);
83
+ }
84
+ /** Standard "no backend" failure when native + Serena both decline. */
85
+ function noBackend(extra = '') {
86
+ return {
87
+ ok: false,
88
+ error: 'No semantic backend available. Install a language server (e.g. ' +
89
+ 'typescript-language-server, pyright) or enable adapters.serena. ' +
90
+ 'Fallback: use search_ast / search_text.' +
91
+ (extra ? ` ${extra}` : ''),
92
+ };
93
+ }
94
+ // --- handlers -------------------------------------------------------------
95
+ async function symbolsOverview(args, ctx) {
96
+ const rel = String(args.relativePath ?? '');
97
+ if (!rel)
98
+ return { ok: false, error: 'relativePath is required.' };
99
+ const conn = await lspForFile(ctx, rel);
100
+ if (conn) {
101
+ const uri = conn.ensureOpen(rel);
102
+ const result = await conn.request('textDocument/documentSymbol', {
103
+ textDocument: { uri },
104
+ });
105
+ return { ok: true, data: { source: 'lsp', symbols: flattenSymbols(result) } };
106
+ }
107
+ const serena = await routeToSerena(ctx, 'code_symbols_overview', args);
108
+ return serena ?? noBackend();
109
+ }
110
+ async function findSymbol(args, ctx) {
111
+ const name = String(args.namePath ?? '');
112
+ if (!name)
113
+ return { ok: false, error: 'namePath is required.' };
114
+ const mgr = ctx.container.lsp;
115
+ if (mgr?.isEnabled?.()) {
116
+ // workspace/symbol needs a live server; pick any configured one we can start.
117
+ const def = mgr.serverForPath('x.ts') ?? mgr.serverById('typescript');
118
+ const conn = def ? await mgr.ensure(def, ctx.projectRoot) : null;
119
+ if (conn) {
120
+ const leaf = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
121
+ const result = await conn.request('workspace/symbol', { query: leaf });
122
+ const matches = (Array.isArray(result) ? result : [])
123
+ .map((s) => {
124
+ const x = s;
125
+ const loc = normalizeLocation(x.location);
126
+ return x.name && loc ? { name: x.name, ...loc } : null;
127
+ })
128
+ .filter(Boolean);
129
+ return { ok: true, data: { source: 'lsp', matches } };
130
+ }
131
+ }
132
+ const serena = await routeToSerena(ctx, 'code_find_symbol', args);
133
+ return serena ?? noBackend();
134
+ }
135
+ /** definition / references / implementations all share the position dance. */
136
+ function positionalTool(lspMethod, serenaName) {
137
+ return async (args, ctx) => {
138
+ const rel = String(args.relativePath ?? args.file ?? '');
139
+ if (rel) {
140
+ const conn = await lspForFile(ctx, rel);
141
+ if (conn) {
142
+ const uri = conn.ensureOpen(rel);
143
+ // Allow either explicit line/column or a symbol name to locate.
144
+ let pos = toLspPosition(args);
145
+ if (args.line === undefined && args.namePath) {
146
+ const found = locateSymbol(absOf(ctx, rel), String(args.namePath));
147
+ if (found)
148
+ pos = found;
149
+ }
150
+ const params = {
151
+ textDocument: { uri },
152
+ position: pos,
153
+ };
154
+ if (lspMethod === 'textDocument/references') {
155
+ params.context = { includeDeclaration: true };
156
+ }
157
+ const result = await conn.request(lspMethod, params);
158
+ const arr = Array.isArray(result) ? result : result ? [result] : [];
159
+ const locations = arr.map(normalizeLocation).filter(Boolean);
160
+ return { ok: true, data: { source: 'lsp', locations } };
161
+ }
162
+ }
163
+ const serena = await routeToSerena(ctx, serenaName, args);
164
+ return serena ?? noBackend('This tool needs a file (relativePath) for native LSP.');
165
+ };
166
+ }
167
+ async function diagnostics(_args, ctx) {
168
+ const rel = String(_args.relativePath ?? '');
169
+ const mgr = ctx.container.lsp;
170
+ if (rel && mgr?.isEnabled?.()) {
171
+ const conn = await lspForFile(ctx, rel);
172
+ if (conn) {
173
+ const uri = conn.ensureOpen(rel);
174
+ // publishDiagnostics is push-based; give the server a brief moment.
175
+ await new Promise((r) => setTimeout(r, 400));
176
+ const diags = conn.diagnostics.get(uri) ?? [];
177
+ const normalized = normalizeDiagnostics(uri, diags);
178
+ return {
179
+ ok: true,
180
+ data: { source: 'lsp', diagnostics: normalized, count: normalized.length },
181
+ };
182
+ }
183
+ }
184
+ const serena = await routeToSerena(ctx, 'code_diagnostics', _args);
185
+ return serena ?? { ok: true, data: { source: 'none', diagnostics: [], count: 0 } };
186
+ }
187
+ async function mutatingSymbol(toolName, args, ctx) {
188
+ // Native LSP rename is supported; body insert/replace are delegated to Serena
189
+ // (which performs the AST edit). Native rename uses textDocument/rename.
190
+ if (toolName === 'code_rename_symbol') {
191
+ const rel = String(args.relativePath ?? args.file ?? '');
192
+ const newName = String(args.newName ?? '');
193
+ if (rel && newName) {
194
+ const conn = await lspForFile(ctx, rel);
195
+ if (conn) {
196
+ const uri = conn.ensureOpen(rel);
197
+ let pos = toLspPosition(args);
198
+ if (args.line === undefined && args.namePath) {
199
+ const found = locateSymbol(absOf(ctx, rel), String(args.namePath));
200
+ if (found)
201
+ pos = found;
202
+ }
203
+ const edit = await conn.request('textDocument/rename', {
204
+ textDocument: { uri },
205
+ position: pos,
206
+ newName,
207
+ });
208
+ return { ok: true, data: { source: 'lsp', workspaceEdit: edit } };
209
+ }
210
+ }
211
+ }
212
+ const serena = await routeToSerena(ctx, toolName, args);
213
+ return serena ?? noBackend('Body edits require the Serena adapter.');
214
+ }
215
+ // --- registration ---------------------------------------------------------
216
+ function readTool(name, description, props, handler, outputSchema) {
217
+ return defineTool({
218
+ name,
219
+ description,
220
+ group: 'code',
221
+ mutates: false,
222
+ inputSchema: { type: 'object', properties: props },
223
+ ...(outputSchema ? { outputSchema } : {}),
224
+ handler,
225
+ });
226
+ }
227
+ function writeTool(name, description, props, handler) {
228
+ return defineTool({
229
+ name,
230
+ description,
231
+ group: 'code',
232
+ mutates: true,
233
+ inputSchema: { type: 'object', properties: props },
234
+ handler,
235
+ });
236
+ }
237
+ export function codeTools() {
238
+ return [
239
+ readTool('code_symbols_overview', 'List symbols declared in a file (native LSP, Serena fallback).', { relativePath: { type: 'string' } }, symbolsOverview),
240
+ readTool('code_find_symbol', 'Find a class/function/method/interface by name across the workspace (native LSP, Serena fallback).', { namePath: { type: 'string' } }, findSymbol),
241
+ readTool('code_find_references', 'Find references to a symbol at a file position or by name (native LSP, Serena fallback).', { relativePath: { type: 'string' }, namePath: { type: 'string' }, line: { type: 'number' }, column: { type: 'number' } }, positionalTool('textDocument/references', 'code_find_references')),
242
+ readTool('code_find_definition', 'Jump to a symbol definition at a file position or by name (native LSP, Serena fallback).', { relativePath: { type: 'string' }, namePath: { type: 'string' }, line: { type: 'number' }, column: { type: 'number' } }, positionalTool('textDocument/definition', 'code_find_definition')),
243
+ readTool('code_find_implementations', 'Find implementations of an interface/abstract symbol (native LSP, Serena fallback).', { relativePath: { type: 'string' }, namePath: { type: 'string' }, line: { type: 'number' }, column: { type: 'number' } }, positionalTool('textDocument/implementation', 'code_find_implementations')),
244
+ readTool('code_diagnostics', 'Get language-server diagnostics for a file (native LSP, Serena fallback).', { relativePath: { type: 'string' } }, diagnostics, DIAGNOSTICS_OUTPUT_SCHEMA),
245
+ writeTool('code_replace_symbol_body', 'Replace the body of a symbol (via Serena adapter).', { namePath: { type: 'string' }, body: { type: 'string' } }, (a, c) => mutatingSymbol('code_replace_symbol_body', a, c)),
246
+ writeTool('code_insert_before_symbol', 'Insert code before a symbol (via Serena adapter).', { namePath: { type: 'string' }, body: { type: 'string' } }, (a, c) => mutatingSymbol('code_insert_before_symbol', a, c)),
247
+ writeTool('code_insert_after_symbol', 'Insert code after a symbol (via Serena adapter).', { namePath: { type: 'string' }, body: { type: 'string' } }, (a, c) => mutatingSymbol('code_insert_after_symbol', a, c)),
248
+ writeTool('code_rename_symbol', 'Rename a symbol across the project (native LSP rename, Serena fallback). Returns a WorkspaceEdit.', { relativePath: { type: 'string' }, namePath: { type: 'string' }, line: { type: 'number' }, column: { type: 'number' }, newName: { type: 'string' } }, (a, c) => mutatingSymbol('code_rename_symbol', a, c)),
249
+ ];
250
+ }
@@ -0,0 +1,135 @@
1
+ import { execa } from 'execa';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { defineTool } from './registry.js';
5
+ import { detectProject } from '../workspace/project-detector.js';
6
+ import { parseErrors } from './error-parser.js';
7
+ import { COVERAGE_OUTPUT_SCHEMA } from './output-schemas.js';
8
+ export function detectCoverageCommand(root) {
9
+ const proj = detectProject(root);
10
+ const has = (f) => existsSync(join(root, f));
11
+ if (proj.languageHints.includes('typescript')) {
12
+ const deps = readPkgDeps(root);
13
+ if (deps.vitest) {
14
+ return {
15
+ argv: ['npx', 'vitest', 'run', '--coverage', '--coverage.reporter=json-summary'],
16
+ report: { kind: 'istanbul-json', path: 'coverage/coverage-summary.json' },
17
+ };
18
+ }
19
+ if (deps.jest) {
20
+ return {
21
+ argv: ['npx', 'jest', '--coverage', '--coverageReporters=json-summary'],
22
+ report: { kind: 'istanbul-json', path: 'coverage/coverage-summary.json' },
23
+ };
24
+ }
25
+ }
26
+ if (has('pyproject.toml') || has('requirements.txt')) {
27
+ return {
28
+ argv: ['pytest', '--cov', '--cov-report=xml'],
29
+ report: { kind: 'cobertura-xml', path: 'coverage.xml' },
30
+ };
31
+ }
32
+ if (has('go.mod')) {
33
+ return { argv: ['go', 'test', '-cover', './...'] };
34
+ }
35
+ if (has('Cargo.toml')) {
36
+ return { argv: ['cargo', 'tarpaulin', '--out', 'Stdout'] };
37
+ }
38
+ return null;
39
+ }
40
+ function readPkgDeps(root) {
41
+ try {
42
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
43
+ return { ...pkg.dependencies, ...pkg.devDependencies };
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ /** Parse an Istanbul json-summary into a flat total-percentage object. */
50
+ export function parseIstanbulSummary(json) {
51
+ try {
52
+ const data = JSON.parse(json);
53
+ const total = data.total;
54
+ if (!total)
55
+ return null;
56
+ const out = {};
57
+ for (const key of ['lines', 'statements', 'functions', 'branches']) {
58
+ const pct = total[key]?.pct;
59
+ if (typeof pct === 'number')
60
+ out[key] = pct;
61
+ }
62
+ return Object.keys(out).length ? out : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** Parse Cobertura XML (pytest-cov) line-rate into a lines percentage. */
69
+ export function parseCoberturaXml(xml) {
70
+ const m = /line-rate="([0-9.]+)"/.exec(xml);
71
+ if (!m)
72
+ return null;
73
+ const lines = Math.round(parseFloat(m[1]) * 1000) / 10; // one decimal place
74
+ const bm = /branch-rate="([0-9.]+)"/.exec(xml);
75
+ const out = { lines };
76
+ if (bm)
77
+ out.branches = Math.round(parseFloat(bm[1]) * 1000) / 10;
78
+ return out;
79
+ }
80
+ export function coverageTools() {
81
+ return [
82
+ defineTool({
83
+ name: 'run_coverage',
84
+ description: 'Run the test suite with coverage enabled and return a structured ' +
85
+ 'coverage summary (lines/branches/functions %) plus parsed failures.',
86
+ group: 'build',
87
+ mutates: false,
88
+ inputSchema: { type: 'object', properties: {} },
89
+ outputSchema: COVERAGE_OUTPUT_SCHEMA,
90
+ handler: async (_a, ctx) => {
91
+ const cmd = detectCoverageCommand(ctx.projectRoot);
92
+ if (!cmd)
93
+ return { ok: false, error: 'No coverage-capable test runner detected.' };
94
+ const [bin, ...rest] = cmd.argv;
95
+ const sub = await execa(bin, rest, {
96
+ cwd: ctx.projectRoot,
97
+ timeout: ctx.config.terminal.defaultTimeoutMs,
98
+ reject: false,
99
+ maxBuffer: ctx.config.terminal.maxOutputBytes * 4,
100
+ });
101
+ const max = ctx.config.terminal.maxOutputBytes;
102
+ const redact = ctx.container.policy.secret.redact;
103
+ const stdout = redact((sub.stdout ?? '').slice(0, max));
104
+ const stderr = redact((sub.stderr ?? '').slice(0, max));
105
+ let summary = null;
106
+ if (cmd.report) {
107
+ const reportPath = join(ctx.projectRoot, cmd.report.path);
108
+ if (existsSync(reportPath)) {
109
+ try {
110
+ const raw = readFileSync(reportPath, 'utf8');
111
+ summary =
112
+ cmd.report.kind === 'istanbul-json'
113
+ ? parseIstanbulSummary(raw)
114
+ : parseCoberturaXml(raw);
115
+ }
116
+ catch {
117
+ summary = null;
118
+ }
119
+ }
120
+ }
121
+ return {
122
+ ok: sub.exitCode === 0,
123
+ data: {
124
+ command: cmd.argv.join(' '),
125
+ exitCode: sub.exitCode ?? null,
126
+ summary,
127
+ errors: parseErrors(`${stdout}\n${stderr}`),
128
+ stdout,
129
+ stderr,
130
+ },
131
+ };
132
+ },
133
+ }),
134
+ ];
135
+ }
@@ -0,0 +1,130 @@
1
+ import { defineTool } from './registry.js';
2
+ import { DB_QUERY_OUTPUT_SCHEMA } from './output-schemas.js';
3
+ export function dbTools() {
4
+ return [
5
+ defineTool({
6
+ name: 'db_connect',
7
+ description: 'Register a read-only dev database connection (sqlite or postgres). Production targets are refused.',
8
+ group: 'db',
9
+ mutates: true,
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ id: { type: 'string' },
14
+ kind: { type: 'string', enum: ['sqlite', 'postgres'] },
15
+ target: { type: 'string', description: 'SQLite file path or Postgres connection string.' },
16
+ },
17
+ required: ['id', 'kind', 'target'],
18
+ },
19
+ handler: async (args, ctx) => {
20
+ try {
21
+ const conn = await ctx.container.db.connect(String(args.id), args.kind, String(args.target));
22
+ return { ok: true, data: conn };
23
+ }
24
+ catch (err) {
25
+ return { ok: false, error: String(err) };
26
+ }
27
+ },
28
+ }),
29
+ defineTool({
30
+ name: 'db_list_connections',
31
+ description: 'List registered database connections.',
32
+ group: 'db',
33
+ mutates: false,
34
+ inputSchema: { type: 'object', properties: {} },
35
+ handler: async (_a, ctx) => ({ ok: true, data: { connections: ctx.container.db.list() } }),
36
+ }),
37
+ defineTool({
38
+ name: 'db_list_tables',
39
+ description: 'List tables in a connected database.',
40
+ group: 'db',
41
+ mutates: false,
42
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
43
+ handler: async (args, ctx) => ({ ok: true, data: { tables: await ctx.container.db.listTables(String(args.id)) } }),
44
+ }),
45
+ defineTool({
46
+ name: 'db_describe_table',
47
+ description: 'Describe a table schema.',
48
+ group: 'db',
49
+ mutates: false,
50
+ inputSchema: {
51
+ type: 'object',
52
+ properties: { id: { type: 'string' }, table: { type: 'string' } },
53
+ required: ['id', 'table'],
54
+ },
55
+ handler: async (args, ctx) => ({
56
+ ok: true,
57
+ data: { schema: await ctx.container.db.describeTable(String(args.id), String(args.table)) },
58
+ }),
59
+ }),
60
+ defineTool({
61
+ name: 'db_query_readonly',
62
+ description: 'Run a read-only query (SELECT/EXPLAIN/WITH). Write queries are rejected; secret columns masked.',
63
+ group: 'db',
64
+ mutates: false,
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: { id: { type: 'string' }, sql: { type: 'string' }, limit: { type: 'number' } },
68
+ required: ['id', 'sql'],
69
+ },
70
+ outputSchema: DB_QUERY_OUTPUT_SCHEMA,
71
+ handler: async (args, ctx) => ({
72
+ ok: true,
73
+ data: { rows: await ctx.container.db.queryReadonly(String(args.id), String(args.sql), Number(args.limit ?? 200)) },
74
+ }),
75
+ }),
76
+ defineTool({
77
+ name: 'db_explain',
78
+ description: 'Explain a query plan (read-only).',
79
+ group: 'db',
80
+ mutates: false,
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: { id: { type: 'string' }, sql: { type: 'string' } },
84
+ required: ['id', 'sql'],
85
+ },
86
+ handler: async (args, ctx) => ({
87
+ ok: true,
88
+ data: { plan: await ctx.container.db.explain(String(args.id), String(args.sql)) },
89
+ }),
90
+ }),
91
+ defineTool({
92
+ name: 'db_write',
93
+ description: 'Execute a single write statement (INSERT/UPDATE/DELETE) on a dev connection. HIGH risk; requires approval.',
94
+ group: 'db',
95
+ mutates: true,
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: { id: { type: 'string' }, sql: { type: 'string' } },
99
+ required: ['id', 'sql'],
100
+ },
101
+ handler: async (args, ctx) => {
102
+ try {
103
+ return { ok: true, data: await ctx.container.db.write(String(args.id), String(args.sql)) };
104
+ }
105
+ catch (err) {
106
+ return { ok: false, error: String(err) };
107
+ }
108
+ },
109
+ }),
110
+ defineTool({
111
+ name: 'db_run_migration',
112
+ description: 'Run a migration script in a single transaction on a dev connection. HIGH risk; requires approval.',
113
+ group: 'db',
114
+ mutates: true,
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: { id: { type: 'string' }, sql: { type: 'string' } },
118
+ required: ['id', 'sql'],
119
+ },
120
+ handler: async (args, ctx) => {
121
+ try {
122
+ return { ok: true, data: await ctx.container.db.runMigration(String(args.id), String(args.sql)) };
123
+ }
124
+ catch (err) {
125
+ return { ok: false, error: String(err) };
126
+ }
127
+ },
128
+ }),
129
+ ];
130
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A compact, dependency-free line-based diff for previews (LCS).
3
+ */
4
+ export function simpleDiff(before, after, label = 'file') {
5
+ if (before === after)
6
+ return `--- ${label}\n(no changes)`;
7
+ const a = before.split('\n');
8
+ const b = after.split('\n');
9
+ const n = a.length;
10
+ const m = b.length;
11
+ // LCS table (kept small enough for typical files)
12
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
13
+ for (let i = n - 1; i >= 0; i--) {
14
+ for (let j = m - 1; j >= 0; j--) {
15
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
16
+ }
17
+ }
18
+ const lines = [`--- ${label} (before)`, `+++ ${label} (after)`];
19
+ let i = 0;
20
+ let j = 0;
21
+ while (i < n && j < m) {
22
+ if (a[i] === b[j]) {
23
+ lines.push(` ${a[i]}`);
24
+ i++;
25
+ j++;
26
+ }
27
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
28
+ lines.push(`- ${a[i]}`);
29
+ i++;
30
+ }
31
+ else {
32
+ lines.push(`+ ${b[j]}`);
33
+ j++;
34
+ }
35
+ }
36
+ while (i < n)
37
+ lines.push(`- ${a[i++]}`);
38
+ while (j < m)
39
+ lines.push(`+ ${b[j++]}`);
40
+ // Trim very large diffs.
41
+ if (lines.length > 400) {
42
+ return lines.slice(0, 400).join('\n') + `\n... (${lines.length - 400} more lines)`;
43
+ }
44
+ return lines.join('\n');
45
+ }