loom-agent 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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Tiny synchronous event bus. Core emits lifecycle events (turn start/end,
|
|
2
|
+
// model switch, tool calls, cost recorded); features like the pet, toasts, and
|
|
3
|
+
// telemetry subscribe without coupling.
|
|
4
|
+
const listeners = new Map();
|
|
5
|
+
|
|
6
|
+
function on(event, fn) {
|
|
7
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
8
|
+
listeners.get(event).add(fn);
|
|
9
|
+
return () => listeners.get(event).delete(fn);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function off(event, fn) {
|
|
13
|
+
listeners.get(event)?.delete(fn);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function emit(event, data) {
|
|
17
|
+
const set = listeners.get(event);
|
|
18
|
+
if (!set) return;
|
|
19
|
+
for (const fn of Array.from(set)) {
|
|
20
|
+
try { fn(data); } catch {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { on, off, emit };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// File-diff capture for the chat area — right-side visual panel showing
|
|
2
|
+
// which files the agent edited and the actual +/- hunks.
|
|
3
|
+
// Snapshot BEFORE a write/edit/bash tool runs, then record AFTER to build the diff.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const { diffLines } = require('diff');
|
|
8
|
+
|
|
9
|
+
const cwd = process.cwd();
|
|
10
|
+
|
|
11
|
+
// Binary content (png/pdf/zip/exe…) read as utf8 is meaningless garbage, and
|
|
12
|
+
// diffing it fills the panel with noise. Detect it up front and show a short
|
|
13
|
+
// "(binary file)" line instead of the hunks.
|
|
14
|
+
function looksBinary(text) {
|
|
15
|
+
if (!text) return false;
|
|
16
|
+
const sample = text.slice(0, 8192);
|
|
17
|
+
if (sample.indexOf('\0') >= 0) return true;
|
|
18
|
+
let bad = 0;
|
|
19
|
+
for (let i = 0; i < sample.length; i++) {
|
|
20
|
+
const c = sample.charCodeAt(i);
|
|
21
|
+
if (c < 8) bad++;
|
|
22
|
+
}
|
|
23
|
+
return bad / Math.max(1, sample.length) > 0.02;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Mirrors the TUI file walker's ignore list (node_modules, .git, dist, …).
|
|
27
|
+
const IGNORE_RX = /(^|[\/])(node_modules|\.git|dist|build|\.next|\.venv|venv|coverage|__pycache__|\.loom|\.idea|\.vscode)([\/]|$)/i;
|
|
28
|
+
|
|
29
|
+
function relPath(abs) {
|
|
30
|
+
const rel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
31
|
+
return rel || abs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read file content, or null if missing.
|
|
35
|
+
function readFileOrNull(abs) {
|
|
36
|
+
try { return fs.readFileSync(abs, 'utf8'); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Track edits per session: abs path -> { before, after }
|
|
40
|
+
const edits = new Map();
|
|
41
|
+
|
|
42
|
+
export function snapshotBefore(filePath) {
|
|
43
|
+
const abs = path.resolve(filePath);
|
|
44
|
+
const before = readFileOrNull(abs);
|
|
45
|
+
edits.set(abs, { before, after: before });
|
|
46
|
+
return { abs, before };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// After the tool finished, re-read the file and build the diff vs. the
|
|
50
|
+
// pre-edit snapshot. Accumulates: if the same file is edited twice in one
|
|
51
|
+
// turn, diff is always against the ORIGINAL snapshot (cumulative view).
|
|
52
|
+
export function snapshotAfter(filePath) {
|
|
53
|
+
const abs = path.resolve(filePath);
|
|
54
|
+
const prev = edits.get(abs) || { before: null };
|
|
55
|
+
const after = readFileOrNull(abs);
|
|
56
|
+
prev.after = after;
|
|
57
|
+
edits.set(abs, prev);
|
|
58
|
+
return buildFileDiff(abs, prev.before, prev.after);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Bash tool detection ───
|
|
62
|
+
// The bash tool can modify files outside write/edit (sed -i, git apply, npm
|
|
63
|
+
// install, scaffolders, …). Snapshot the repo state before the call, then
|
|
64
|
+
// diff after it: git repos use `git diff` (accurate, no content copies);
|
|
65
|
+
// non-git directories snapshot small file contents + mtimes.
|
|
66
|
+
let bashBefore = null; // { git: bool, files: Map<abs, {content|null, mtimeMs, size}>, untracked: Set<abs> }
|
|
67
|
+
|
|
68
|
+
function walkFiles(root, depth, out) {
|
|
69
|
+
if (depth > 5 || out.length > 400) return;
|
|
70
|
+
let entries;
|
|
71
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
const full = path.join(root, e.name);
|
|
74
|
+
if (IGNORE_RX.test(full)) continue;
|
|
75
|
+
if (e.isDirectory()) walkFiles(full, depth + 1, out);
|
|
76
|
+
else out.push(full);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function git(args) {
|
|
81
|
+
try {
|
|
82
|
+
return execSync('git ' + args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function snapshotBashBefore() {
|
|
89
|
+
const isGit = git('rev-parse --is-inside-work-tree') === 'true';
|
|
90
|
+
const files = new Map();
|
|
91
|
+
if (isGit) {
|
|
92
|
+
const untracked = new Set();
|
|
93
|
+
const st = git('status --porcelain');
|
|
94
|
+
for (const line of String(st || '').split('\n')) {
|
|
95
|
+
if (/^\?\?/.test(line)) untracked.add(path.resolve(cwd, line.slice(3).trim()));
|
|
96
|
+
}
|
|
97
|
+
bashBefore = { git: true, files, untracked };
|
|
98
|
+
} else {
|
|
99
|
+
const list = [];
|
|
100
|
+
walkFiles(cwd, 0, list);
|
|
101
|
+
for (const f of list) {
|
|
102
|
+
try {
|
|
103
|
+
const st = fs.statSync(f);
|
|
104
|
+
let content = null;
|
|
105
|
+
if (st.size <= 1024 * 1024) content = readFileOrNull(f);
|
|
106
|
+
files.set(f, { content, mtimeMs: st.mtimeMs, size: st.size });
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
bashBefore = { git: false, files, untracked: new Set() };
|
|
110
|
+
}
|
|
111
|
+
return isGit;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// After the bash tool finished, return diffs for everything it changed.
|
|
115
|
+
export function diffBashAfter() {
|
|
116
|
+
if (!bashBefore) return [];
|
|
117
|
+
const out = [];
|
|
118
|
+
if (bashBefore.git) {
|
|
119
|
+
const diffText = git('diff --no-color --no-ext-diff') || '';
|
|
120
|
+
for (const d of parseGitDiff(diffText)) out.push(d);
|
|
121
|
+
// New untracked files → all-added diffs.
|
|
122
|
+
const st = git('status --porcelain') || '';
|
|
123
|
+
for (const line of st.split('\n')) {
|
|
124
|
+
if (!/^\?\?/.test(line)) continue;
|
|
125
|
+
const abs = path.resolve(cwd, line.slice(3).trim());
|
|
126
|
+
if (bashBefore.untracked.has(abs)) continue; // was already there
|
|
127
|
+
const d = buildFileDiff(abs, null, readFileOrNull(abs));
|
|
128
|
+
if (d.added || d.removed) out.push(d);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const list = [];
|
|
132
|
+
walkFiles(cwd, 0, list);
|
|
133
|
+
const now = new Map();
|
|
134
|
+
for (const f of list) {
|
|
135
|
+
try {
|
|
136
|
+
const st = fs.statSync(f);
|
|
137
|
+
now.set(f, { mtimeMs: st.mtimeMs, size: st.size });
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
// Changed or deleted files.
|
|
141
|
+
const changed = [];
|
|
142
|
+
for (const [abs, st] of bashBefore.files) {
|
|
143
|
+
const cur = now.get(abs);
|
|
144
|
+
if (!cur || cur.mtimeMs !== st.mtimeMs || cur.size !== st.size) changed.push(abs);
|
|
145
|
+
}
|
|
146
|
+
for (const abs of now.keys()) {
|
|
147
|
+
if (!bashBefore.files.has(abs)) changed.push(abs); // newly created
|
|
148
|
+
}
|
|
149
|
+
for (const abs of changed) {
|
|
150
|
+
const before = bashBefore.files.get(abs)?.content;
|
|
151
|
+
const after = readFileOrNull(abs);
|
|
152
|
+
const d = buildFileDiff(abs, before, after);
|
|
153
|
+
if (d.added || d.removed) out.push(d);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
bashBefore = null;
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Parse a unified `git diff` into per-file { path, added, removed, lines }.
|
|
161
|
+
function parseGitDiff(text) {
|
|
162
|
+
const out = [];
|
|
163
|
+
const filePat = /^diff --git a\/(.*) b\/(.*)$/;
|
|
164
|
+
let cur = null;
|
|
165
|
+
const lines = String(text).split('\n');
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const fm = line.match(filePat);
|
|
168
|
+
if (fm) {
|
|
169
|
+
if (cur && (cur.added || cur.removed)) out.push(cur);
|
|
170
|
+
const p = fm[2].replace(/^"|"$/g, '');
|
|
171
|
+
cur = { path: p, abs: path.resolve(cwd, p), added: 0, removed: 0, lines: /** @type {Array<{kind: string, text: string}>} */ ([]) };
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!cur) continue;
|
|
175
|
+
if (line.startsWith('@@')) continue;
|
|
176
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
177
|
+
if (line.startsWith('\\')) continue;
|
|
178
|
+
if (/^Binary files/.test(line)) { cur.added++; cur.removed++; cur.lines.push({ kind: 'ctx', text: '(binary file changed)' }); continue; }
|
|
179
|
+
if (line.startsWith('+')) { if (looksBinary(line.slice(1))) continue; cur.added++; cur.lines.push({ kind: 'add', text: line.slice(1) }); }
|
|
180
|
+
else if (line.startsWith('-')) { if (looksBinary(line.slice(1))) continue; cur.removed++; cur.lines.push({ kind: 'del', text: line.slice(1) }); }
|
|
181
|
+
else { if (looksBinary(line.slice(1))) continue; cur.lines.push({ kind: 'ctx', text: line.slice(1) }); }
|
|
182
|
+
}
|
|
183
|
+
if (cur && (cur.added || cur.removed)) out.push(cur);
|
|
184
|
+
for (const d of out) {
|
|
185
|
+
d.lines = trimContext(d.lines, 2);
|
|
186
|
+
d.lines = d.lines.length > 24 ? d.lines.slice(0, 24) : d.lines;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build a compact visual diff: counts + colored hunk lines.
|
|
192
|
+
export function buildFileDiff(abs, before, after) {
|
|
193
|
+
if (looksBinary(after) || looksBinary(before)) {
|
|
194
|
+
// Unchanged binary file (snapshot taken before a tool that didn't touch
|
|
195
|
+
// it) → empty diff; don't show a bogus "0 bytes changed" entry.
|
|
196
|
+
if (before !== null && after !== null && before === after) {
|
|
197
|
+
return { path: relPath(abs), abs, added: 0, removed: 0, lines: [], isNew: false };
|
|
198
|
+
}
|
|
199
|
+
const beforeBytes = before ? Buffer.byteLength(before, 'utf8') : 0;
|
|
200
|
+
const afterBytes = after ? Buffer.byteLength(after, 'utf8') : 0;
|
|
201
|
+
const bytesChanged = Math.max(0, Math.abs(afterBytes - beforeBytes));
|
|
202
|
+
return {
|
|
203
|
+
path: relPath(abs),
|
|
204
|
+
abs,
|
|
205
|
+
added: 1,
|
|
206
|
+
removed: 0,
|
|
207
|
+
lines: [{ kind: 'ctx', text: '(binary file, ' + bytesChanged + ' bytes changed)' }],
|
|
208
|
+
isNew: before === null && after !== null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const parts = diffLines(before || '', after || '');
|
|
212
|
+
let added = 0;
|
|
213
|
+
let removed = 0;
|
|
214
|
+
const hunks = [];
|
|
215
|
+
for (const part of parts) {
|
|
216
|
+
if (part.added) { added += part.count; }
|
|
217
|
+
else if (part.removed) { removed += part.count; }
|
|
218
|
+
}
|
|
219
|
+
// Line-by-line with kind markers; trim context to keep the panel compact.
|
|
220
|
+
const lines = diffToLines(parts);
|
|
221
|
+
const trimmed = trimContext(lines, 2);
|
|
222
|
+
const shown = trimmed.length > 24 ? trimmed.slice(0, 24) : trimmed;
|
|
223
|
+
return {
|
|
224
|
+
path: relPath(abs),
|
|
225
|
+
abs,
|
|
226
|
+
added,
|
|
227
|
+
removed,
|
|
228
|
+
lines: shown,
|
|
229
|
+
isNew: before === null && after !== null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function diffToLines(parts) {
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const part of parts) {
|
|
236
|
+
if (part.added) out.push({ kind: 'add', text: part.value });
|
|
237
|
+
else if (part.removed) out.push({ kind: 'del', text: part.value });
|
|
238
|
+
else out.push({ kind: 'ctx', text: part.value });
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Keep 2 context lines around each hunk, drop the rest (ellipsis marker).
|
|
244
|
+
function trimContext(lines, ctx) {
|
|
245
|
+
if (!lines.length) return lines;
|
|
246
|
+
const keep = [];
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
if (lines[i].kind !== 'ctx') {
|
|
249
|
+
for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) {
|
|
250
|
+
if (!keep.includes(j)) keep.push(j);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
keep.sort((a, b) => a - b);
|
|
255
|
+
if (!keep.length) return [];
|
|
256
|
+
const out = [];
|
|
257
|
+
let last = -10;
|
|
258
|
+
for (const i of keep) {
|
|
259
|
+
if (i - last > 1) out.push({ kind: 'ctx', text: '…' });
|
|
260
|
+
out.push(lines[i]);
|
|
261
|
+
last = i;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function clearFileDiffs() { edits.clear(); }
|
|
267
|
+
export function getFileDiffs() {
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const [abs, e] of edits) {
|
|
270
|
+
const d = buildFileDiff(abs, e.before, e.after);
|
|
271
|
+
if (d.added || d.removed) out.push(d);
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
export function formatDiffCount(d) {
|
|
276
|
+
const parts = [];
|
|
277
|
+
if (d.added) parts.push('+' + d.added);
|
|
278
|
+
if (d.removed) parts.push('-' + d.removed);
|
|
279
|
+
return parts.join(' ') || '±0';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { parseGitDiff };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Formatters — run language-specific formatters on files after the agent
|
|
2
|
+
// writes/edits them (OpenCode-style). Disabled by default; enable via
|
|
3
|
+
// config.json `formatter`: true (all built-ins) or an object of per-formatter
|
|
4
|
+
// overrides / custom formatters.
|
|
5
|
+
//
|
|
6
|
+
// formatter: false → all disabled (default)
|
|
7
|
+
// formatter: true → all built-ins enabled
|
|
8
|
+
// formatter: { ... } → built-ins enabled + overrides
|
|
9
|
+
// formatter: { prettier: { disabled: true } } → disable one
|
|
10
|
+
// formatter: { gofmt: { command: ["gofmt","-w","$FILE"], extensions: [".go"] } } → override
|
|
11
|
+
// formatter: { myfmt: { command: ["fmt", "$FILE"], extensions: [".zzz"] } } → custom
|
|
12
|
+
//
|
|
13
|
+
// The `$FILE` placeholder is replaced with the formatted file's path. When a
|
|
14
|
+
// command has no `$FILE`, the path is appended as the final argument.
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { spawn } = require('child_process');
|
|
18
|
+
const { loadConfig } = require('../config/settings');
|
|
19
|
+
|
|
20
|
+
/** @typedef {Object} FormatterDef
|
|
21
|
+
* @property {Array<string>} command
|
|
22
|
+
* @property {Array<string>} extensions
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @typedef {Object} FormatResult
|
|
26
|
+
* @property {boolean} formatted
|
|
27
|
+
* @property {string} [id]
|
|
28
|
+
* @property {Array<string>} [command]
|
|
29
|
+
* @property {string} [reason]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @type {Record<string, FormatterDef>} */
|
|
33
|
+
const DEFAULT_FORMATTERS = {
|
|
34
|
+
prettier: {
|
|
35
|
+
command: ['npx', 'prettier', '--write', '$FILE'],
|
|
36
|
+
extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.html', '.css', '.scss', '.md', '.json', '.json5', '.yaml', '.yml'],
|
|
37
|
+
},
|
|
38
|
+
biome: {
|
|
39
|
+
command: ['npx', 'biome', 'format', '--write', '$FILE'],
|
|
40
|
+
extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.html', '.css', '.json', '.jsonc', '.md'],
|
|
41
|
+
},
|
|
42
|
+
gofmt: { command: ['gofmt', '-w', '$FILE'], extensions: ['.go'] },
|
|
43
|
+
rustfmt: { command: ['rustfmt', '--edition', '2021', '$FILE'], extensions: ['.rs'] },
|
|
44
|
+
ruff: { command: ['ruff', 'format', '$FILE'], extensions: ['.py', '.pyi'] },
|
|
45
|
+
uv: { command: ['uv', 'fmt', '$FILE'], extensions: ['.py', '.pyi'] },
|
|
46
|
+
clangformat: {
|
|
47
|
+
command: ['clang-format', '-i', '$FILE'],
|
|
48
|
+
extensions: ['.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.ino', '.m', '.mm'],
|
|
49
|
+
},
|
|
50
|
+
shfmt: { command: ['shfmt', '-w', '$FILE'], extensions: ['.sh', '.bash'] },
|
|
51
|
+
ktlint: { command: ['ktlint', '-F', '$FILE'], extensions: ['.kt', '.kts'] },
|
|
52
|
+
dart: { command: ['dart', 'format', '$FILE'], extensions: ['.dart'] },
|
|
53
|
+
terraform: { command: ['terraform', 'fmt', '$FILE'], extensions: ['.tf', '.tfvars'] },
|
|
54
|
+
mix: { command: ['mix', 'format', '$FILE'], extensions: ['.ex', '.exs', '.eex', '.heex', '.leex', '.neex'] },
|
|
55
|
+
gleam: { command: ['gleam', 'format', '$FILE'], extensions: ['.gleam'] },
|
|
56
|
+
zig: { command: ['zig', 'fmt', '$FILE'], extensions: ['.zig', '.zon'] },
|
|
57
|
+
nixfmt: { command: ['nixfmt', '$FILE'], extensions: ['.nix'] },
|
|
58
|
+
ormolu: { command: ['ormolu', '--mode', 'inplace', '$FILE'], extensions: ['.hs'] },
|
|
59
|
+
ocamlformat: { command: ['ocamlformat', '--inplace', '$FILE'], extensions: ['.ml', '.mli'] },
|
|
60
|
+
rubocop: { command: ['rubocop', '-a', '$FILE'], extensions: ['.rb', '.rake', '.gemspec', '.ru'] },
|
|
61
|
+
standardrb: { command: ['standardrb', '--fix', '$FILE'], extensions: ['.rb', '.rake', '.gemspec', '.ru'] },
|
|
62
|
+
htmlbeautifier: { command: ['htmlbeautifier', '$FILE'], extensions: ['.erb'] },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the enabled formatter set from config + built-ins.
|
|
67
|
+
* @returns {{enabled: boolean, formatters: Record<string, FormatterDef>}}
|
|
68
|
+
*/
|
|
69
|
+
function enabledFormatters() {
|
|
70
|
+
const cfg = loadConfig();
|
|
71
|
+
const f = cfg.formatter;
|
|
72
|
+
if (f === false || f === undefined || f === null) return { enabled: false, formatters: /** @type {Record<string, FormatterDef>} */ ({}) };
|
|
73
|
+
if (f === true) return { enabled: true, formatters: { ...DEFAULT_FORMATTERS } };
|
|
74
|
+
if (f && typeof f === 'object') {
|
|
75
|
+
const out = /** @type {Record<string, FormatterDef>} */ ({});
|
|
76
|
+
for (const [id, def] of Object.entries(DEFAULT_FORMATTERS)) {
|
|
77
|
+
const u = f[id];
|
|
78
|
+
const merged = { command: def.command, extensions: def.extensions, ...(u && typeof u === 'object' ? u : {}) };
|
|
79
|
+
if (merged.disabled) continue;
|
|
80
|
+
out[id] = /** @type {FormatterDef} */ ({ command: merged.command, extensions: merged.extensions });
|
|
81
|
+
}
|
|
82
|
+
// Custom formatters: any object key that isn't built-in with command+extensions.
|
|
83
|
+
for (const [id, u] of Object.entries(f)) {
|
|
84
|
+
if (!u || typeof u !== 'object') continue;
|
|
85
|
+
if (u.command && Array.isArray(u.extensions)) {
|
|
86
|
+
if (!u.disabled) out[id] = /** @type {FormatterDef} */ ({ command: u.command, extensions: u.extensions });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { enabled: true, formatters: out };
|
|
90
|
+
}
|
|
91
|
+
return { enabled: false, formatters: /** @type {Record<string, FormatterDef>} */ ({}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Substitute the $FILE placeholder (or append the path) in a command.
|
|
95
|
+
* @param {Array<string>} command
|
|
96
|
+
* @param {string} filePath
|
|
97
|
+
* @returns {Array<string>} */
|
|
98
|
+
function buildCommand(command, filePath) {
|
|
99
|
+
if (command.includes('$FILE')) {
|
|
100
|
+
return command.map((a) => (a === '$FILE' ? filePath : a));
|
|
101
|
+
}
|
|
102
|
+
return command.concat([filePath]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pick the first enabled formatter that handles the given extension.
|
|
106
|
+
* @param {string} ext
|
|
107
|
+
* @returns {{found: boolean, id?: string, command?: Array<string>, reason?: string}} */
|
|
108
|
+
function resolveFormatter(ext) {
|
|
109
|
+
const { enabled, formatters } = enabledFormatters();
|
|
110
|
+
if (!enabled) return { found: false, reason: 'formatters are disabled (set config formatter: true to enable)' };
|
|
111
|
+
const extLower = ext.toLowerCase();
|
|
112
|
+
const ids = Object.keys(formatters).sort();
|
|
113
|
+
for (const id of ids) {
|
|
114
|
+
const def = formatters[id];
|
|
115
|
+
if (def.extensions.some((e) => e.toLowerCase() === extLower)) {
|
|
116
|
+
// Return the raw template — the $FILE placeholder is substituted with the
|
|
117
|
+
// real path in formatFile (resolveFormatter has no path yet).
|
|
118
|
+
return { found: true, id, command: def.command.slice() };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { found: false, reason: `no enabled formatter handles extension "${ext}"` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Run the file through its formatter in place.
|
|
125
|
+
* @param {string} filePath
|
|
126
|
+
* @returns {Promise<FormatResult>} */
|
|
127
|
+
function formatFile(filePath) {
|
|
128
|
+
const ext = path.extname(filePath);
|
|
129
|
+
const resolved = resolveFormatter(ext);
|
|
130
|
+
if (!resolved.found) return Promise.resolve({ formatted: false, reason: resolved.reason });
|
|
131
|
+
const template = resolved.command;
|
|
132
|
+
const id = resolved.id;
|
|
133
|
+
if (!template || !id) return Promise.resolve({ formatted: false, reason: 'formatter not resolved' });
|
|
134
|
+
const command = buildCommand(template, filePath);
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
let child;
|
|
137
|
+
try {
|
|
138
|
+
child = spawn(command[0], command.slice(1), {
|
|
139
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
});
|
|
142
|
+
} catch (e) {
|
|
143
|
+
resolve({ formatted: false, reason: e && e.message ? e.message : String(e) });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let stdout = '';
|
|
147
|
+
let stderr = '';
|
|
148
|
+
let settled = false;
|
|
149
|
+
const done = (res) => { if (!settled) { settled = true; clearTimeout(timer); resolve(res); } };
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
try { child.kill(); } catch {}
|
|
152
|
+
done({ formatted: false, reason: `${id} timed out after 30000ms` });
|
|
153
|
+
}, 30000);
|
|
154
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
155
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
156
|
+
child.on('error', (err) => {
|
|
157
|
+
const errCode = err && 'code' in err ? err.code : undefined;
|
|
158
|
+
if (errCode === 'ENOENT') {
|
|
159
|
+
done({ formatted: false, reason: `formatter command not found: ${command[0]}. Install it or disable the ${id} formatter.` });
|
|
160
|
+
} else {
|
|
161
|
+
done({ formatted: false, reason: err && err.message ? err.message : String(err) });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
child.on('close', (code) => {
|
|
165
|
+
if (code !== 0) {
|
|
166
|
+
const msg = String(stderr || stdout || '').trim();
|
|
167
|
+
done({ formatted: false, reason: `${id} exited ${code}: ${msg.slice(0, 300)}` });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
done({ formatted: true, id, command });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Convenience for the tool layer: format a file just written; on success
|
|
176
|
+
* return a short note to append to the tool result.
|
|
177
|
+
* @param {string} filePath
|
|
178
|
+
* @returns {Promise<string>} */
|
|
179
|
+
async function formatAfterWrite(filePath) {
|
|
180
|
+
if (!fs.existsSync(filePath)) return '';
|
|
181
|
+
const res = await formatFile(filePath);
|
|
182
|
+
return res.formatted ? `\n[formatted by ${res.id}]` : '';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A names/status summary of the current formatter setup (for /format).
|
|
186
|
+
* @returns {Array<string>} */
|
|
187
|
+
function formatStatusLines() {
|
|
188
|
+
const { enabled, formatters } = enabledFormatters();
|
|
189
|
+
const lines = [`Formatters: ${enabled ? 'ENABLED' : 'DISABLED'} (set config.json "formatter": true to enable)`];
|
|
190
|
+
if (enabled) {
|
|
191
|
+
for (const [id, def] of Object.entries(formatters)) {
|
|
192
|
+
lines.push(` ${id.padEnd(16)} ${def.extensions.join(' ')}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
DEFAULT_FORMATTERS,
|
|
200
|
+
enabledFormatters,
|
|
201
|
+
resolveFormatter,
|
|
202
|
+
buildCommand,
|
|
203
|
+
formatFile,
|
|
204
|
+
formatAfterWrite,
|
|
205
|
+
formatStatusLines,
|
|
206
|
+
};
|