@stevederico/dotbot 0.26.0 → 0.28.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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ 0.28
2
+
3
+ Add --sandbox mode
4
+ Add --allow domain presets
5
+ Add domain-gated web_fetch
6
+ Add domain-gated browser_navigate
7
+ Add preset-unlocked tools
8
+ Add API key validation
9
+ Add models CLI command
10
+ Add /models REPL command
11
+ Add /load model command
12
+
13
+ 0.27
14
+
15
+ Add interactive API key prompt
16
+ Add key saving to dotbotrc
17
+ Add provider signup URLs
18
+ Update thinking spinner display
19
+
1
20
  0.26
2
21
 
3
22
  Update REPL prompt style
package/bin/dotbot.js CHANGED
@@ -22,7 +22,7 @@ process.emit = function (event, error) {
22
22
  */
23
23
 
24
24
  import { parseArgs } from 'node:util';
25
- import { readFileSync, existsSync } from 'node:fs';
25
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { dirname, join } from 'node:path';
28
28
  import { homedir } from 'node:os';
@@ -100,6 +100,7 @@ Usage:
100
100
  echo "msg" | dotbot Pipe input from stdin
101
101
 
102
102
  Commands:
103
+ models List available models from provider
103
104
  doctor Check environment and configuration
104
105
  tools List all available tools
105
106
  stats Show database statistics
@@ -121,6 +122,8 @@ Options:
121
122
  --db SQLite database path (default: ${DEFAULT_DB})
122
123
  --port Server port for 'serve' command (default: ${DEFAULT_PORT})
123
124
  --openai Enable OpenAI-compatible API endpoints (/v1/chat/completions, /v1/models)
125
+ --sandbox Restrict tools to safe subset (no files, code, browser, messages)
126
+ --allow Allow domain/preset in sandbox (github, slack, discord, npm, etc.)
124
127
  --json Output as JSON (for inspection commands)
125
128
  --verbose Show initialization logs
126
129
  --help, -h Show this help
@@ -138,16 +141,20 @@ Config File:
138
141
  Examples:
139
142
  dotbot "What's the weather in SF?"
140
143
  dotbot
144
+ dotbot -p anthropic -m claude-sonnet-4-5 "Hello"
145
+ dotbot -p ollama -m llama3 "Summarize this"
146
+ dotbot -p openai -m gpt-4o
147
+ dotbot models
141
148
  dotbot serve --port 8080
142
149
  dotbot doctor
143
150
  dotbot tools
144
151
  dotbot memory search "preferences"
145
- dotbot memory delete user_pref
146
152
  dotbot stats --json
153
+ dotbot --sandbox "What is 2+2?"
154
+ dotbot --sandbox --allow github "Check my repo"
147
155
  dotbot --system "You are a pirate" "Hello"
148
156
  dotbot --session abc-123 "follow up question"
149
157
  echo "What is 2+2?" | dotbot
150
- cat question.txt | dotbot
151
158
  `);
152
159
  }
153
160
 
@@ -162,7 +169,18 @@ function loadConfig() {
162
169
  }
163
170
  try {
164
171
  const content = readFileSync(CONFIG_PATH, 'utf8');
165
- return JSON.parse(content);
172
+ const config = JSON.parse(content);
173
+
174
+ // Inject saved API keys into process.env (don't override existing)
175
+ if (config.env && typeof config.env === 'object') {
176
+ for (const [key, value] of Object.entries(config.env)) {
177
+ if (!process.env[key]) {
178
+ process.env[key] = value;
179
+ }
180
+ }
181
+ }
182
+
183
+ return config;
166
184
  } catch (err) {
167
185
  console.error(`Warning: Invalid config file at ${CONFIG_PATH}: ${err.message}`);
168
186
  return {};
@@ -193,10 +211,17 @@ function parseCliArgs() {
193
211
  port: { type: 'string' },
194
212
  openai: { type: 'boolean', default: false },
195
213
  session: { type: 'string', default: '' },
214
+ sandbox: { type: 'boolean', default: false },
215
+ allow: { type: 'string', multiple: true },
196
216
  },
197
217
  });
198
218
 
199
219
  // Merge: CLI args > config file > hardcoded defaults
220
+ // Build sandbox domain allowlist from --allow flags, presets, and config
221
+ const allowFlags = values.allow || [];
222
+ const configAllow = config.sandboxAllow || [];
223
+ const allAllow = [...allowFlags, ...configAllow];
224
+
200
225
  return {
201
226
  ...values,
202
227
  provider: values.provider ?? config.provider ?? 'xai',
@@ -205,6 +230,8 @@ function parseCliArgs() {
205
230
  db: values.db ?? config.db ?? DEFAULT_DB,
206
231
  port: values.port ?? config.port ?? String(DEFAULT_PORT),
207
232
  session: values.session ?? '',
233
+ sandbox: values.sandbox || config.sandbox || false,
234
+ sandboxAllow: allAllow,
208
235
  positionals,
209
236
  };
210
237
  } catch (err) {
@@ -214,7 +241,42 @@ function parseCliArgs() {
214
241
  }
215
242
 
216
243
  /**
217
- * Get provider config with API key from environment.
244
+ * Prompt user for input via readline.
245
+ *
246
+ * @param {string} question - Prompt text
247
+ * @returns {Promise<string>} User input
248
+ */
249
+ function askUser(question) {
250
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
251
+ return new Promise((resolve) => {
252
+ rl.question(question, (answer) => {
253
+ rl.close();
254
+ resolve(answer.trim());
255
+ });
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Save or update a key in ~/.dotbotrc config file.
261
+ *
262
+ * @param {string} key - Config key
263
+ * @param {*} value - Config value
264
+ */
265
+ function saveToConfig(key, value) {
266
+ let config = {};
267
+ if (existsSync(CONFIG_PATH)) {
268
+ try {
269
+ config = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
270
+ } catch {
271
+ config = {};
272
+ }
273
+ }
274
+ config[key] = value;
275
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
276
+ }
277
+
278
+ /**
279
+ * Get provider config with API key from environment or interactive prompt.
218
280
  *
219
281
  * @param {string} providerId - Provider ID
220
282
  * @returns {Object} Provider config with headers
@@ -228,25 +290,267 @@ async function getProviderConfig(providerId) {
228
290
  process.exit(1);
229
291
  }
230
292
 
293
+ if (providerId === 'ollama') {
294
+ const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
295
+ return { ...base, apiUrl: `${baseUrl}/api/chat` };
296
+ }
297
+
231
298
  const envKey = base.envKey;
232
- const apiKey = process.env[envKey];
299
+ let apiKey = process.env[envKey];
233
300
 
234
- if (!apiKey && providerId !== 'ollama') {
235
- console.error(`Missing ${envKey} environment variable`);
236
- process.exit(1);
301
+ if (!apiKey) {
302
+ if (!process.stdin.isTTY) {
303
+ console.error(`Missing ${envKey} environment variable.`);
304
+ console.error(`Get one at: ${getProviderSignupUrl(providerId)}`);
305
+ process.exit(1);
306
+ }
307
+
308
+ console.log(`\n${base.name} API key not found (${envKey}). Get one at: ${getProviderSignupUrl(providerId)}\n`);
309
+
310
+ apiKey = await askUser(`Paste your ${base.name} API key: `);
311
+ if (!apiKey) {
312
+ console.error('No API key provided.');
313
+ process.exit(1);
314
+ }
315
+
316
+ // Validate key by fetching models
317
+ process.stdout.write('Validating');
318
+ startSpinner();
319
+ const { ok } = await fetchProviderModels(providerId, apiKey);
320
+ if (ok) {
321
+ stopSpinner('valid');
322
+ } else {
323
+ stopSpinner('failed');
324
+ console.error(`Could not authenticate with ${base.name}. Check your API key and try again.`);
325
+ process.exit(1);
326
+ }
327
+
328
+ const save = await askUser('Save to ~/.dotbotrc for next time? (Y/n) ');
329
+ if (save.toLowerCase() !== 'n') {
330
+ saveToConfig('env', { ...loadConfig().env, [envKey]: apiKey });
331
+ console.log(`Saved to ${CONFIG_PATH}\n`);
332
+ }
333
+
334
+ process.env[envKey] = apiKey;
237
335
  }
238
336
 
337
+ return {
338
+ ...base,
339
+ headers: () => base.headers(apiKey),
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Get signup/API key URL for a provider.
345
+ *
346
+ * @param {string} providerId - Provider ID
347
+ * @returns {string} URL to get an API key
348
+ */
349
+ function getProviderSignupUrl(providerId) {
350
+ const urls = {
351
+ xai: 'https://console.x.ai',
352
+ openai: 'https://platform.openai.com/api-keys',
353
+ anthropic: 'https://console.anthropic.com/settings/keys',
354
+ cerebras: 'https://cloud.cerebras.ai',
355
+ };
356
+ return urls[providerId] || 'the provider\'s website';
357
+ }
358
+
359
+ /**
360
+ * Fetch available models from a provider's API.
361
+ *
362
+ * @param {string} providerId - Provider ID
363
+ * @param {string} apiKey - API key for authentication
364
+ * @returns {Promise<{ok: boolean, models: Array<{id: string, name: string}>}>} Validation result with model list
365
+ */
366
+ async function fetchProviderModels(providerId, apiKey) {
367
+ await loadModules();
368
+ const base = AI_PROVIDERS[providerId];
369
+ if (!base) return { ok: false, models: [] };
370
+
371
+ let url;
372
+ let headers;
373
+
239
374
  if (providerId === 'ollama') {
240
375
  const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
241
- return { ...base, apiUrl: `${baseUrl}/api/chat` };
376
+ url = `${baseUrl}/api/tags`;
377
+ headers = { 'Content-Type': 'application/json' };
378
+ } else {
379
+ url = `${base.apiUrl}/models`;
380
+ headers = base.headers(apiKey);
381
+ }
382
+
383
+ try {
384
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
385
+ if (!res.ok) return { ok: false, models: [] };
386
+
387
+ const data = await res.json();
388
+
389
+ let models = [];
390
+ if (providerId === 'ollama') {
391
+ models = (data.models || []).map((m) => ({ id: m.name, name: m.name }));
392
+ } else if (providerId === 'anthropic') {
393
+ models = (data.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
394
+ } else {
395
+ models = (data.data || []).map((m) => ({ id: m.id, name: m.id }));
396
+ }
397
+
398
+ return { ok: true, models };
399
+ } catch {
400
+ return { ok: false, models: [] };
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Tools always allowed in sandbox mode (safe, internal-only).
406
+ */
407
+ const SANDBOX_ALLOWED_TOOLS = new Set([
408
+ 'memory_save', 'memory_search', 'memory_delete', 'memory_list', 'memory_read', 'memory_update',
409
+ 'web_search', 'grokipedia_search',
410
+ 'file_read', 'file_list',
411
+ 'run_code',
412
+ 'weather_get',
413
+ 'event_query', 'events_summary',
414
+ 'task_create', 'task_list', 'task_plan', 'task_work', 'task_step_done', 'task_complete',
415
+ 'task_delete', 'task_search', 'task_stats',
416
+ 'trigger_create', 'trigger_list', 'trigger_toggle', 'trigger_delete',
417
+ 'schedule_job', 'list_jobs', 'toggle_job', 'cancel_job',
418
+ ]);
419
+
420
+ /** Tools that are allowed in sandbox but domain-gated via allowlist. */
421
+ const SANDBOX_GATED_TOOLS = new Set(['web_fetch', 'browser_navigate']);
422
+
423
+ /** Tools unlocked in sandbox when their preset is in the --allow list. */
424
+ const SANDBOX_PRESET_TOOLS = {
425
+ messages: ['message_list', 'message_send', 'message_delete', 'message_read'],
426
+ images: ['image_generate', 'image_list', 'image_search'],
427
+ notifications: ['notify_user'],
428
+ };
429
+
430
+ /**
431
+ * Domain presets matching NemoClaw's policy preset pattern.
432
+ * Each preset maps to a list of allowed domains.
433
+ */
434
+ const DOMAIN_PRESETS = {
435
+ github: ['github.com', 'api.github.com', 'raw.githubusercontent.com'],
436
+ slack: ['slack.com', 'api.slack.com'],
437
+ discord: ['discord.com', 'api.discord.com'],
438
+ npm: ['registry.npmjs.org', 'www.npmjs.com'],
439
+ pypi: ['pypi.org', 'files.pythonhosted.org'],
440
+ jira: ['atlassian.net', 'jira.atlassian.com'],
441
+ huggingface: ['huggingface.co', 'api-inference.huggingface.co'],
442
+ docker: ['hub.docker.com', 'registry-1.docker.io'],
443
+ telegram: ['api.telegram.org'],
444
+ };
445
+
446
+ /**
447
+ * Resolve --allow values into a Set of allowed domains.
448
+ * Accepts preset names (e.g., "github") or raw domains (e.g., "api.example.com").
449
+ *
450
+ * @param {Array<string>} allowList - Preset names or domain names
451
+ * @returns {Set<string>} Resolved domain set
452
+ */
453
+ function resolveSandboxDomains(allowList = []) {
454
+ const domains = new Set();
455
+ for (const entry of allowList) {
456
+ const lower = entry.toLowerCase();
457
+ if (DOMAIN_PRESETS[lower]) {
458
+ for (const d of DOMAIN_PRESETS[lower]) domains.add(d);
459
+ } else {
460
+ domains.add(lower);
461
+ }
462
+ }
463
+ return domains;
464
+ }
465
+
466
+ /**
467
+ * Check if a URL's hostname matches the sandbox domain allowlist.
468
+ *
469
+ * @param {string} urlStr - URL to check
470
+ * @param {Set<string>} allowedDomains - Set of allowed domain names
471
+ * @returns {boolean} Whether the domain is allowed
472
+ */
473
+ function isDomainAllowed(urlStr, allowedDomains) {
474
+ try {
475
+ const { hostname } = new URL(urlStr);
476
+ if (allowedDomains.has(hostname)) return true;
477
+ // Support wildcard subdomains (e.g., "atlassian.net" matches "myteam.atlassian.net")
478
+ for (const domain of allowedDomains) {
479
+ if (hostname.endsWith(`.${domain}`)) return true;
480
+ }
481
+ return false;
482
+ } catch {
483
+ return false;
242
484
  }
485
+ }
243
486
 
487
+ /**
488
+ * Wrap a tool's execute function with domain enforcement.
489
+ *
490
+ * @param {Object} tool - Tool definition
491
+ * @param {Set<string>} allowedDomains - Allowed domains
492
+ * @returns {Object} Tool with domain-gated execute
493
+ */
494
+ function wrapWithDomainGate(tool, allowedDomains) {
495
+ const original = tool.execute;
244
496
  return {
245
- ...base,
246
- headers: () => base.headers(apiKey),
497
+ ...tool,
498
+ description: `${tool.description} [SANDBOX: restricted to allowed domains]`,
499
+ execute: async (input, signal, context) => {
500
+ const url = input.url;
501
+ if (!url || !isDomainAllowed(url, allowedDomains)) {
502
+ const allowed = [...allowedDomains].join(', ') || 'none';
503
+ return `Blocked by sandbox policy. Domain not in allowlist.\nAllowed domains: ${allowed}`;
504
+ }
505
+ return original(input, signal, context);
506
+ },
247
507
  };
248
508
  }
249
509
 
510
+ /**
511
+ * Get tools filtered by sandbox mode with domain-gated network access.
512
+ *
513
+ * Mirrors NemoClaw's deny-by-default policy:
514
+ * - No filesystem access (file_*)
515
+ * - No code execution (run_code)
516
+ * - No outbound messaging (message_*)
517
+ * - No image generation, notifications, or app scaffolding
518
+ * - Network tools (web_fetch, browser_navigate) restricted to domain allowlist
519
+ * - Curated search APIs (web_search, grokipedia_search) always allowed
520
+ *
521
+ * @param {boolean} sandbox - Whether sandbox mode is active
522
+ * @param {Array<string>} allowList - Domain presets or raw domains to allow
523
+ * @returns {Array} Filtered and gated tools
524
+ */
525
+ function getActiveTools(sandbox = false, allowList = []) {
526
+ if (!sandbox) return coreTools;
527
+
528
+ const allowedDomains = resolveSandboxDomains(allowList);
529
+ const allowLower = new Set(allowList.map((a) => a.toLowerCase()));
530
+
531
+ // Build set of preset-unlocked tool names
532
+ const presetUnlocked = new Set();
533
+ for (const [preset, toolNames] of Object.entries(SANDBOX_PRESET_TOOLS)) {
534
+ if (allowLower.has(preset)) {
535
+ for (const name of toolNames) presetUnlocked.add(name);
536
+ }
537
+ }
538
+
539
+ const tools = [];
540
+
541
+ for (const tool of coreTools) {
542
+ if (SANDBOX_ALLOWED_TOOLS.has(tool.name)) {
543
+ tools.push(tool);
544
+ } else if (SANDBOX_GATED_TOOLS.has(tool.name) && allowedDomains.size > 0) {
545
+ tools.push(wrapWithDomainGate(tool, allowedDomains));
546
+ } else if (presetUnlocked.has(tool.name)) {
547
+ tools.push(tool);
548
+ }
549
+ }
550
+
551
+ return tools;
552
+ }
553
+
250
554
  /**
251
555
  * Initialize stores.
252
556
  *
@@ -331,38 +635,48 @@ async function runChat(message, options) {
331
635
  ...storesObj,
332
636
  };
333
637
 
334
- let isThinking = false;
638
+ let hasThinkingText = false;
335
639
  let thinkingDone = false;
336
640
 
641
+ process.stdout.write('Thinking');
642
+ startSpinner();
643
+
337
644
  for await (const event of agentLoop({
338
645
  model: options.model,
339
646
  messages,
340
- tools: coreTools,
647
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
341
648
  provider,
342
649
  context,
343
650
  })) {
344
651
  switch (event.type) {
345
652
  case 'thinking':
346
- if (!isThinking) {
347
- process.stdout.write('Thinking...\n');
348
- isThinking = true;
349
- }
350
653
  if (event.text) {
654
+ if (!hasThinkingText) {
655
+ stopSpinner('');
656
+ process.stdout.write('\n');
657
+ hasThinkingText = true;
658
+ }
351
659
  process.stdout.write(event.text);
352
660
  }
353
661
  break;
354
662
  case 'text_delta':
355
- if (isThinking && !thinkingDone) {
356
- stopSpinner('');
357
- process.stdout.write('\n...done thinking.\n\n');
663
+ if (!thinkingDone) {
664
+ if (hasThinkingText) {
665
+ process.stdout.write('\n...done thinking.\n\n');
666
+ } else {
667
+ stopSpinner('');
668
+ }
358
669
  thinkingDone = true;
359
670
  }
360
671
  process.stdout.write(event.text);
361
672
  break;
362
673
  case 'tool_start':
363
- if (isThinking && !thinkingDone) {
364
- stopSpinner('');
365
- process.stdout.write('\n...done thinking.\n\n');
674
+ if (!thinkingDone) {
675
+ if (hasThinkingText) {
676
+ process.stdout.write('\n...done thinking.\n\n');
677
+ } else {
678
+ stopSpinner('');
679
+ }
366
680
  thinkingDone = true;
367
681
  }
368
682
  process.stdout.write(`[${event.name}] `);
@@ -420,7 +734,7 @@ async function runRepl(options) {
420
734
  output: process.stdout,
421
735
  });
422
736
 
423
- console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}`);
737
+ console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}${options.sandbox ? ' (sandbox)' : ''}`);
424
738
  if (options.session) {
425
739
  console.log(`Resuming session: ${session.id}`);
426
740
  }
@@ -428,6 +742,8 @@ async function runRepl(options) {
428
742
 
429
743
  const showHelp = () => {
430
744
  console.log('Available Commands:');
745
+ console.log(' /models List available models from provider');
746
+ console.log(' /load <model> Switch to a different model');
431
747
  console.log(' /show Show model information');
432
748
  console.log(' /clear Clear session context');
433
749
  console.log(' /bye Exit');
@@ -497,6 +813,50 @@ async function runRepl(options) {
497
813
  return;
498
814
  }
499
815
 
816
+ if (trimmed === '/models') {
817
+ const apiKey = process.env[AI_PROVIDERS[options.provider]?.envKey];
818
+ process.stdout.write('Fetching models');
819
+ startSpinner();
820
+ const { ok, models } = await fetchProviderModels(options.provider, apiKey);
821
+ if (ok && models.length) {
822
+ stopSpinner('');
823
+ console.log('');
824
+ for (const m of models) {
825
+ const active = m.id === options.model ? ' (active)' : '';
826
+ console.log(` ${m.id}${active}`);
827
+ }
828
+ console.log('');
829
+ } else {
830
+ stopSpinner('');
831
+ // Fall back to static list
832
+ const base = AI_PROVIDERS[options.provider];
833
+ if (base.models?.length) {
834
+ console.log('');
835
+ for (const m of base.models) {
836
+ const active = m.id === options.model ? ' (active)' : '';
837
+ console.log(` ${m.id}${active}`);
838
+ }
839
+ console.log('');
840
+ } else {
841
+ console.log('\nNo models found.\n');
842
+ }
843
+ }
844
+ promptUser();
845
+ return;
846
+ }
847
+
848
+ if (trimmed.startsWith('/load ')) {
849
+ const newModel = trimmed.slice(6).trim();
850
+ if (!newModel) {
851
+ console.log('Usage: /load <model-name>\n');
852
+ } else {
853
+ options.model = newModel;
854
+ console.log(`Switched to ${newModel}\n`);
855
+ }
856
+ promptUser();
857
+ return;
858
+ }
859
+
500
860
  await handleMessage(trimmed);
501
861
  });
502
862
  };
@@ -504,41 +864,51 @@ async function runRepl(options) {
504
864
  const handleMessage = async (text) => {
505
865
  messages.push({ role: 'user', content: text });
506
866
 
507
- let isThinking = false;
867
+ let hasThinkingText = false;
508
868
  let thinkingDone = false;
509
869
  let assistantContent = '';
510
870
 
871
+ process.stdout.write('Thinking');
872
+ startSpinner();
873
+
511
874
  try {
512
875
  for await (const event of agentLoop({
513
876
  model: options.model,
514
877
  messages: [...messages],
515
- tools: coreTools,
878
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
516
879
  provider,
517
880
  context,
518
881
  })) {
519
882
  switch (event.type) {
520
883
  case 'thinking':
521
- if (!isThinking) {
522
- process.stdout.write('Thinking...\n');
523
- isThinking = true;
524
- }
525
884
  if (event.text) {
885
+ if (!hasThinkingText) {
886
+ stopSpinner('');
887
+ process.stdout.write('\n');
888
+ hasThinkingText = true;
889
+ }
526
890
  process.stdout.write(event.text);
527
891
  }
528
892
  break;
529
893
  case 'text_delta':
530
- if (isThinking && !thinkingDone) {
531
- stopSpinner('');
532
- process.stdout.write('\n...done thinking.\n\n');
894
+ if (!thinkingDone) {
895
+ if (hasThinkingText) {
896
+ process.stdout.write('\n...done thinking.\n\n');
897
+ } else {
898
+ stopSpinner('');
899
+ }
533
900
  thinkingDone = true;
534
901
  }
535
902
  process.stdout.write(event.text);
536
903
  assistantContent += event.text;
537
904
  break;
538
905
  case 'tool_start':
539
- if (isThinking && !thinkingDone) {
540
- stopSpinner('');
541
- process.stdout.write('\n...done thinking.\n\n');
906
+ if (!thinkingDone) {
907
+ if (hasThinkingText) {
908
+ process.stdout.write('\n...done thinking.\n\n');
909
+ } else {
910
+ stopSpinner('');
911
+ }
542
912
  thinkingDone = true;
543
913
  }
544
914
  process.stdout.write(`[${event.name}] `);
@@ -670,7 +1040,7 @@ async function runServer(options) {
670
1040
  for await (const event of agentLoop({
671
1041
  model,
672
1042
  messages: reqMessages,
673
- tools: coreTools,
1043
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
674
1044
  provider,
675
1045
  context,
676
1046
  })) {
@@ -703,7 +1073,7 @@ async function runServer(options) {
703
1073
  for await (const event of agentLoop({
704
1074
  model,
705
1075
  messages: reqMessages,
706
- tools: coreTools,
1076
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
707
1077
  provider,
708
1078
  context,
709
1079
  })) {
@@ -777,7 +1147,7 @@ async function runServer(options) {
777
1147
  for await (const event of agentLoop({
778
1148
  model,
779
1149
  messages,
780
- tools: coreTools,
1150
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
781
1151
  provider,
782
1152
  context,
783
1153
  })) {
@@ -1164,6 +1534,62 @@ async function runDoctor(options) {
1164
1534
  console.log();
1165
1535
  }
1166
1536
 
1537
+ /**
1538
+ * List available models from the provider's API.
1539
+ *
1540
+ * @param {Object} options - CLI options
1541
+ */
1542
+ async function runModels(options) {
1543
+ await loadModules();
1544
+ const base = AI_PROVIDERS[options.provider];
1545
+ if (!base) {
1546
+ console.error(`Unknown provider: ${options.provider}`);
1547
+ process.exit(1);
1548
+ }
1549
+
1550
+ const apiKey = process.env[base.envKey];
1551
+ if (!apiKey && options.provider !== 'ollama') {
1552
+ console.error(`Missing ${base.envKey} — set it or run dotbot to configure interactively.`);
1553
+ process.exit(1);
1554
+ }
1555
+
1556
+ process.stdout.write('Fetching models');
1557
+ startSpinner();
1558
+
1559
+ const { ok, models } = await fetchProviderModels(options.provider, apiKey);
1560
+
1561
+ if (!ok) {
1562
+ stopSpinner('failed');
1563
+ console.error(`Could not fetch models from ${base.name}. Check your API key.`);
1564
+
1565
+ // Fall back to static list
1566
+ if (base.models?.length) {
1567
+ console.log(`\nLocal model list (${base.name}):\n`);
1568
+ for (const m of base.models) {
1569
+ const active = m.id === options.model ? ' (active)' : '';
1570
+ console.log(` ${m.id}${active}`);
1571
+ }
1572
+ console.log();
1573
+ }
1574
+ return;
1575
+ }
1576
+
1577
+ stopSpinner('');
1578
+
1579
+ if (options.json) {
1580
+ console.log(JSON.stringify(models));
1581
+ return;
1582
+ }
1583
+
1584
+ console.log(`\n${base.name} models (${models.length})\n`);
1585
+ for (const m of models) {
1586
+ const active = m.id === options.model ? ' (active)' : '';
1587
+ const label = m.name !== m.id ? ` — ${m.name}` : '';
1588
+ console.log(` ${m.id}${label}${active}`);
1589
+ }
1590
+ console.log();
1591
+ }
1592
+
1167
1593
  /**
1168
1594
  * Main entry point.
1169
1595
  */
@@ -1196,6 +1622,9 @@ async function main() {
1196
1622
  }
1197
1623
 
1198
1624
  switch (command) {
1625
+ case 'models':
1626
+ await runModels(args);
1627
+ break;
1199
1628
  case 'doctor':
1200
1629
  await runDoctor(args);
1201
1630
  break;
package/dotbot.db CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevederico/dotbot",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "AI agent CLI and library for Node.js — streaming, multi-provider, tool execution, autonomous tasks",
5
5
  "type": "module",
6
6
  "main": "index.js",