loom-agent 1.3.3 → 1.5.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.
@@ -1,5 +1,6 @@
1
1
  const { listSkills, installSkill, removeSkill } = require('../skills/skills-manager');
2
2
  const { loadServers, addServer, parseMcpAddArgs, removeServer, listServers, toggleServer, seedDefaults } = require('../mcp/mcp-manager');
3
+ const { CONNECTOR_PRESETS } = require('../mcp/connector-presets');
3
4
  const sessionStore = require('./session-store');
4
5
 
5
6
  // Quote-aware CLI tokenizer for slash commands, so paths with spaces survive:
@@ -124,8 +125,9 @@ function mcpHelp() {
124
125
  'Example (claude-compatible): /mcp add stm32 -- "C:\\stm32-mcp\\.venv\\Scripts\\python.exe" -m stm32_mcp.server',
125
126
  'Example with env: /mcp add -e BRAVE_API_KEY=x brave-search -- npx -y @modelcontextprotocol/server-brave-search',
126
127
  'From the shell (same syntax): loom add mcp [-e KEY=V] <name> [--] <command> [args...]',
128
+ 'Connectors from the shell: loom add connector <preset> [-e KEY=V]... (or the same one-liner form)',
127
129
  'The "--" separator is optional; quote any path with spaces. Env keys are usable as $KEY in args.',
128
- 'In the /mcp and /connectors browsers, press A to add with the same one-liner syntax.',
130
+ 'In the /mcp and /connectors browsers, press A (or Enter on the "+ Add" row) to add with the same one-liner syntax.',
129
131
  ].join('\n');
130
132
  }
131
133
 
@@ -172,6 +174,89 @@ function mcpAddLineCmd(line) {
172
174
  return addServerFromArgv(argv);
173
175
  }
174
176
 
