lazyclaw 3.99.11 → 3.99.12

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/cli.mjs CHANGED
@@ -39,6 +39,13 @@ function _resolveAuthKey(cfg, provider) {
39
39
  const active = (cfg.authActiveProfile || {})[provider];
40
40
  const hit = arr.find((p) => p && p.label === active) || arr[0];
41
41
  if (hit?.key) return hit.key;
42
+ // Custom OpenAI-compatible providers store their api-key inline in the
43
+ // customProviders[] entry. Honour that before falling back to the
44
+ // legacy single-key cfg['api-key'].
45
+ const custom = Array.isArray(cfg.customProviders)
46
+ ? cfg.customProviders.find((p) => p && p.name === provider)
47
+ : null;
48
+ if (custom?.apiKey) return custom.apiKey;
42
49
  return cfg['api-key'] || '';
43
50
  }
44
51
 
@@ -684,6 +691,15 @@ function require_registry_sync() {
684
691
  }
685
692
  async function ensureRegistry() {
686
693
  if (!_registryMod) _registryMod = await import('./providers/registry.mjs');
694
+ // Re-run registration on every call so config changes within the same
695
+ // process (e.g. setup wizard adding a custom endpoint mid-session) take
696
+ // effect for the next chat / agent / picker invocation. registerCustom-
697
+ // Providers is idempotent — re-registering the same name is a no-op.
698
+ try {
699
+ if (typeof _registryMod.registerCustomProviders === 'function') {
700
+ _registryMod.registerCustomProviders(readConfig());
701
+ }
702
+ } catch { /* never let a malformed cfg.customProviders block startup */ }
687
703
  return _registryMod;
688
704
  }
689
705
 
