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.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,579 @@
1
+ const readline = require('readline');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { Session, MEMORY_TEMPLATE } = require('./session');
6
+ const { connect, disconnect, status } = require('../config/provider-cmd');
7
+ const { loadConfig, saveConfig, LOOM_DIR } = require('../config/settings');
8
+ const { ProviderRouter, PROVIDERS } = require('../providers');
9
+ const { saveSession, loadSession } = require('./session-store');
10
+ const { defaultMcpInstall } = require('./plugin-cmd');
11
+
12
+ const LOOM_BASE = `
13
+ ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
14
+ ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
15
+ ██║ ██║ ██║██║ ██║██╔████╔██║ ██║ ██║ ██║██║ ██║█████╗
16
+ ██║ ██║ ██║██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██╔══╝
17
+ ███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║ ╚██████╗██████╔╝██████╔╝███████╗
18
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═════╝ ╚═════╝ ╚══════╝`;
19
+
20
+ class LoomCLI {
21
+ /** @type {import('readline').Interface} */
22
+ rl = readline.createInterface({
23
+ input: process.stdin,
24
+ output: process.stdout,
25
+ prompt: '\x1b[36m> \x1b[0m',
26
+ completer: (line) => this.completer(line),
27
+ terminal: true,
28
+ });
29
+ /** @type {import('./session').Session} */
30
+ session = new Session();
31
+ constructor() {
32
+ this.running = true;
33
+ this.config = loadConfig();
34
+ this.initialPrompt = null;
35
+ this.initialSession = null;
36
+ }
37
+
38
+ async start() {
39
+ console.log(LOOM_BASE);
40
+ console.log(`\n Loom Code v1.0.0 — AI Coding Agent for the terminal`);
41
+ console.log(` Press Ctrl+C or ESC to interrupt | /help for commands\n`);
42
+
43
+ process.stdin.on('keypress', (str, key) => {
44
+ if (key && key.name === 'escape') {
45
+ this.onEscape();
46
+ }
47
+ });
48
+
49
+ if (this.initialSession) {
50
+ this.session.messages = this.initialSession.messages || [];
51
+ this.session.conversationId = this.initialSession.id || this.session.conversationId;
52
+ }
53
+ this.running = true;
54
+
55
+ this.showPrompt();
56
+ if (this.initialPrompt) {
57
+ const prompt = this.initialPrompt;
58
+ this.initialPrompt = null;
59
+ setImmediate(() => this.rl.emit('line', prompt));
60
+ }
61
+ return this.runLoop();
62
+ }
63
+
64
+ onEscape() {
65
+ if (this.session) {
66
+ this.session.interrupt();
67
+ }
68
+ }
69
+
70
+ showPrompt() {
71
+ process.stdout.write(`\n👁 > `);
72
+ }
73
+
74
+ async runLoop() {
75
+ return new Promise((resolve) => {
76
+ this.rl.on('line', async (line) => {
77
+ try {
78
+ const trimmed = line.trim();
79
+ if (!trimmed) { this.showPrompt(); return; }
80
+
81
+ if (trimmed.startsWith('/')) {
82
+ await this.handleSlashCommand(trimmed);
83
+ } else {
84
+ await this.handleMessage(trimmed);
85
+ }
86
+ } catch (err) {
87
+ console.log(`\nError: ${err.message}`);
88
+ }
89
+ this.showPrompt();
90
+ });
91
+ });
92
+ }
93
+
94
+ async handleMessage(text) {
95
+ console.log(`\n[You] ${text}`);
96
+ console.log('\n[Loom is thinking...]');
97
+
98
+ try {
99
+ const resp = await this.session.sendUserMessage(text);
100
+ this.printResponse(resp);
101
+ } catch (err) {
102
+ console.log(`\nError: ${err.message}`);
103
+ }
104
+ }
105
+
106
+ printResponse(resp) {
107
+ if (!resp) return console.log('(no response)');
108
+ if (resp.type === 'text') {
109
+ console.log(`\n[Loom] ${resp.content}`);
110
+ } else if (resp.type === 'error') {
111
+ console.log(`\n[Error] ${resp.content}`);
112
+ } else if (resp.type === 'tool_call') {
113
+ console.log(`\n[Tool: ${resp.name}(${JSON.stringify(resp.params)})]`);
114
+ } else {
115
+ console.log('(no response)');
116
+ }
117
+ }
118
+
119
+ async headless(text) {
120
+ if (!text) {
121
+ console.error('Usage: loom -p "query"');
122
+ process.exit(1);
123
+ }
124
+ this.session = new Session();
125
+ const resp = await this.session.sendUserMessage(text);
126
+ if (resp.type === 'text') {
127
+ process.stdout.write(resp.content + '\n');
128
+ } else if (resp.type === 'error') {
129
+ console.error(resp.content);
130
+ process.exit(1);
131
+ }
132
+ process.exit(0);
133
+ }
134
+
135
+ async handleSlashCommand(cmd) {
136
+ const parts = cmd.slice(1).split(/\s+/);
137
+ const command = parts[0].toLowerCase();
138
+ const args = parts.slice(1);
139
+
140
+ switch (command) {
141
+ case 'help':
142
+ return this.cmdHelp();
143
+ case 'init':
144
+ return this.cmdInit();
145
+ case 'memory':
146
+ return this.cmdMemory();
147
+ case 'connect':
148
+ return this.cmdConnect(args);
149
+ case 'model':
150
+ return this.cmdModel(args);
151
+ case 'models':
152
+ return this.cmdModels();
153
+ case 'status':
154
+ return this.cmdStatus();
155
+ case 'clear':
156
+ return this.cmdClear();
157
+ case 'compact':
158
+ return this.cmdCompact();
159
+ case 'undo':
160
+ return this.cmdUndo();
161
+ case 'reset':
162
+ return this.cmdReset();
163
+ case 'doctor':
164
+ return this.cmdDoctor();
165
+ case 'skills':
166
+ return this.cmdSkills(args);
167
+ case 'mcp':
168
+ return this.cmdMcp(args);
169
+ case 'providers':
170
+ return this.cmdProviders();
171
+ case 'format':
172
+ return this.cmdFormat(args);
173
+ case 'lsp':
174
+ return this.cmdLsp(args);
175
+ case 'graph':
176
+ return this.cmdGraph(args);
177
+ case 'quit':
178
+ case 'exit':
179
+ return this.cmdExit();
180
+ default:
181
+ console.log(`Unknown command: /${command}. Type /help for commands.`);
182
+ }
183
+ }
184
+
185
+ cmdHelp() {
186
+ console.log(`
187
+ ┌────────────────────────────────────────────────┐
188
+ │ Loom Code Commands │
189
+ ├────────────────────────────────────────────────┤
190
+ │ /help Show this help │
191
+ │ /init (Re)create LOOM.md memory file │
192
+ │ /memory View/edit LOOM.md memory file │
193
+ │ /connect Connect to an AI provider │
194
+ │ /model Set the active model │
195
+ │ /models List available models │
196
+ │ /status Show connection status │
197
+ │ /clear Clear conversation history │
198
+ │ /compact Compact conversation │
199
+ │ /undo Undo last change │
200
+ │ /reset Reset entire session │
201
+ │ /doctor Run diagnostics │
202
+ │ /providers List all supported providers │
203
+ │ /format Manage formatters (file auto-fmt) │
204
+ │ /lsp Manage LSP servers + diagnostics │
205
+ │ /exit Exit Loom Code │
206
+ │ │
207
+ │ ESC Interrupt current operation │
208
+ │ Ctrl+C Exit Loom Code │
209
+ └────────────────────────────────────────────────┘
210
+ `);
211
+ }
212
+
213
+ async cmdInit() {
214
+ const cwd = process.cwd();
215
+ const loomMdPath = path.join(cwd, 'LOOM.md');
216
+ if (fs.existsSync(loomMdPath)) {
217
+ console.log(`LOOM.md already exists at ${loomMdPath}`);
218
+ return;
219
+ }
220
+ fs.writeFileSync(loomMdPath, MEMORY_TEMPLATE);
221
+ console.log(`Created LOOM.md at ${loomMdPath}`);
222
+ console.log('Edit this file with your project conventions and standards.');
223
+ }
224
+
225
+ async cmdMemory() {
226
+ const locations = [
227
+ path.join(process.cwd(), 'LOOM.md'),
228
+ path.join(process.cwd(), '.loom', 'LOOM.md'),
229
+ path.join(os.homedir(), '.loom', 'LOOM.md'),
230
+ ];
231
+ console.log('\nMemory file locations:');
232
+ for (const loc of locations) {
233
+ const exists = fs.existsSync(loc);
234
+ console.log(` ${exists ? '✓' : '✗'} ${loc}${exists ? ' (' + fs.statSync(loc).size + ' bytes)' : ''}`);
235
+ }
236
+ const target = locations[0];
237
+ if (!fs.existsSync(target)) fs.writeFileSync(target, MEMORY_TEMPLATE);
238
+ try {
239
+ const { execSync } = require('child_process');
240
+ if (process.platform === 'win32') execSync('start "" "' + target + '"', { stdio: 'ignore', windowsHide: true });
241
+ else if (process.platform === 'darwin') execSync('open "' + target + '"', { stdio: 'ignore' });
242
+ else execSync('xdg-open "' + target + '"', { stdio: 'ignore' });
243
+ console.log(`Opened ${target} in your default editor.`);
244
+ } catch (e) {
245
+ console.log(`Edit it yourself: ${target}`);
246
+ }
247
+ }
248
+
249
+ async cmdConnect(args) {
250
+ const provider = args[0];
251
+ if (!provider) {
252
+ console.log('Usage: /connect <provider> [api-key]');
253
+ console.log('Run /providers to list every supported provider (built-ins + models.dev registry).');
254
+ return;
255
+ }
256
+ const key = args[1];
257
+ const result = connect(provider, key);
258
+ console.log(result);
259
+ if (!key) {
260
+ const { envNamesFor } = require('../providers/registry');
261
+ const envHint = (envNamesFor(provider) || [])[0] || provider.toUpperCase() + '_API_KEY';
262
+ console.log(`Set API key with environment variable: ${envHint}`);
263
+ }
264
+ }
265
+
266
+ async cmdModel(args) {
267
+ const provider = this.session?.provider?.active?.name || 'anthropic';
268
+ const models = PROVIDERS[provider]?.models || [];
269
+ if (!args.length) {
270
+ console.log(`Available models for ${provider}:`);
271
+ models.forEach(m => console.log(` ${m.id} — ${m.name}`));
272
+ return;
273
+ }
274
+ this.config.model = this.config.model || {};
275
+ this.config.model[provider] = args[0];
276
+ saveConfig(this.config);
277
+ console.log(`Model set to: ${args[0]}`);
278
+ }
279
+
280
+ async cmdModels() {
281
+ const provider = this.session?.provider?.active?.name || 'anthropic';
282
+ const models = PROVIDERS[provider]?.models || [];
283
+ console.log(`\nModels available for ${provider}:`);
284
+ models.forEach(m => console.log(` ${m.id} — ${m.name}`));
285
+ }
286
+
287
+ async cmdStatus() {
288
+ const s = status();
289
+ console.log(`\nProvider: ${s.provider}`);
290
+ console.log(`Model: ${s.model}`);
291
+ console.log(`API Key: ${s.hasKey ? 'configured' : 'NOT SET'}`);
292
+ }
293
+
294
+ async cmdClear() {
295
+ this.session.reset();
296
+ console.log('Conversation cleared.');
297
+ }
298
+
299
+ async cmdCompact() {
300
+ this.session.messages = this.session.messages.slice(-10);
301
+ console.log('Conversation compacted - keeping last 10 messages.');
302
+ }
303
+
304
+ cmdUndo() {
305
+ if (this.session.messages.length >= 2) {
306
+ this.session.messages.pop();
307
+ this.session.messages.pop();
308
+ console.log('Last exchange undone.');
309
+ } else {
310
+ console.log('Nothing to undo.');
311
+ }
312
+ }
313
+
314
+ async cmdReset() {
315
+ this.session = new Session();
316
+ console.log('Session fully reset.');
317
+ }
318
+
319
+ async cmdDoctor() {
320
+ console.log('\n=== Loom Code Diagnostics ===');
321
+ console.log(`Node: ${process.version}`);
322
+ console.log(`Platform: ${os.platform()} ${os.arch()}`);
323
+ console.log(`Home: ${os.homedir()}`);
324
+ console.log(`CWD: ${process.cwd()}`);
325
+ console.log(`Config: ${path.join(os.homedir(), '.loom', 'config.json')}`);
326
+ const config = loadConfig();
327
+ console.log(`Provider: ${config.provider}`);
328
+ const hasKeys = Object.keys(config.apiKeys || {}).length > 0;
329
+ console.log(`Has API keys: ${hasKeys ? 'Yes (' + Object.keys(config.apiKeys).join(', ') + ')' : 'No'}`);
330
+ const hasMemory = fs.existsSync(path.join(process.cwd(), 'LOOM.md'));
331
+ console.log(`LOOM.md in project: ${hasMemory ? 'Yes' : 'No'}`);
332
+ console.log('\nSupported providers:');
333
+ for (const [name, mod] of Object.entries(PROVIDERS)) {
334
+ const hasModels = (mod.models?.length || 0) > 0;
335
+ console.log(` ${name} — ${hasModels ? mod.models.length + ' models' : 'no models'}`);
336
+ }
337
+ }
338
+
339
+ cmdProviders() {
340
+ console.log('\nSupported AI Providers:');
341
+ for (const [name, mod] of Object.entries(PROVIDERS)) {
342
+ const count = mod.models?.length || 0;
343
+ console.log(` ${name}: ${count} models`);
344
+ if (mod.models) {
345
+ mod.models.slice(0, 5).forEach(m => console.log(` - ${m.id}`));
346
+ }
347
+ }
348
+ console.log('\nConnect with: /connect <provider>');
349
+ }
350
+
351
+ cmdFormat(args) {
352
+ const plugin = require('./plugin-cmd');
353
+ console.log(plugin.formatCmd(args));
354
+ }
355
+
356
+ async cmdLsp(args) {
357
+ const plugin = require('./plugin-cmd');
358
+ console.log(await plugin.lspCmd(args));
359
+ }
360
+
361
+ async cmdGraph(args) {
362
+ // /graph — rebuild the memory graph and display it as text.
363
+ // Writes graph.json to .loom/graph/ for the TUI to consume.
364
+ // Dynamic import avoids static analysis by tsc (graph.ts has loose types).
365
+ const mod = await import('../core/graph.js');
366
+ const { buildGraph, renderGraphText } = mod;
367
+ const cwd = process.cwd();
368
+ const graph = buildGraph(cwd);
369
+ const outDir = path.join(cwd, '.loom', 'graph');
370
+ fs.mkdirSync(outDir, { recursive: true });
371
+ const jsonPath = path.join(outDir, 'graph.json');
372
+ fs.writeFileSync(jsonPath, JSON.stringify(graph, null, 2));
373
+ console.log(renderGraphText(graph));
374
+ console.log('');
375
+ console.log('Graph data written to ' + jsonPath);
376
+ if (args[0] === 'open' || args[0] === '--open') {
377
+ await this.openGraphBrowser(graph);
378
+ } else {
379
+ console.log('Run /graph in the TUI for the interactive view, or "loom graph open" for the browser graph.');
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Serve the browser-based force-directed graph view (design doc: Loom
385
+ * Graph View Design) and open it in the default browser.
386
+ * @param {{ nodes: any[], edges: any[] }} graph
387
+ * @returns {Promise<void>}
388
+ */
389
+ async openGraphBrowser(graph) {
390
+ const http = require('http');
391
+ const template = fs.readFileSync(path.join(__dirname, '..', 'web', 'graph-view.html'), 'utf8');
392
+ const html = template.replace('window.__GRAPH__ || { nodes: [], edges: [] }', JSON.stringify(graph).replace(/<\/script/gi, '<\\/script'));
393
+ const server = http.createServer((req, res) => {
394
+ const p = req.url || '/';
395
+ if (p === '/' || p === '/index.html') {
396
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
397
+ res.end(html);
398
+ } else if (p === '/graph.json') {
399
+ res.writeHead(200, { 'Content-Type': 'application/json' });
400
+ res.end(JSON.stringify(graph));
401
+ } else {
402
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
403
+ res.end('not found');
404
+ }
405
+ });
406
+ await new Promise((resolve, reject) => {
407
+ server.once('error', reject);
408
+ server.listen(0, '127.0.0.1', () => resolve(undefined));
409
+ });
410
+ const port = /** @type {any} */ (server.address()).port;
411
+ const url = 'http://127.0.0.1:' + port + '/';
412
+ console.log('Graph view: ' + url + ' (Ctrl+C to stop the server)');
413
+ try {
414
+ const { execSync } = require('child_process');
415
+ if (process.platform === 'win32') execSync('start "" "' + url + '"', { stdio: 'ignore', windowsHide: true });
416
+ else if (process.platform === 'darwin') execSync('open "' + url + '"', { stdio: 'ignore' });
417
+ else execSync('xdg-open "' + url + '"', { stdio: 'ignore' });
418
+ } catch { /* browser open failed; the URL was printed above */ }
419
+ }
420
+
421
+ cmdSkills(args) {
422
+ const plugin = require('./plugin-cmd');
423
+ if (args[0] === 'install') console.log(plugin.installSkillCmd(args.slice(1)));
424
+ else if (args[0] === 'remove') console.log(plugin.removeSkillCmd(args.slice(1)));
425
+ else if (args[0] === 'help') console.log(plugin.skillHelp());
426
+ else console.log(plugin.listSkillsText());
427
+ }
428
+
429
+ cmdMcp(args) {
430
+ const plugin = require('./plugin-cmd');
431
+ if (args[0] === 'add') console.log(plugin.mcpAddCmd(args.slice(1)));
432
+ else if (args[0] === 'remove') console.log(plugin.mcpRemoveCmd(args.slice(1)));
433
+ else if (args[0] === 'toggle') console.log(plugin.mcpToggleCmd(args.slice(1)));
434
+ else if (args[0] === 'help') console.log(plugin.mcpHelp());
435
+ else console.log(plugin.listMcpText());
436
+ }
437
+
438
+ cmdExit() {
439
+ if (this.session) {
440
+ const res = saveSession(this.session);
441
+ console.log('\nSession saved as: ' + res.id);
442
+ console.log('Resume with: loom -s ' + res.id);
443
+ }
444
+ console.log('\nGoodbye from Loom Code!');
445
+ process.exit(0);
446
+ }
447
+
448
+ completer(line) {
449
+ const commands = [
450
+ '/help', '/init', '/memory', '/connect', '/model', '/models',
451
+ '/status', '/clear', '/compact', '/undo', '/reset', '/doctor',
452
+ '/providers', '/skills', '/mcp', '/format', '/lsp', '/exit'
453
+ ];
454
+ const hits = commands.filter(c => c.startsWith(line));
455
+ return [hits.length ? hits : commands, line];
456
+ }
457
+ }
458
+
459
+ function findBun() {
460
+ // Check common installation paths
461
+ const paths = [
462
+ path.join(os.homedir(), '.bun', 'bin', 'bun.exe'),
463
+ path.join(os.homedir(), '.bun', 'bin', 'bun'),
464
+ '/usr/local/bin/bun',
465
+ '/opt/homebrew/bin/bun',
466
+ '/usr/bin/bun',
467
+ ];
468
+ for (const p of paths) {
469
+ if (fs.existsSync(p)) return p;
470
+ }
471
+ // Search PATH (Windows: where.exe, POSIX: which)
472
+ try {
473
+ const { execSync } = require('child_process');
474
+ const isWin = process.platform === 'win32';
475
+ const result = execSync(isWin ? 'where.exe bun 2>nul' : 'which bun 2>/dev/null', { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim();
476
+ if (result && fs.existsSync(result.split('\n')[0].trim())) return result.split('\n')[0].trim();
477
+ } catch {}
478
+ return null;
479
+ }
480
+
481
+ async function main() {
482
+ const cli = new LoomCLI();
483
+
484
+ const args = process.argv.slice(2);
485
+ const VERSION = require('../../package.json').version;
486
+
487
+ const printIdx = args.indexOf('-p');
488
+ const printLongIdx = args.indexOf('--print');
489
+ const printMode = printIdx !== -1 || printLongIdx !== -1;
490
+ const autoMode = args.includes('--auto') || args.includes('-a');
491
+ if (autoMode) cli.session.permissions.setAuto(true);
492
+
493
+ if (args.includes('--version') || args.includes('-v')) {
494
+ console.log(`loom-code v${VERSION}`);
495
+ process.exit(0);
496
+ }
497
+ if (args.includes('--help') || args.includes('-h')) {
498
+ console.log('loom - AI coding agent for the terminal');
499
+ console.log('Usage: loom [options] [prompt...]');
500
+ console.log(' loom Start interactive TUI session');
501
+ console.log(' loom "prompt" Start with initial prompt');
502
+ console.log(' loom -p "query" Run one-shot, print result, exit');
503
+ console.log(' loom --basic Use basic line-mode REPL (no TUI)');
504
+ console.log(' loom --auto Auto-approve permission prompts');
505
+ console.log(' loom acp ACP server (JSON-RPC over stdio, for editor plugins)');
506
+ console.log(' loom web Browser interface (HTTP server, opens the browser)');
507
+ console.log(' loom attach <url> Attach the terminal to a running `loom web` server');
508
+ console.log(' loom -s <session-id> Resume a saved session');
509
+ console.log(' loom --version Show version');
510
+ console.log(' loom --help Show this help');
511
+ process.exit(0);
512
+ }
513
+
514
+ const sIndex = args.indexOf('-s');
515
+ const sessionId = sIndex !== -1 ? args[sIndex + 1] : null;
516
+
517
+ // loom graph [open] — memory graph subcommand.
518
+ // loom graph rebuild + print the graph as text, exit
519
+ // loom graph open serve the browser graph view and open it
520
+ if (args[0] === 'graph') {
521
+ const open = args[1] === 'open' || args[1] === '--open';
522
+ await cli.cmdGraph(open ? ['open'] : []);
523
+ if (!open) process.exit(0);
524
+ return;
525
+ }
526
+
527
+ if (sessionId && sIndex !== -1) {
528
+ const data = loadSession(sessionId);
529
+ if (data && data.messages && data.messages.length) {
530
+ console.log(`Resuming session ${sessionId} (${data.messages.length} messages)`);
531
+ cli.initialSession = data;
532
+ } else {
533
+ console.error('Session not found: ' + sessionId);
534
+ process.exit(1);
535
+ }
536
+ }
537
+
538
+ process.on('SIGINT', () => {
539
+ if (cli.session) {
540
+ const id = saveSession(cli.session).id;
541
+ console.log('\nSession saved: ' + id);
542
+ console.log('Resume with: loom -s ' + id);
543
+ }
544
+ process.exit(0);
545
+ });
546
+ defaultMcpInstall();
547
+
548
+ const promptArgs = args.filter(a => !a.startsWith('-'));
549
+
550
+ if (printMode) {
551
+ await cli.headless(promptArgs.join(' '));
552
+ return;
553
+ }
554
+
555
+ if ((args.includes('--tui') || (!args.includes('--basic') && process.stdin.isTTY))) {
556
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
557
+ if (canRaw) {
558
+ // Launch new OpenTUI TUI via bun
559
+ const bunPath = findBun();
560
+ const tuiEntry = path.join(__dirname, '..', 'tui-open.tsx');
561
+ if (bunPath && fs.existsSync(tuiEntry)) {
562
+ const { spawnSync } = require('child_process');
563
+ const tuiArgs = [tuiEntry];
564
+ if (sessionId) tuiArgs.push('-s', sessionId);
565
+ if (autoMode) tuiArgs.push('--auto');
566
+ const prompt = promptArgs.join(' ');
567
+ if (prompt) tuiArgs.push(...prompt.split(/\s+/));
568
+ process.exit(spawnSync(bunPath, tuiArgs, { stdio: 'inherit', env: process.env }).status ?? 0);
569
+ }
570
+ console.error('[loom] bun not found — full TUI requires bun (https://bun.sh/). Falling back to line-mode REPL.');
571
+ console.error('[loom] Use --basic to skip this warning.\n');
572
+ }
573
+ }
574
+
575
+ cli.initialPrompt = promptArgs.join(' ');
576
+ await cli.start();
577
+ }
578
+
579
+ module.exports = { main, LoomCLI };
@@ -0,0 +1,70 @@
1
+ // Custom slash commands — markdown files the user drops into
2
+ // <cwd>/.loom/commands/*.md or <LOOM_CONFIG_DIR or ~/.loom>/commands/*.md.
3
+ // The filename (minus .md) becomes the command: /deploy runs deploy.md.
4
+ // $ARGUMENTS in the body is replaced with everything after "/name ".
5
+ //
6
+ // # .loom/commands/deploy.md
7
+ // Run the deploy checklist for $ARGUMENTS: build, test, then ship.
8
+ //
9
+ // Used by the TUI's processSlash + autocomplete; pure functions here so the
10
+ // CLI can share them later. Reads are cached for 5s so typing stays snappy.
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ const CACHE_TTL_MS = 5000;
16
+ /** @type {{at: number, list: {name: string, file: string}[]}} */
17
+ let _cache = { at: 0, list: [] };
18
+
19
+ function commandDirs() {
20
+ const dirs = [path.join(process.cwd(), '.loom', 'commands')];
21
+ const cfgDir = process.env.LOOM_COMMANDS_DIR
22
+ || (process.env.LOOM_CONFIG_DIR ? path.join(process.env.LOOM_CONFIG_DIR, 'commands') : null)
23
+ || path.join(os.homedir(), '.loom', 'commands');
24
+ dirs.push(cfgDir);
25
+ return [...new Set(dirs)];
26
+ }
27
+
28
+ /**
29
+ * List available custom commands: [{ name, file, dir }]
30
+ * @returns {{ name: string, file: string }[]}
31
+ */
32
+ function listCustomCommands() {
33
+ if (Date.now() - _cache.at < CACHE_TTL_MS) return _cache.list;
34
+ const out = [];
35
+ for (const dir of commandDirs()) {
36
+ let entries = [];
37
+ try { entries = fs.readdirSync(dir); } catch { continue; }
38
+ for (const f of entries) {
39
+ if (!f.endsWith('.md')) continue;
40
+ const name = f.slice(0, -3);
41
+ if (!/^[a-z][\w-]*$/i.test(name)) continue;
42
+ if (out.some(c => c.name === name)) continue; // cwd wins over global
43
+ out.push({ name, file: path.join(dir, f) });
44
+ }
45
+ }
46
+ _cache = { at: Date.now(), list: out };
47
+ return out;
48
+ }
49
+
50
+ /** Invalidate the cache (tests / after editing a command). */
51
+ function invalidateCommandCache() {
52
+ _cache = { at: 0, list: [] };
53
+ }
54
+
55
+ /**
56
+ * Expand "/name args" into the prompt text: the md body with $ARGUMENTS
57
+ * substituted. Returns null when the command doesn't exist.
58
+ * @param {string} name
59
+ * @param {string} [argsText]
60
+ * @returns {string|null}
61
+ */
62
+ function expandCustomCommand(name, argsText) {
63
+ const cmd = listCustomCommands().find(c => c.name === String(name || '').toLowerCase());
64
+ if (!cmd) return null;
65
+ let body = '';
66
+ try { body = fs.readFileSync(cmd.file, 'utf8'); } catch { return null; }
67
+ return body.replace(/\$ARGUMENTS/g, String(argsText || '').trim()).trim();
68
+ }
69
+
70
+ module.exports = { listCustomCommands, expandCustomCommand, invalidateCommandCache, commandDirs };
@@ -0,0 +1,29 @@
1
+ class LoomError extends Error {
2
+ constructor(message, code = 'LOOM_ERR') {
3
+ super(message);
4
+ this.name = 'LoomError';
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ class ProviderError extends LoomError {
10
+ constructor(message, provider) {
11
+ super(message, 'PROVIDER_ERR');
12
+ this.provider = provider;
13
+ }
14
+ }
15
+
16
+ class ToolError extends LoomError {
17
+ constructor(message, tool) {
18
+ super(message, 'TOOL_ERR');
19
+ this.tool = tool;
20
+ }
21
+ }
22
+
23
+ class ConfigError extends LoomError {
24
+ constructor(message) {
25
+ super(message, 'CONFIG_ERR');
26
+ }
27
+ }
28
+
29
+ module.exports = { LoomError, ProviderError, ToolError, ConfigError };