@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,57 @@
1
+ const PATTERNS = [
2
+ {
3
+ // TypeScript: src/x.ts(12,5): error TS2345: msg
4
+ tool: 'typescript',
5
+ re: /^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+TS\d+:\s+(.+)$/,
6
+ map: (m) => ({ tool: 'typescript', file: m[1], line: +m[2], column: +m[3], severity: m[4], message: m[5] }),
7
+ },
8
+ {
9
+ // ESLint: /path:12:5 error msg rule
10
+ tool: 'eslint',
11
+ re: /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)(?:\s{2,}[\w-/]+)?$/,
12
+ map: (m) => ({ tool: 'eslint', line: +m[1], column: +m[2], severity: m[3], message: m[4] }),
13
+ },
14
+ {
15
+ // Pytest: file.py:12: in test / AssertionError
16
+ tool: 'pytest',
17
+ re: /^(.+\.py):(\d+):\s+(.+)$/,
18
+ map: (m) => ({ tool: 'pytest', file: m[1], line: +m[2], severity: 'error', message: m[3] }),
19
+ },
20
+ {
21
+ // Ruff: file.py:12:5: E501 msg
22
+ tool: 'ruff',
23
+ re: /^(.+\.py):(\d+):(\d+):\s+(\w+\d+)\s+(.+)$/,
24
+ map: (m) => ({ tool: 'ruff', file: m[1], line: +m[2], column: +m[3], severity: 'error', message: `${m[4]} ${m[5]}` }),
25
+ },
26
+ {
27
+ // Go: ./file.go:12:5: msg
28
+ tool: 'go',
29
+ re: /^(.+\.go):(\d+):(\d+):\s+(.+)$/,
30
+ map: (m) => ({ tool: 'go', file: m[1], line: +m[2], column: +m[3], severity: 'error', message: m[4] }),
31
+ },
32
+ {
33
+ // Rust: error[E0382]: msg
34
+ tool: 'rust',
35
+ re: /^error(?:\[[A-Z]\d+\])?:\s+(.+)$/,
36
+ map: (m) => ({ tool: 'rust', severity: 'error', message: m[1] }),
37
+ },
38
+ {
39
+ // Vitest/Jest: FAIL path > test name
40
+ tool: 'vitest',
41
+ re: /^\s*(?:FAIL|✗|×)\s+(.+)$/,
42
+ map: (m) => ({ tool: 'vitest', severity: 'error', message: m[1] }),
43
+ },
44
+ ];
45
+ export function parseErrors(output) {
46
+ const errors = [];
47
+ for (const line of output.split('\n')) {
48
+ for (const p of PATTERNS) {
49
+ const m = p.re.exec(line.trim());
50
+ if (m) {
51
+ errors.push(p.map(m));
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ return errors.slice(0, 100);
57
+ }
@@ -0,0 +1,319 @@
1
+ import { readFileSync, writeFileSync, statSync, existsSync, unlinkSync, mkdirSync, renameSync, copyFileSync, cpSync, readdirSync, } from 'node:fs';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { defineTool } from './registry.js';
4
+ import { simpleDiff } from './diff-util.js';
5
+ const TEXT_LIMIT = 2_000_000;
6
+ function isProbablyBinary(buf) {
7
+ const len = Math.min(buf.length, 8000);
8
+ for (let i = 0; i < len; i++)
9
+ if (buf[i] === 0)
10
+ return true;
11
+ return false;
12
+ }
13
+ export function fileTools() {
14
+ return [
15
+ defineTool({
16
+ name: 'file_read',
17
+ description: 'Read a text file within the workspace, with optional line offset/limit.',
18
+ group: 'file',
19
+ mutates: false,
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ path: { type: 'string' },
24
+ offset: { type: 'number', description: 'Start line (0-based).' },
25
+ limit: { type: 'number', description: 'Max lines to return.' },
26
+ },
27
+ required: ['path'],
28
+ },
29
+ handler: async (args, ctx) => {
30
+ const abs = ctx.container.policy.path.resolveSafe(String(args.path), ctx.projectRoot);
31
+ const st = statSync(abs);
32
+ if (st.size > TEXT_LIMIT) {
33
+ return { ok: false, error: `File too large (${st.size} bytes). Use offset/limit chunking.` };
34
+ }
35
+ const buf = readFileSync(abs);
36
+ if (isProbablyBinary(buf)) {
37
+ return { ok: true, data: { binary: true, size: st.size, path: String(args.path) } };
38
+ }
39
+ let text = ctx.container.policy.secret.redact(buf.toString('utf8'));
40
+ if (args.offset !== undefined || args.limit !== undefined) {
41
+ const lines = text.split('\n');
42
+ const start = Number(args.offset ?? 0);
43
+ const count = args.limit !== undefined ? Number(args.limit) : lines.length;
44
+ text = lines.slice(start, start + count).join('\n');
45
+ }
46
+ return { ok: true, data: { content: text, size: st.size } };
47
+ },
48
+ }),
49
+ defineTool({
50
+ name: 'file_read_many',
51
+ description: 'Read multiple text files at once (bounded by count and bytes).',
52
+ group: 'file',
53
+ mutates: false,
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ paths: { type: 'array', items: { type: 'string' } },
58
+ maxFiles: { type: 'number' },
59
+ maxBytes: { type: 'number' },
60
+ },
61
+ required: ['paths'],
62
+ },
63
+ handler: async (args, ctx) => {
64
+ const paths = args.paths.slice(0, Number(args.maxFiles ?? 25));
65
+ const maxBytes = Number(args.maxBytes ?? 500_000);
66
+ let total = 0;
67
+ const out = {};
68
+ for (const p of paths) {
69
+ try {
70
+ const abs = ctx.container.policy.path.resolveSafe(p, ctx.projectRoot);
71
+ const buf = readFileSync(abs);
72
+ if (isProbablyBinary(buf)) {
73
+ out[p] = '[binary file omitted]';
74
+ continue;
75
+ }
76
+ if (total + buf.length > maxBytes) {
77
+ out[p] = '[skipped: byte budget exceeded]';
78
+ continue;
79
+ }
80
+ total += buf.length;
81
+ out[p] = ctx.container.policy.secret.redact(buf.toString('utf8'));
82
+ }
83
+ catch (err) {
84
+ out[p] = `[error: ${String(err)}]`;
85
+ }
86
+ }
87
+ return { ok: true, data: { files: out } };
88
+ },
89
+ }),
90
+ defineTool({
91
+ name: 'file_write',
92
+ description: 'Create or overwrite a text file within the workspace. Returns a diff.',
93
+ group: 'file',
94
+ mutates: true,
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: { path: { type: 'string' }, content: { type: 'string' } },
98
+ required: ['path', 'content'],
99
+ },
100
+ handler: async (args, ctx) => {
101
+ const abs = ctx.container.policy.path.resolveSafe(String(args.path), ctx.projectRoot);
102
+ const before = existsSync(abs) ? readFileSync(abs, 'utf8') : '';
103
+ const after = String(args.content);
104
+ mkdirSync(dirname(abs), { recursive: true });
105
+ writeFileSync(abs, after, 'utf8');
106
+ return { ok: true, diff: simpleDiff(before, after, String(args.path)), data: { written: true } };
107
+ },
108
+ }),
109
+ defineTool({
110
+ name: 'file_edit_block',
111
+ description: 'Replace an exact text block in a file. Refuses on occurrence mismatch.',
112
+ group: 'file',
113
+ mutates: true,
114
+ inputSchema: {
115
+ type: 'object',
116
+ properties: {
117
+ path: { type: 'string' },
118
+ oldText: { type: 'string' },
119
+ newText: { type: 'string' },
120
+ expectedOccurrences: { type: 'number' },
121
+ },
122
+ required: ['path', 'oldText', 'newText'],
123
+ },
124
+ handler: async (args, ctx) => {
125
+ const abs = ctx.container.policy.path.resolveSafe(String(args.path), ctx.projectRoot);
126
+ const before = readFileSync(abs, 'utf8');
127
+ const oldText = String(args.oldText);
128
+ const expected = args.expectedOccurrences !== undefined ? Number(args.expectedOccurrences) : 1;
129
+ const count = before.split(oldText).length - 1;
130
+ if (count === 0)
131
+ return { ok: false, error: 'oldText not found in file.' };
132
+ if (count !== expected) {
133
+ return { ok: false, error: `Found ${count} occurrences, expected ${expected}. Refusing to edit.` };
134
+ }
135
+ const after = before.split(oldText).join(String(args.newText));
136
+ writeFileSync(abs, after, 'utf8');
137
+ return { ok: true, diff: simpleDiff(before, after, String(args.path)), data: { replaced: count } };
138
+ },
139
+ }),
140
+ defineTool({
141
+ name: 'file_patch',
142
+ description: 'Apply a simple unified-style patch (single-file add/replace blocks).',
143
+ group: 'file',
144
+ mutates: true,
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } },
148
+ required: ['path', 'newText'],
149
+ },
150
+ handler: async (args, ctx) => {
151
+ // Pragmatic patch: treat as edit_block when oldText present, else full write.
152
+ const abs = ctx.container.policy.path.resolveSafe(String(args.path), ctx.projectRoot);
153
+ const before = existsSync(abs) ? readFileSync(abs, 'utf8') : '';
154
+ let after;
155
+ if (args.oldText) {
156
+ if (!before.includes(String(args.oldText)))
157
+ return { ok: false, error: 'Patch context not found.' };
158
+ after = before.replace(String(args.oldText), String(args.newText));
159
+ }
160
+ else {
161
+ after = String(args.newText);
162
+ }
163
+ mkdirSync(dirname(abs), { recursive: true });
164
+ writeFileSync(abs, after, 'utf8');
165
+ return { ok: true, diff: simpleDiff(before, after, String(args.path)), data: { patched: true } };
166
+ },
167
+ }),
168
+ defineTool({
169
+ name: 'file_move',
170
+ description: 'Move or rename a file or directory within the workspace. Both source and ' +
171
+ 'destination are boundary-checked. Refuses to overwrite unless overwrite=true.',
172
+ group: 'file',
173
+ mutates: true,
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ from: { type: 'string', description: 'Source path.' },
178
+ to: { type: 'string', description: 'Destination path.' },
179
+ overwrite: { type: 'boolean', description: 'Allow replacing an existing destination.' },
180
+ },
181
+ required: ['from', 'to'],
182
+ },
183
+ handler: async (args, ctx) => {
184
+ const fromAbs = ctx.container.policy.path.resolveSafe(String(args.from), ctx.projectRoot);
185
+ const toAbs = ctx.container.policy.path.resolveSafe(String(args.to), ctx.projectRoot);
186
+ if (!existsSync(fromAbs))
187
+ return { ok: false, error: `Source does not exist: ${args.from}` };
188
+ if (existsSync(toAbs) && args.overwrite !== true) {
189
+ return { ok: false, error: `Destination exists: ${args.to}. Pass overwrite=true to replace.` };
190
+ }
191
+ mkdirSync(dirname(toAbs), { recursive: true });
192
+ renameSync(fromAbs, toAbs);
193
+ return { ok: true, data: { moved: true, from: String(args.from), to: String(args.to) } };
194
+ },
195
+ }),
196
+ defineTool({
197
+ name: 'file_copy',
198
+ description: 'Copy a file or directory within the workspace. Directories are copied ' +
199
+ 'recursively. Refuses to overwrite unless overwrite=true.',
200
+ group: 'file',
201
+ mutates: true,
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ from: { type: 'string', description: 'Source path.' },
206
+ to: { type: 'string', description: 'Destination path.' },
207
+ overwrite: { type: 'boolean', description: 'Allow replacing an existing destination.' },
208
+ },
209
+ required: ['from', 'to'],
210
+ },
211
+ handler: async (args, ctx) => {
212
+ const fromAbs = ctx.container.policy.path.resolveSafe(String(args.from), ctx.projectRoot);
213
+ const toAbs = ctx.container.policy.path.resolveSafe(String(args.to), ctx.projectRoot);
214
+ if (!existsSync(fromAbs))
215
+ return { ok: false, error: `Source does not exist: ${args.from}` };
216
+ if (existsSync(toAbs) && args.overwrite !== true) {
217
+ return { ok: false, error: `Destination exists: ${args.to}. Pass overwrite=true to replace.` };
218
+ }
219
+ mkdirSync(dirname(toAbs), { recursive: true });
220
+ const isDir = statSync(fromAbs).isDirectory();
221
+ if (isDir) {
222
+ cpSync(fromAbs, toAbs, { recursive: true, force: args.overwrite === true });
223
+ }
224
+ else {
225
+ copyFileSync(fromAbs, toAbs);
226
+ }
227
+ return { ok: true, data: { copied: true, directory: isDir, from: String(args.from), to: String(args.to) } };
228
+ },
229
+ }),
230
+ defineTool({
231
+ name: 'list_directory',
232
+ description: 'List the entries of a directory within the workspace. Returns files and ' +
233
+ 'subdirectories with type and size. Non-recursive by default.',
234
+ group: 'file',
235
+ mutates: false,
236
+ inputSchema: {
237
+ type: 'object',
238
+ properties: {
239
+ path: { type: 'string', description: 'Directory path (default workspace root).' },
240
+ recursive: { type: 'boolean', description: 'Recurse into subdirectories.' },
241
+ maxEntries: { type: 'number', description: 'Cap on returned entries (default 1000).' },
242
+ },
243
+ },
244
+ handler: async (args, ctx) => {
245
+ const target = args.path !== undefined ? String(args.path) : '.';
246
+ const rootAbs = ctx.container.policy.path.resolveSafe(target, ctx.projectRoot);
247
+ if (!existsSync(rootAbs))
248
+ return { ok: false, error: `Directory does not exist: ${target}` };
249
+ if (!statSync(rootAbs).isDirectory())
250
+ return { ok: false, error: `Not a directory: ${target}` };
251
+ const max = Number(args.maxEntries ?? 1000);
252
+ const recursive = args.recursive === true;
253
+ const entries = [];
254
+ let truncated = false;
255
+ const walk = (dir) => {
256
+ if (truncated)
257
+ return;
258
+ let names;
259
+ try {
260
+ names = readdirSync(dir).sort();
261
+ }
262
+ catch {
263
+ return;
264
+ }
265
+ for (const name of names) {
266
+ if (entries.length >= max) {
267
+ truncated = true;
268
+ return;
269
+ }
270
+ const abs = join(dir, name);
271
+ // Skip anything the path policy denies (secrets, node_modules, .git internals).
272
+ if (ctx.container.policy.path.isDenied(abs, ctx.projectRoot))
273
+ continue;
274
+ let st;
275
+ try {
276
+ st = statSync(abs);
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ const isDir = st.isDirectory();
282
+ entries.push({
283
+ path: relative(ctx.projectRoot, abs).split(sep).join('/'),
284
+ type: isDir ? 'dir' : 'file',
285
+ size: isDir ? 0 : st.size,
286
+ });
287
+ if (recursive && isDir)
288
+ walk(abs);
289
+ }
290
+ };
291
+ walk(rootAbs);
292
+ return {
293
+ ok: true,
294
+ data: {
295
+ path: relative(ctx.projectRoot, rootAbs).split(sep).join('/') || '.',
296
+ recursive,
297
+ count: entries.length,
298
+ truncated,
299
+ entries,
300
+ },
301
+ };
302
+ },
303
+ }),
304
+ defineTool({
305
+ name: 'file_delete',
306
+ description: 'Delete a file within the workspace. Requires approval by default.',
307
+ group: 'file',
308
+ mutates: true,
309
+ inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
310
+ handler: async (args, ctx) => {
311
+ const abs = ctx.container.policy.path.resolveSafe(String(args.path), ctx.projectRoot);
312
+ if (!existsSync(abs))
313
+ return { ok: false, error: 'File does not exist.' };
314
+ unlinkSync(abs);
315
+ return { ok: true, data: { deleted: String(args.path) } };
316
+ },
317
+ }),
318
+ ];
319
+ }
@@ -0,0 +1,118 @@
1
+ import { execa } from 'execa';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { defineTool } from './registry.js';
5
+ import { detectProject } from '../workspace/project-detector.js';
6
+ /**
7
+ * Resolve the formatter for a project. Prefers explicit config files, then
8
+ * language defaults. Returns null when none is detectable.
9
+ */
10
+ export function detectFormatter(root) {
11
+ const has = (f) => existsSync(join(root, f));
12
+ // JS/TS: prefer Prettier, then Biome.
13
+ if (has('.prettierrc') ||
14
+ has('.prettierrc.json') ||
15
+ has('.prettierrc.js') ||
16
+ has('prettier.config.js') ||
17
+ has('.prettierrc.yaml')) {
18
+ return {
19
+ id: 'prettier',
20
+ check: ['npx', 'prettier', '--check', '.'],
21
+ apply: ['npx', 'prettier', '--write', '.'],
22
+ };
23
+ }
24
+ if (has('biome.json') || has('biome.jsonc')) {
25
+ return {
26
+ id: 'biome',
27
+ check: ['npx', 'biome', 'check', '.'],
28
+ apply: ['npx', 'biome', 'check', '--write', '.'],
29
+ };
30
+ }
31
+ // Python: Ruff formatter, then Black.
32
+ if (has('pyproject.toml') || has('ruff.toml') || has('.ruff.toml')) {
33
+ return {
34
+ id: 'ruff',
35
+ check: ['ruff', 'format', '--check', '.'],
36
+ apply: ['ruff', 'format', '.'],
37
+ };
38
+ }
39
+ if (has('requirements.txt')) {
40
+ return {
41
+ id: 'black',
42
+ check: ['black', '--check', '.'],
43
+ apply: ['black', '.'],
44
+ };
45
+ }
46
+ // Go / Rust.
47
+ if (has('go.mod')) {
48
+ return { id: 'gofmt', check: ['gofmt', '-l', '.'], apply: ['gofmt', '-w', '.'] };
49
+ }
50
+ if (has('Cargo.toml')) {
51
+ return { id: 'rustfmt', check: ['cargo', 'fmt', '--', '--check'], apply: ['cargo', 'fmt'] };
52
+ }
53
+ // JS/TS fallback when package.json exists but no formatter config: try prettier.
54
+ const proj = detectProject(root);
55
+ if (proj.languageHints.includes('typescript')) {
56
+ return {
57
+ id: 'prettier',
58
+ check: ['npx', 'prettier', '--check', '.'],
59
+ apply: ['npx', 'prettier', '--write', '.'],
60
+ };
61
+ }
62
+ return null;
63
+ }
64
+ async function runFmt(ctx, argv) {
65
+ const [bin, ...rest] = argv;
66
+ const sub = await execa(bin, rest, {
67
+ cwd: ctx.projectRoot,
68
+ timeout: ctx.config.terminal.defaultTimeoutMs,
69
+ reject: false,
70
+ maxBuffer: ctx.config.terminal.maxOutputBytes * 4,
71
+ });
72
+ const max = ctx.config.terminal.maxOutputBytes;
73
+ const redact = ctx.container.policy.secret.redact;
74
+ return {
75
+ ok: sub.exitCode === 0,
76
+ data: {
77
+ command: argv.join(' '),
78
+ exitCode: sub.exitCode ?? null,
79
+ stdout: redact((sub.stdout ?? '').slice(0, max)),
80
+ stderr: redact((sub.stderr ?? '').slice(0, max)),
81
+ },
82
+ };
83
+ }
84
+ export function formatTools() {
85
+ return [
86
+ defineTool({
87
+ name: 'format_check',
88
+ description: 'Check formatting without writing (auto-detects Prettier/Biome/Ruff/Black/gofmt/rustfmt).',
89
+ group: 'format',
90
+ mutates: false,
91
+ inputSchema: { type: 'object', properties: {} },
92
+ handler: async (_a, ctx) => {
93
+ const fmt = detectFormatter(ctx.projectRoot);
94
+ if (!fmt)
95
+ return { ok: false, error: 'No formatter detected for this project.' };
96
+ const res = await runFmt(ctx, fmt.check);
97
+ res.data.formatter = fmt.id;
98
+ // A non-zero exit from a check means "would reformat", not a hard error.
99
+ return { ok: true, data: { ...res.data, needsFormatting: !res.ok } };
100
+ },
101
+ }),
102
+ defineTool({
103
+ name: 'format_apply',
104
+ description: 'Apply formatting in place (auto-detects the project formatter). Mutates files.',
105
+ group: 'format',
106
+ mutates: true,
107
+ inputSchema: { type: 'object', properties: {} },
108
+ handler: async (_a, ctx) => {
109
+ const fmt = detectFormatter(ctx.projectRoot);
110
+ if (!fmt)
111
+ return { ok: false, error: 'No formatter detected for this project.' };
112
+ const res = await runFmt(ctx, fmt.apply);
113
+ res.data.formatter = fmt.id;
114
+ return res;
115
+ },
116
+ }),
117
+ ];
118
+ }