@@ -885,7 +901,7 @@ const SUBCOMMAND_SUBS = {
885
901
  config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
886
902
  sessions: ['list', 'show', 'clear', 'export', 'search'],
887
903
  skills: ['list', 'show', 'install', 'remove', 'search'],
888
- providers: ['list', 'info', 'test'],
904
+ providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
889
905
  rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
890
906
  completion: ['bash', 'zsh'],
891
907
  auth: ['list', 'add', 'remove', 'use', 'rotate'],
@@ -1095,7 +1111,7 @@ const HELP_SUMMARIES = {
1095
1111
  onboard: 'Guided setup (use --non-interactive for scripts)',
1096
1112
  sessions: 'Persistent chat sessions (list|show|clear|export)',
1097
1113
  skills: 'Markdown skill bundles (list|show|install|remove)',
1098
- providers: 'Inspect registered providers (list|info <name>)',
1114
+ providers: 'Inspect / register providers (list|info|test|add|remove|models)',
1099
1115
  daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
1100
1116
  version: 'Print VERSION + node + platform as JSON',
1101
1117
  completion: 'Emit shell completion script (completion <bash|zsh>)',
@@ -1134,7 +1150,7 @@ const HELP_DETAILS = {
1134
1150
  onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
1135
1151
  sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
1136
1152
  skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <user/repo[@ref][:path]> [--prefix <p>] [--force] | install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n install <user>/<repo>[@<ref>][:<subpath>] — fetch a GitHub tarball, install every .md under skills/ (or the explicit subpath, or repo root). Default ref is `main`.\n --prefix prepends a name prefix so a multi-skill repo doesn\'t collide with locally-managed skills. --force overwrites existing names.\n install <name> --from <path> | --from-url <https://...> — single-file install. --from-url is HTTPS-only with a 1 MiB cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
1137
- providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.',
1153
+ providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T] | add <name> --base-url <url> [--api-key <k>] [--default-model <id>] [--no-probe] | remove <name> | models <name> [--filter <substr>]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.\n add — register a custom OpenAI-compatible endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).\n Probes /v1/models on success unless --no-probe is set; persists to cfg.customProviders[].\n remove — drop a custom provider entry from cfg.customProviders[].\n models — fetch + print the live model catalogue from <provider>/v1/models (works for openai / ollama / custom).',
1138
1154
  daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
1139
1155
  version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
1140
1156
  completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
@@ -1495,7 +1511,7 @@ function _printChatBanner(activeProvName, activeModel, version) {
1495
1511
  // optional pre-coloured pill (e.g. "[api key]") that lands on the
1496
1512
  // right side of the row. `defaultIdx` lets the caller pin where the
1497
1513
  // cursor lands; default 0.
1498
- async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
1514
+ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0, searchable = false }) {
1499
1515
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
1500
1516
  // Non-TTY fallback: print the labels on stderr and read a single
1501
1517
  // line of stdin. Used when somebody pipes input to `lazyclaw
@@ -1528,24 +1544,68 @@ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
1528
1544
  // before drawing so the picker always receives the first keypress.
1529
1545
  process.stdin.resume();
1530
1546
  if (process.stdin.ref) process.stdin.ref();
1531
- let idx = Math.max(0, Math.min(items.length - 1, defaultIdx));
1532
1547
  const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1533
1548
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1534
1549
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1535
1550
 
1551
+ // Typeahead state. `query` accumulates printable chars when searchable
1552
+ // is on; the visible item slice is recomputed on every keystroke. We
1553
+ // keep `defaultIdx` semantics by mapping it to the unfiltered list and
1554
+ // tracking selection inside the filtered view via the item identity.
1555
+ let query = '';
1556
+ const matchScore = (it, q) => {
1557
+ if (!q) return 0;
1558
+ const hay = `${it.label || ''} ${it.desc || ''} ${it.id || ''}`.toLowerCase();
1559
+ const needle = q.toLowerCase();
1560
+ if (hay.includes(needle)) return hay.indexOf(needle) === 0 ? 2 : 1;
1561
+ // simple subsequence fallback so "g4o" matches "gpt-4o".
1562
+ let i = 0; let matched = 0;
1563
+ for (const ch of hay) {
1564
+ if (ch === needle[matched]) { matched++; if (matched === needle.length) break; }
1565
+ i++;
1566
+ }
1567
+ return matched === needle.length ? 0.5 : 0;
1568
+ };
1569
+ const filterItems = () => {
1570
+ if (!searchable || !query) return items.slice();
1571
+ const scored = items
1572
+ .map((it) => ({ it, s: matchScore(it, query) }))
1573
+ .filter((x) => x.s > 0)
1574
+ .sort((a, b) => b.s - a.s);
1575
+ return scored.map((x) => x.it);
1576
+ };
1577
+ let view = filterItems();
1578
+ let idx = Math.max(0, Math.min(view.length - 1, defaultIdx));
1579
+ if (idx < 0) idx = 0;
1580
+
1536
1581
  const draw = () => {
1537
1582
  process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
1538
1583
  process.stdout.write(accent(title) + '\n');
1539
1584
  if (subtitle) process.stdout.write(dim(subtitle) + '\n');
1540
- process.stdout.write(dim('↑/↓ to move · Enter to confirm · Esc to back · q to quit') + '\n\n');
1541
- const rows = Math.max(6, (process.stdout.rows || 24) - 8);
1585
+ const help = searchable
1586
+ ? '↑/↓ to move · Enter to confirm · type to search · Esc to back · Ctrl+U to clear · q to quit'
1587
+ : '↑/↓ to move · Enter to confirm · Esc to back · q to quit';
1588
+ process.stdout.write(dim(help) + '\n');
1589
+ if (searchable) {
1590
+ const q = query ? bold(query) : dim('(type to filter)');
1591
+ process.stdout.write(dim(' search: ') + q + dim(` ${view.length}/${items.length} match`) + '\n\n');
1592
+ } else {
1593
+ process.stdout.write('\n');
1594
+ }
1595
+ if (view.length === 0) {
1596
+ process.stdout.write(' ' + dim('(no matches — backspace or Ctrl+U to clear the filter)') + '\n');
1597
+ if (footer) process.stdout.write('\n' + dim(footer) + '\n');
1598
+ return;
1599
+ }
1600
+ const headerLines = subtitle ? 4 : 3;
1601
+ const rows = Math.max(6, (process.stdout.rows || 24) - (headerLines + (searchable ? 3 : 4)));
1542
1602
  let from = Math.max(0, idx - Math.floor(rows / 2));
1543
- if (from + rows > items.length) from = Math.max(0, items.length - rows);
1544
- const to = Math.min(items.length, from + rows);
1603
+ if (from + rows > view.length) from = Math.max(0, view.length - rows);
1604
+ const to = Math.min(view.length, from + rows);
1545
1605
  // Pre-compute label width so descriptions line up across rows.
1546
- const labelW = items.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
1606
+ const labelW = view.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
1547
1607
  for (let i = from; i < to; i++) {
1548
- const it = items[i];
1608
+ const it = view[i];
1549
1609
  const marker = i === idx ? accent('❯ ') : ' ';
1550
1610
  const lbl = (it.label || '').padEnd(labelW);
1551
1611
  const lblOut = i === idx ? bold(lbl) : lbl;
@@ -1553,26 +1613,50 @@ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
1553
1613
  const tag = it.tag ? ' ' + it.tag : '';
1554
1614
  process.stdout.write(`${marker}${lblOut}${desc}${tag}\n`);
1555
1615
  }
1556
- if (to < items.length) {
1557
- process.stdout.write(`${dim(` …(${items.length - to} more)`)}\n`);
1616
+ if (to < view.length) {
1617
+ process.stdout.write(`${dim(` …(${view.length - to} more)`)}\n`);
1558
1618
  }
1559
1619
  if (footer) process.stdout.write('\n' + dim(footer) + '\n');
1560
1620
  };
1561
1621
 
1562
1622
  draw();
1563
1623
  return await new Promise((resolve) => {
1564
- const onKey = (_str, key) => {
1624
+ const recompute = () => {
1625
+ view = filterItems();
1626
+ if (idx >= view.length) idx = Math.max(0, view.length - 1);
1627
+ draw();
1628
+ };
1629
+ const onKey = (str, key) => {
1565
1630
  if (!key) return;
1566
- if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
1567
- else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
1631
+ if (key.name === 'up') { if (view.length) { idx = (idx - 1 + view.length) % view.length; draw(); } }
1632
+ else if (key.name === 'down') { if (view.length) { idx = (idx + 1) % view.length; draw(); } }
1568
1633
  else if (key.name === 'pageup') { idx = Math.max(0, idx - 10); draw(); }
1569
- else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 10); draw(); }
1634
+ else if (key.name === 'pagedown') { idx = Math.min(view.length - 1, idx + 10); draw(); }
1570
1635
  else if (key.name === 'home') { idx = 0; draw(); }
1571
- else if (key.name === 'end') { idx = items.length - 1; draw(); }
1572
- else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
1636
+ else if (key.name === 'end') { idx = view.length - 1; draw(); }
1637
+ else if (key.name === 'return') {
1638
+ if (view.length === 0) return;
1639
+ cleanup();
1640
+ resolve(view[idx]);
1641
+ }
1573
1642
  else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
1574
- else if (key.name === 'escape') { cleanup(); resolve('BACK'); }
1575
- else if (key.name === 'q') { cleanup(); resolve('CANCEL'); }
1643
+ else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
1644
+ else if (key.name === 'escape') {
1645
+ if (searchable && query) { query = ''; recompute(); return; }
1646
+ cleanup(); resolve('BACK');
1647
+ }
1648
+ else if (key.name === 'backspace') {
1649
+ if (searchable && query.length > 0) { query = query.slice(0, -1); recompute(); }
1650
+ }
1651
+ else if (searchable && str && str.length === 1 && str >= ' ' && str !== '\x7f' && !key.ctrl && !key.meta) {
1652
+ // Printable char → append to filter buffer. We deliberately do not
1653
+ // intercept 'q' as a shortcut when searchable is on, because the
1654
+ // user might be typing a model id that contains 'q'. Use Esc / Ctrl+C
1655
+ // to bail out instead.
1656
+ query += str;
1657
+ recompute();
1658
+ }
1659
+ else if (!searchable && key.name === 'q') { cleanup(); resolve('CANCEL'); }
1576
1660
  };
1577
1661
  const cleanup = () => {
1578
1662
  process.stdin.off('keypress', onKey);
@@ -1659,57 +1743,245 @@ async function _pickProviderInteractive() {
1659
1743
  let provider = null;
1660
1744
  while (!provider) {
1661
1745
  const memberNames = families[family.id].members;
1662
- if (memberNames.length === 1) {
1663
- // Auto-advance — no point making the user pick from a single
1664
- // row.
1665
- provider = { id: memberNames[0] };
1666
- break;
1667
- }
1668
1746
  const provItems = memberNames.map((name) => {
1669
1747
  const meta = info[name] || {};
1670
1748
  const models = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(default)';
1749
+ const isCustom = !!meta.custom;
1671
1750
  return {
1672
1751
  id: name,
1673
1752
  label: name,
1674
- desc: `models: ${models}`,
1675
- tag: meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m',
1753
+ desc: isCustom
1754
+ ? `custom · ${meta.baseUrl || ''}`
1755
+ : `models: ${models}`,
1756
+ tag: isCustom
1757
+ ? '\x1b[38;5;213m[custom]\x1b[0m'
1758
+ : (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
1676
1759
  };
1677
1760
  });
1761
+ // Surface a "+ Add a new custom endpoint…" entry inside the API-key
1762
+ // family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
1763
+ // speak the OpenAI Chat-Completions wire format — this single hook
1764
+ // covers every one of them without shipping a per-vendor provider.
1765
+ if (family.id === 'api') {
1766
+ provItems.push({
1767
+ id: '__add_custom__',
1768
+ label: '+ Add a custom OpenAI-compatible endpoint…',
1769
+ desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
1770
+ tag: '\x1b[38;5;213m[new]\x1b[0m',
1771
+ });
1772
+ }
1773
+ if (memberNames.length === 1 && family.id !== 'api') {
1774
+ // Auto-advance — no point making the user pick from a single row,
1775
+ // unless we just appended the "+ Add custom" entry above.
1776
+ provider = { id: memberNames[0] };
1777
+ break;
1778
+ }
1678
1779
  const picked = await _arrowMenu({
1679
1780
  title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
1680
- subtitle: `Showing ${memberNames.length} ${family.label.toLowerCase()} provider(s).`,
1781
+ subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
1681
1782
  items: provItems,
1783
+ searchable: true,
1682
1784
  });
1683
1785
  if (picked === 'CANCEL') return null;
1684
1786
  if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
1787
+ if (picked && picked.id === '__add_custom__') {
1788
+ const added = await _addCustomProviderInteractive();
1789
+ if (!added) continue; // back to provider list
1790
+ // Force the registry to pick up the new entry and recompute the
1791
+ // family bucket for the next loop iteration.
1792
+ await ensureRegistry();
1793
+ Object.assign(families, _providerFamilies());
1794
+ provider = { id: added.name };
1795
+ break;
1796
+ }
1685
1797
  provider = picked;
1686
1798
  }
1687
1799
 
1688
1800
  // ── Step 3 — model ────────────────────────────────────────────
1689
1801
  const meta = info[provider.id] || {};
1690
- const models = Array.isArray(meta.suggestedModels) ? meta.suggestedModels : [];
1691
- if (!models.length) {
1692
- // Provider has no curated models (mock) return without a
1693
- // model so the underlying call uses the provider default.
1802
+ const baseModels = Array.isArray(meta.suggestedModels) ? meta.suggestedModels.slice() : [];
1803
+ const isCustom = !!meta.custom;
1804
+ const supportsLiveFetch = !!meta.baseUrl || provider.id === 'openai' || provider.id === 'ollama';
1805
+
1806
+ if (!baseModels.length && !supportsLiveFetch) {
1807
+ // Provider has no curated models AND no live-fetch surface (mock) —
1808
+ // return without a model so the underlying call uses the provider
1809
+ // default.
1694
1810
  return { provider: provider.id, model: null };
1695
1811
  }
1812
+
1813
+ let dynamicModels = [];
1696
1814
  while (true) {
1697
- const modelItems = models.map((m) => ({ id: m, label: m, desc: '' }));
1698
- // Pin the cursor to the provider's defaultModel so Enter without
1699
- // navigation picks the most-recommended one.
1700
- const defaultIdx = Math.max(0, models.indexOf(meta.defaultModel || models[0]));
1815
+ const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
1816
+ const modelItems = allModels.map((m) => ({ id: m, label: m, desc: '' }));
1817
+ if (supportsLiveFetch) {
1818
+ modelItems.unshift({
1819
+ id: '__fetch_models__',
1820
+ label: '↻ Fetch live model list from /v1/models',
1821
+ desc: isCustom ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
1822
+ tag: '\x1b[38;5;245m[live]\x1b[0m',
1823
+ });
1824
+ }
1825
+ modelItems.push({
1826
+ id: '__custom_model__',
1827
+ label: '… type a custom model id',
1828
+ desc: 'use any model id supported by this provider, even if not listed above',
1829
+ tag: '\x1b[38;5;245m[free]\x1b[0m',
1830
+ });
1831
+
1832
+ const defaultIdx = supportsLiveFetch
1833
+ ? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
1834
+ : Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
1701
1835
  const picked = await _arrowMenu({
1702
1836
  title: `LazyClaw setup — Step 3 of 3: pick a model for ${provider.id}`,
1703
- subtitle: `Showing ${models.length} suggested model(s). Type the model id directly later via /model in chat to use anything not listed here.`,
1837
+ subtitle: `Type to filter ${allModels.length} model(s). Enter to confirm. Backspace clears one char, Ctrl+U clears the filter.`,
1704
1838
  items: modelItems,
1705
1839
  defaultIdx,
1840
+ searchable: true,
1706
1841
  });
1707
1842
  if (picked === 'CANCEL') return null;
1708
1843
  if (picked === 'BACK') return _pickProviderInteractive(); // back to step 1
1844
+ if (picked.id === '__custom_model__') {
1845
+ const typed = (await _quickPrompt(` model id for ${provider.id}: `)).trim();
1846
+ if (!typed) continue;
1847
+ return { provider: provider.id, model: typed };
1848
+ }
1849
+ if (picked.id === '__fetch_models__') {
1850
+ try {
1851
+ process.stdout.write(`\n fetching ${provider.id} model list…\n`);
1852
+ const fetched = await _fetchModelsForProvider(provider.id);
1853
+ if (!fetched.length) {
1854
+ process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
1855
+ await _quickPrompt(' press Enter to continue ');
1856
+ } else {
1857
+ dynamicModels = fetched;
1858
+ process.stdout.write(` fetched ${fetched.length} model(s).\n`);
1859
+ await _quickPrompt(' press Enter to pick one ');
1860
+ }
1861
+ } catch (e) {
1862
+ process.stdout.write(`\n ${'\x1b[33m'}fetch failed:${'\x1b[0m'} ${e?.message || e}\n`);
1863
+ await _quickPrompt(' press Enter to continue ');
1864
+ }
1865
+ continue;
1866
+ }
1709
1867
  return { provider: provider.id, model: picked.id };
1710
1868
  }
1711
1869
  }
1712
1870
 
1871
+ // Resolve {baseUrl, apiKey} for a provider so we can call /v1/models on
1872
+ // its behalf. Returns null when the provider doesn't expose an OpenAI-
1873
+ // compatible model catalogue (e.g. anthropic, gemini, claude-cli).
1874
+ function _modelCatalogueFor(providerId) {
1875
+ const cfg = readConfig();
1876
+ const meta = (_registryMod.PROVIDER_INFO || {})[providerId] || {};
1877
+ if (meta.custom && meta.baseUrl) {
1878
+ const entry = (cfg.customProviders || []).find((p) => p && p.name === providerId) || {};
1879
+ return { baseUrl: meta.baseUrl, apiKey: entry.apiKey || cfg['api-key'] || '' };
1880
+ }
1881
+ if (providerId === 'openai') {
1882
+ return { baseUrl: 'https://api.openai.com/v1', apiKey: _resolveAuthKey(cfg, 'openai') };
1883
+ }
1884
+ if (providerId === 'ollama') {
1885
+ const host = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
1886
+ return { baseUrl: `${host.replace(/\/$/, '')}/v1`, apiKey: '' };
1887
+ }
1888
+ return null;
1889
+ }
1890
+
1891
+ async function _fetchModelsForProvider(providerId) {
1892
+ const c = _modelCatalogueFor(providerId);
1893
+ if (!c) throw new Error(`provider "${providerId}" does not expose an OpenAI-compatible /v1/models endpoint`);
1894
+ const { fetchOpenAICompatModels } = await import('./providers/openai_compat.mjs');
1895
+ return fetchOpenAICompatModels({ baseUrl: c.baseUrl, apiKey: c.apiKey });
1896
+ }
1897
+
1898
+ // Walk the user through registering a new OpenAI-compatible custom
1899
+ // provider (NIM, OpenRouter, vLLM, LM Studio, Together, Groq, …).
1900
+ // Persists into cfg.customProviders[] and returns { name } on success,
1901
+ // or null when the user backs out.
1902
+ async function _addCustomProviderInteractive() {
1903
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1904
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1905
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1906
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1907
+
1908
+ process.stdout.write('\x1b[2J\x1b[H');
1909
+ process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
1910
+ process.stdout.write(dim('Works with any service that speaks the OpenAI v1 wire format.') + '\n');
1911
+ process.stdout.write(dim('Examples:') + '\n');
1912
+ process.stdout.write(dim(' · NVIDIA NIM https://integrate.api.nvidia.com/v1') + '\n');
1913
+ process.stdout.write(dim(' · OpenRouter https://openrouter.ai/api/v1') + '\n');
1914
+ process.stdout.write(dim(' · Together AI https://api.together.xyz/v1') + '\n');
1915
+ process.stdout.write(dim(' · Groq https://api.groq.com/openai/v1') + '\n');
1916
+ process.stdout.write(dim(' · vLLM / LM Studio http://localhost:8000/v1') + '\n\n');
1917
+
1918
+ const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels } = _registryMod;
1919
+ let name;
1920
+ while (true) {
1921
+ const raw = (await _quickPrompt(` ${bold('name')} ${dim('(short id, e.g. "nim", "openrouter"):')} `)).trim();
1922
+ if (!raw) {
1923
+ process.stdout.write(dim(' cancelled — back to the picker.\n'));
1924
+ return null;
1925
+ }
1926
+ try { name = validateCustomProviderName(raw); break; }
1927
+ catch (e) { process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`); }
1928
+ }
1929
+ const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
1930
+ if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
1931
+ if (!/^https?:\/\//i.test(baseUrlRaw)) {
1932
+ process.stdout.write(' \x1b[33mbaseUrl must start with http:// or https://\x1b[0m — cancelled.\n');
1933
+ return null;
1934
+ }
1935
+ const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
1936
+
1937
+ // Persist to cfg.customProviders[]. Overwrite an existing entry of the
1938
+ // same name so re-running setup with a corrected URL just works.
1939
+ const cfg = readConfig();
1940
+ cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
1941
+ const existingIdx = cfg.customProviders.findIndex((p) => p && p.name === name);
1942
+ const entry = {
1943
+ name,
1944
+ baseUrl: baseUrlRaw.replace(/\/+$/, ''),
1945
+ apiKey: apiKey || undefined,
1946
+ };
1947
+ if (existingIdx >= 0) cfg.customProviders[existingIdx] = { ...cfg.customProviders[existingIdx], ...entry };
1948
+ else cfg.customProviders.push(entry);
1949
+ writeConfig(cfg);
1950
+
1951
+ // Hot-register so the provider is callable in this same process.
1952
+ registerCustomProviders(cfg);
1953
+
1954
+ // Best-effort live model probe so the user sees we can reach it. Skip
1955
+ // silently on failure — registration still succeeds and /v1/models can
1956
+ // be re-tried from the model picker.
1957
+ let probeMsg = '';
1958
+ try {
1959
+ const list = await fetchOpenAICompatModels({ baseUrl: entry.baseUrl, apiKey: entry.apiKey || '' });
1960
+ if (list.length) {
1961
+ probeMsg = ` ${ok('✓')} reachable — ${list.length} model(s) advertised at ${entry.baseUrl}/models\n`;
1962
+ // Persist the catalogue so the picker can show it without re-fetching.
1963
+ const updated = readConfig();
1964
+ const i = (updated.customProviders || []).findIndex((p) => p && p.name === name);
1965
+ if (i >= 0) {
1966
+ updated.customProviders[i].suggestedModels = list.slice(0, 50);
1967
+ if (!updated.customProviders[i].defaultModel) updated.customProviders[i].defaultModel = list[0];
1968
+ writeConfig(updated);
1969
+ registerCustomProviders(updated);
1970
+ }
1971
+ } else {
1972
+ probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
1973
+ }
1974
+ } catch (e) {
1975
+ probeMsg = ` \x1b[33m!\x1b[0m registered, but /v1/models probe failed: ${e?.message || e}\n`;
1976
+ }
1977
+ process.stdout.write('\n');
1978
+ process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${name} ${dim('→')} ${entry.baseUrl}\n`);
1979
+ process.stdout.write(probeMsg);
1980
+ process.stdout.write(dim(` Removable any time via: lazyclaw providers remove ${name}\n`));
1981
+ await _quickPrompt(' press Enter to continue ');
1982
+ return { name };
1983
+ }
1984
+
1713
1985
  async function cmdChat(flags = {}) {
1714
1986
  await ensureRegistry();
1715
1987
  const sessionsMod = await import('./sessions.mjs');
@@ -3113,8 +3385,116 @@ async function cmdProviders(sub, positional, flags = {}) {
3113
3385
  process.exit(1);
3114
3386
  }
3115
3387
  }
3388
+ case 'add': {
3389
+ // Register an OpenAI-compatible custom endpoint non-interactively.
3390
+ // Mirrors the picker's "+ Add custom" flow but scriptable, so users
3391
+ // can wire NIM / OpenRouter / vLLM into config without entering the
3392
+ // arrow-key UI.
3393
+ // lazyclaw providers add nim \
3394
+ // --base-url https://integrate.api.nvidia.com/v1 \
3395
+ // --api-key nvapi-xxx \
3396
+ // [--default-model meta/llama-3.1-70b] \
3397
+ // [--no-probe]
3398
+ const name = positional[0];
3399
+ const baseUrl = flags['base-url'] || flags.baseUrl;
3400
+ const apiKey = flags['api-key'] || flags.apiKey || '';
3401
+ if (!name || !baseUrl) {
3402
+ console.error('Usage: lazyclaw providers add <name> --base-url <url> [--api-key <key>] [--default-model <id>] [--no-probe]');
3403
+ process.exit(2);
3404
+ }
3405
+ let validName;
3406
+ try { validName = _registryMod.validateCustomProviderName(name); }
3407
+ catch (e) { console.error(e.message); process.exit(2); }
3408
+ if (!/^https?:\/\//i.test(String(baseUrl))) {
3409
+ console.error('--base-url must start with http:// or https://');
3410
+ process.exit(2);
3411
+ }
3412
+ const cfg = readConfig();
3413
+ cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
3414
+ const idx = cfg.customProviders.findIndex((p) => p && p.name === validName);
3415
+ const entry = {
3416
+ name: validName,
3417
+ baseUrl: String(baseUrl).replace(/\/+$/, ''),
3418
+ apiKey: apiKey || undefined,
3419
+ };
3420
+ if (flags['default-model']) entry.defaultModel = flags['default-model'];
3421
+ if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
3422
+ else cfg.customProviders.push(entry);
3423
+ writeConfig(cfg);
3424
+ _registryMod.registerCustomProviders(cfg);
3425
+
3426
+ let probe = null;
3427
+ if (!flags['no-probe']) {
3428
+ try {
3429
+ const list = await _registryMod.fetchOpenAICompatModels({
3430
+ baseUrl: entry.baseUrl, apiKey: entry.apiKey || '',
3431
+ });
3432
+ probe = { ok: true, modelCount: list.length, sample: list.slice(0, 8) };
3433
+ if (list.length) {
3434
+ const updated = readConfig();
3435
+ const i = (updated.customProviders || []).findIndex((p) => p && p.name === validName);
3436
+ if (i >= 0) {
3437
+ updated.customProviders[i].suggestedModels = list.slice(0, 50);
3438
+ if (!updated.customProviders[i].defaultModel) updated.customProviders[i].defaultModel = list[0];
3439
+ writeConfig(updated);
3440
+ _registryMod.registerCustomProviders(updated);
3441
+ }
3442
+ }
3443
+ } catch (e) {
3444
+ probe = { ok: false, error: e?.message || String(e) };
3445
+ }
3446
+ }
3447
+ console.log(JSON.stringify({
3448
+ ok: true, added: validName, baseUrl: entry.baseUrl, hasApiKey: !!entry.apiKey, probe,
3449
+ }, null, 2));
3450
+ return;
3451
+ }
3452
+ case 'remove': {
3453
+ const name = positional[0];
3454
+ if (!name) { console.error('Usage: lazyclaw providers remove <name>'); process.exit(2); }
3455
+ const cfg = readConfig();
3456
+ const list = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
3457
+ const before = list.length;
3458
+ cfg.customProviders = list.filter((p) => !(p && p.name === name));
3459
+ if (cfg.customProviders.length === before) {
3460
+ console.error(`no custom provider named "${name}" — registered: ${list.map((p) => p.name).join(', ') || '(none)'}`);
3461
+ process.exit(2);
3462
+ }
3463
+ writeConfig(cfg);
3464
+ // The in-memory PROVIDERS map keeps the dropped entry until process
3465
+ // restart — fine for the CLI (each invocation re-registers from
3466
+ // disk). We don't try to mutate it here.
3467
+ console.log(JSON.stringify({ ok: true, removed: name }, null, 2));
3468
+ return;
3469
+ }
3470
+ case 'models': {
3471
+ // Fetch + print the live model list from a provider's /v1/models.
3472
+ // Works for any registered OpenAI-compatible endpoint (custom +
3473
+ // openai + ollama). Used by the picker but useful standalone too:
3474
+ // lazyclaw providers models nim
3475
+ // lazyclaw providers models openai --filter gpt-4
3476
+ const name = positional[0];
3477
+ if (!name) { console.error('Usage: lazyclaw providers models <name> [--filter <substr>]'); process.exit(2); }
3478
+ if (!_registryMod.PROVIDERS[name]) {
3479
+ console.error(`unknown provider: ${name}`);
3480
+ process.exit(2);
3481
+ }
3482
+ try {
3483
+ const list = await _fetchModelsForProvider(name);
3484
+ let out = list;
3485
+ if (flags.filter) {
3486
+ const f = String(flags.filter).toLowerCase();
3487
+ out = out.filter((m) => m.toLowerCase().includes(f));
3488
+ }
3489
+ console.log(JSON.stringify({ ok: true, provider: name, count: out.length, models: out }, null, 2));
3490
+ return;
3491
+ } catch (e) {
3492
+ console.log(JSON.stringify({ ok: false, provider: name, error: e?.message || String(e) }, null, 2));
3493
+ process.exit(1);
3494
+ }
3495
+ }
3116
3496
  default:
3117
- console.error('Usage: lazyclaw providers <list|info <name>|test <name> [--model X] [--prompt T]>');
3497
+ console.error('Usage: lazyclaw providers <list|info <name>|test <name>|add <name> --base-url <url> [--api-key <k>]|remove <name>|models <name>>');
3118
3498
  process.exit(2);
3119
3499
  }
3120
3500
  }
@@ -3333,6 +3713,8 @@ const BOOLEAN_FLAGS = new Set([
3333
3713
  'aggregate', // inspect (list mode): per-node stats across sessions
3334
3714
  'all', // providers test: run all providers in parallel
3335
3715
  'with-turn-count', // sessions list: include turn count per session
3716
+ 'no-probe', // providers add: skip the /v1/models reachability probe
3717
+ 'pick', // onboard / chat: force the interactive picker even when provider already set
3336
3718
  ]);
3337
3719
 
3338
3720
  function parseArgs(argv) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.11",
3
+ "version": "3.99.12",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",