177
+ function connectorUsage() {
178
+ return [
179
+ 'Usage: /connectors add <preset> [-e KEY=V]... (presets: ' + CONNECTOR_PRESETS.map(p => p.id).join(', ') + ')',
180
+ ' or: /connectors add [-e KEY=V] <name> [--] <command> [args...]',
181
+ 'Example: /connectors add supabase -e SUPABASE_ACCESS_TOKEN=xxx',
182
+ ].join('\n');
183
+ }
184
+
185
+ // /connectors add + `loom add connector` — preset-aware one-line add. A bare
186
+ // preset name expands to the full one-liner (env from -e flags anywhere, any
187
+ // leftover tokens appended as trailing args, e.g. --project-ref X); anything
188
+ // else falls through to the generic mcp one-liner. A connector IS an MCP
189
+ // server — only the wording differs. A missing required key is an error, not
190
+ // a silently broken server.
191
+ function connectorAddFromArgv(argvIn) {
192
+ let argv = argvIn.slice();
193
+ if (argv[0] === 'add') argv = argv.slice(1);
194
+ else if ((argv[0] === 'connector' || argv[0] === 'connectors') && argv[1] === 'add') argv = argv.slice(2);
195
+ else if (argv[0] === 'connector' || argv[0] === 'connectors') argv = argv.slice(1);
196
+ if (!argv.length) return connectorUsage();
197
+
198
+ // Collect -e/--env KEY=V pairs from the front while looking for a name.
199
+ const env = /** @type {Record<string, string>} */ ({});
200
+ let i = 0;
201
+ while (i + 1 < argv.length && (argv[i] === '-e' || argv[i] === '--env')) {
202
+ const kv = String(argv[i + 1]);
203
+ const eq = kv.indexOf('=');
204
+ if (eq > 0) env[kv.slice(0, eq)] = kv.slice(eq + 1);
205
+ i += 2;
206
+ }
207
+ const name = i < argv.length ? String(argv[i]) : '';
208
+ // Explicit "--" means the user wrote a full custom one-liner — never expand.
209
+ const preset = argv.indexOf('--') === -1 ? CONNECTOR_PRESETS.find(p => p.id === name) : undefined;
210
+ if (!preset) {
211
+ const msg = addServerFromArgv(argv);
212
+ return typeof msg === 'string' ? msg.replace('Added MCP server', 'Added connector') : msg;
213
+ }
214
+
215
+ // Preset form: env flags anywhere, leftover tokens appended as extra args.
216
+ const extras = /** @type {string[]} */ ([]);
217
+ for (let j = i + 1; j < argv.length; j++) {
218
+ const a = argv[j];
219
+ if ((a === '-e' || a === '--env') && j + 1 < argv.length) {
220
+ const kv = String(argv[j + 1]);
221
+ const eq = kv.indexOf('=');
222
+ if (eq > 0) env[kv.slice(0, eq)] = kv.slice(eq + 1);
223
+ j++;
224
+ continue;
225
+ }
226
+ extras.push(String(a));
227
+ }
228
+ // Required keys = every prompt the guided TUI flow would ask for.
229
+ const missing = (preset.prompts || []).map(p => p.key).filter(k => !env[k]);
230
+ if (missing.length) {
231
+ const hints = (preset.prompts || [])
232
+ .filter(p => missing.indexOf(p.key) !== -1)
233
+ .map(p => ' -e ' + p.key + '=... ' + p.label);
234
+ const firstKey = (preset.prompts.find(p => p.key === missing[0]) || { key: missing[0] }).key;
235
+ return 'Missing key for ' + preset.label + ':\n' + hints.join('\n') +
236
+ '\nExample: /connectors add ' + preset.id + ' -e ' + firstKey + '=...';
237
+ }
238
+ // Build the claude-style one-liner argv and reuse the shared engine
239
+ // ($KEY resolution, npx wrapping, persistence).
240
+ const out = /** @type {string[]} */ ([]);
241
+ for (const k of Object.keys(env)) out.push('-e', k + '=' + env[k]);
242
+ out.push(preset.id, '--');
243
+ if (preset.command) out.push(preset.command, ...(preset.args || []));
244
+ else out.push('npx', '-y', String(preset.package), ...(preset.args || []));
245
+ for (const x of extras) out.push(x);
246
+ const msg = addServerFromArgv(out);
247
+ return typeof msg === 'string' ? msg.replace('Added MCP server', 'Added connector') : msg;
248
+ }
249
+
250
+ // argv-array flavor for /connectors add (App.tsx slash args).
251
+ function connectorAddCmd(args) {
252
+ return connectorAddFromArgv(args);
253
+ }
254
+
255
+ // Single-raw-line flavor for `loom add connector` and the one-line add input.
256
+ function connectorAddLineCmd(line) {
257
+ return connectorAddFromArgv(tokenizeCli(String(line || '').trim()));
258
+ }
259
+
175
260
  function mcpRemoveCmd(args) {
176
261
  const name = args[0];
177
262
  if (!name) return 'Usage: /mcp remove <name>';
@@ -365,6 +450,8 @@ module.exports = {
365
450
  listMcpText,
366
451
  mcpAddCmd,
367
452
  mcpAddLineCmd,
453
+ connectorAddCmd,
454
+ connectorAddLineCmd,
368
455
  mcpRemoveCmd,
369
456
  mcpToggleCmd,
370
457
  diffCmd,
@@ -74,6 +74,28 @@ function detectDocker(runner) {
74
74
  return { state: 'ready', version };
75
75
  }
76
76
 
77
+ /**
78
+ * NON-BLOCKING Docker detection (spawn-based twin of detectDocker). The TUI
79
+ * must never call the sync version — `docker version` can hang 10s when the
80
+ * daemon is starting, which froze the whole interface. Injectable async
81
+ * runner for tests: (cmd, args) => Promise<{code, out}>.
82
+ * @param {(cmd: string, args: string[]) => Promise<{ code: number, out: string }>} [runner]
83
+ * @returns {Promise<{ state: DockerState, version: string }>}
84
+ */
85
+ async function detectDockerAsync(runner) {
86
+ const run = runner || ((/** @type {string} */ cmd, /** @type {string[]} */ args) =>
87
+ spawnAsync(cmd, args, { timeoutMs: 10000 }));
88
+ const server = await run('docker', ['version', '--format', '{{.Server.Version}}']);
89
+ if (server.code === 0) {
90
+ const version = String(server.out || '').trim();
91
+ if (version) return { state: 'ready', version };
92
+ return { state: 'no_daemon', version: '' };
93
+ }
94
+ const cli = await run('docker', ['--version']);
95
+ if (cli.code === 0) return { state: 'no_daemon', version: '' };
96
+ return { state: 'not_installed', version: '' };
97
+ }
98
+
77
99
  /**
78
100
  * Platform-appropriate Docker install guide shown before the user leaves to
79
101
  * install. Mentions the restart when the installer requires one, and the
@@ -165,6 +187,41 @@ function sandboxStatus() {
165
187
  };
166
188
  }
167
189
 
190
+ /**
191
+ * NON-BLOCKING twin of sandboxStatus (spawn-based docker checks). Use this
192
+ * from the TUI — the sync version freezes the interface while docker answers.
193
+ * @returns {Promise<ReturnType<typeof sandboxStatus>>}
194
+ */
195
+ async function sandboxStatusAsync() {
196
+ const cfg = loadConfig();
197
+ const sandboxCfg = cfg.sandbox || {};
198
+ const { state, version } = await detectDockerAsync();
199
+ let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
200
+ if (state === 'ready') {
201
+ const insp = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 10000 });
202
+ if (insp.code === 0) container = insp.out.trim() === 'true' ? 'running' : 'stopped';
203
+ }
204
+ return {
205
+ docker: state,
206
+ dockerVersion: version,
207
+ enabled: !!sandboxCfg.enabled && container === 'running',
208
+ pending: !!sandboxCfg.pending,
209
+ container,
210
+ image: sandboxCfg.image || DEFAULT_IMAGE,
211
+ osId: sandboxCfg.osId || 'alpine',
212
+ projectRoot: sandboxCfg.projectRoot || '',
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Config-only pending check (no docker involved — zero cost, never blocks).
218
+ * @returns {boolean}
219
+ */
220
+ function sandboxPending() {
221
+ const cfg = loadConfig();
222
+ return !!(cfg.sandbox && cfg.sandbox.pending);
223
+ }
224
+
168
225
  /**
169
226
  * Map a host working directory to its path inside the container. The project
170
227
  * root is mounted at /workspace; subdirectories map underneath it. Anything
@@ -348,7 +405,7 @@ async function setupSandbox(opts) {
348
405
  const root = path.resolve(o.projectRoot || process.cwd());
349
406
  const log = o.onLog || (() => {});
350
407
  const prog = o.onProgress || (() => {});
351
- const { state, version } = detectDocker();
408
+ const { state, version } = await detectDockerAsync();
352
409
  if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
353
410
  if (state === 'no_daemon') {
354
411
  return { ok: false, error: 'Docker is installed but the daemon is not running. Start Docker Desktop (or `sudo systemctl start docker`), then run /sandbox setup again.' };
@@ -558,10 +615,13 @@ module.exports = {
558
615
  DEFAULT_IMAGE,
559
616
  OS_CATALOG,
560
617
  detectDocker,
618
+ detectDockerAsync,
561
619
  installGuide,
562
620
  installLink,
563
621
  openInstallPage,
564
622
  sandboxStatus,
623
+ sandboxStatusAsync,
624
+ sandboxPending,
565
625
  toSandboxPath,
566
626
  runInSandbox,
567
627
  setupSandbox,