lazyclaw 3.99.13 → 3.99.15

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/README.md CHANGED
@@ -54,6 +54,54 @@ lazyclaw onboard --non-interactive --provider openai \
54
54
 
55
55
  `onboard` only prompts for an api-key when the picked provider's `requiresApiKey` is true (the picker labels each row `[subscription]` / `[api key]` / `[no key]` so the choice is explicit).
56
56
 
57
+ ### Built-in OpenAI-compatible vendors
58
+
59
+ Eight popular OpenAI-compatible services ship as first-class providers — pick one in the setup picker (no `+ Add custom` walkthrough needed) or set the matching environment variable and they Just Work:
60
+
61
+ | Provider | Models include | Env var |
62
+ |---|---|---|
63
+ | `nim` | `meta/llama-3.1-405b-instruct`, `nvidia/llama-3.1-nemotron-70b-instruct`, `deepseek-ai/deepseek-r1`, `mistralai/mixtral-8x22b-instruct-v0.1` | `NVIDIA_API_KEY` (or `NIM_API_KEY`) |
64
+ | `openrouter` | `anthropic/claude-3.5-sonnet`, `openai/gpt-4o`, `meta-llama/llama-3.1-405b-instruct`, `deepseek/deepseek-r1` | `OPENROUTER_API_KEY` |
65
+ | `groq` | `llama-3.3-70b-versatile`, `mixtral-8x7b-32768`, `deepseek-r1-distill-llama-70b` | `GROQ_API_KEY` |
66
+ | `together` | `meta-llama/Llama-3.3-70B-Instruct-Turbo`, `Qwen/Qwen2.5-72B-Instruct-Turbo`, `deepseek-ai/DeepSeek-V3` | `TOGETHER_API_KEY` |
67
+ | `xai` | `grok-2-latest`, `grok-2-vision-1212` | `XAI_API_KEY` (or `GROK_API_KEY`) |
68
+ | `deepseek` | `deepseek-chat`, `deepseek-reasoner` | `DEEPSEEK_API_KEY` |
69
+ | `mistral` | `mistral-large-latest`, `codestral-latest`, `pixtral-large-latest` | `MISTRAL_API_KEY` |
70
+ | `fireworks` | `accounts/fireworks/models/llama-v3p3-70b-instruct`, `…/deepseek-r1` | `FIREWORKS_API_KEY` |
71
+
72
+ ```bash
73
+ # NVIDIA NIM via env var — no `lazyclaw onboard` needed
74
+ export NVIDIA_API_KEY=nvapi-...
75
+ lazyclaw chat --provider nim --model meta/llama-3.1-405b-instruct
76
+
77
+ # Or commit the choice to ~/.lazyclaw/config.json
78
+ lazyclaw onboard --non-interactive --provider nim \
79
+ --model nvidia/llama-3.1-nemotron-70b-instruct --api-key nvapi-...
80
+ ```
81
+
82
+ Need a vendor that's **not** built-in? `+ Add a custom OpenAI-compatible endpoint…` inside the setup picker (or `lazyclaw providers add <name> --base-url <url>`) still works for vLLM / LM Studio / private gateways / anything else that speaks the OpenAI v1 wire format.
83
+
84
+ ## Launcher (no-arg `lazyclaw`)
85
+
86
+ Running `lazyclaw` with no subcommand drops into an arrow-key launcher with every subcommand laid out as a menu. Navigation:
87
+
88
+ | Key | What it does |
89
+ |---|---|
90
+ | `↑` / `↓` / `Home` / `End` / `PgUp` / `PgDn` | Move the selection |
91
+ | `Enter` | Run the highlighted item |
92
+ | `q` / `Esc` / `Ctrl-C` | Leave lazyclaw |
93
+ | `/` | Open an inline slash-command prompt |
94
+
95
+ Slash commands at the launcher (typed after `/`):
96
+
97
+ | Slash | What it does |
98
+ |---|---|
99
+ | `/exit` / `/quit` | Leave lazyclaw |
100
+ | `/help` | List launcher slash commands inline |
101
+ | `/version` | Print version + node + platform |
102
+
103
+ The slash buffer lives just below the menu — backspace edits it, deleting past `/` returns to menu mode, and `Esc` cancels slash mode without leaving lazyclaw.
104
+
57
105
  ## Interactive chat
58
106
 
59
107
  ```bash
@@ -88,12 +136,14 @@ Slash commands inside the REPL:
88
136
  |---|---|
89
137
  | `/help` | List slash commands |
90
138
  | `/status` | Print provider + model + masked key |
91
- | `/provider X` | Switch active provider mid-session (history kept) |
92
- | `/model X` | Switch model. Accepts unified `provider/model` form |
139
+ | `/provider` | Open the family / provider / model arrow picker |
140
+ | `/provider X` | Switch active provider directly by name |
141
+ | `/model` | Open the per-provider model picker (type-filter + live `/v1/models` fetch) |
142
+ | `/model X` | Switch model directly. Accepts unified `provider/model` form |
93
143
  | `/skill a,b` | Replace the system prompt with a composition of named skills |
94
144
  | `/usage` | Message count + chars + cumulative token totals |
95
145
  | `/new` / `/reset` | Wipe history and start over |
96
- | `/exit` | Quit |
146
+ | `/exit` | Leave the chat REPL (returns to the launcher when chat was opened from it) |
97
147
 
98
148
  **Cursor-style ghost autocomplete**: type `/` and the longest matching slash command appears in dim grey after the cursor. **`→`** accepts; **`Tab`** cycles. **Ctrl-C** during a streaming reply aborts that turn (not the whole process); **Ctrl-C** at an empty prompt exits.
99
149
 
package/cli.mjs CHANGED
@@ -33,19 +33,26 @@ function writeConfig(cfg) {
33
33
  // without forcing the dynamic import on every hot-path call.
34
34
  // 1. cfg.authProfiles[provider] active label, if set
35
35
  // 2. first profile in the array
36
- // 3. legacy single `cfg["api-key"]` (pre-v3.93 configs)
36
+ // 3. customProviders[<provider>].apiKey (custom OpenAI-compat entries)
37
+ // 4. PROVIDER_INFO[<provider>].envKey / altEnvKeys env var (built-in
38
+ // OpenAI-compat: nim → NVIDIA_API_KEY, openrouter → OPENROUTER_API_KEY, …)
39
+ // 5. legacy single `cfg["api-key"]` (pre-v3.93 configs)
37
40
  function _resolveAuthKey(cfg, provider) {
38
41
  const arr = (cfg.authProfiles || {})[provider] || [];
39
42
  const active = (cfg.authActiveProfile || {})[provider];
40
43
  const hit = arr.find((p) => p && p.label === active) || arr[0];
41
44
  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
45
  const custom = Array.isArray(cfg.customProviders)
46
46
  ? cfg.customProviders.find((p) => p && p.name === provider)
47
47
  : null;
48
48
  if (custom?.apiKey) return custom.apiKey;
49
+ // Built-in OpenAI-compat env var fallback. Skipped silently when the
50
+ // registry module isn't loaded yet (every chat / agent path calls
51
+ // ensureRegistry() before _resolveAuthKey, so this is just defence-in-depth).
52
+ if (_registryMod && typeof _registryMod.resolveBuiltinEnvKey === 'function') {
53
+ const envHit = _registryMod.resolveBuiltinEnvKey(provider);
54
+ if (envHit) return envHit;
55
+ }
49
56
  return cfg['api-key'] || '';
50
57
  }
51
58
 
@@ -1724,12 +1731,19 @@ async function _pickProviderInteractive() {
1724
1731
  while (!family) {
1725
1732
  const familyItems = Object.entries(families)
1726
1733
  .filter(([, b]) => b.members.length > 0)
1727
- .map(([id, b]) => ({
1728
- id,
1729
- label: b.label,
1730
- desc: `${b.desc} · ${b.members.join(' / ')}`,
1731
- tag: b.tag,
1732
- }));
1734
+ .map(([id, b]) => {
1735
+ // Show member count + a few names instead of the full list — the
1736
+ // API-key family alone now has 12 vendors and joining all of them
1737
+ // produced an unreadable line.
1738
+ const preview = b.members.slice(0, 3).join(' / ');
1739
+ const more = b.members.length > 3 ? ` … (+${b.members.length - 3} more)` : '';
1740
+ return {
1741
+ id,
1742
+ label: b.label,
1743
+ desc: `${b.desc} · ${preview}${more}`,
1744
+ tag: b.tag,
1745
+ };
1746
+ });
1733
1747
  const picked = await _arrowMenu({
1734
1748
  title: 'LazyClaw setup — Step 1 of 3: pick how you want to auth',
1735
1749
  subtitle: 'API: bring your own key · CLI/Local: use what\'s already on this machine · Mock: offline test',
@@ -1745,14 +1759,21 @@ async function _pickProviderInteractive() {
1745
1759
  const memberNames = families[family.id].members;
1746
1760
  const provItems = memberNames.map((name) => {
1747
1761
  const meta = info[name] || {};
1748
- const models = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(default)';
1749
1762
  const isCustom = !!meta.custom;
1763
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
1764
+ // Step-2 desc used to preview four suggested model ids per provider.
1765
+ // That made the row read like "gemini · models: gemini-2.5-pro ·
1766
+ // gemini-2.5-flash · gemini-2.0-flash · gemini-2.0-flash-thinking-exp",
1767
+ // which is too dense and partly redundant — step 3 already shows the
1768
+ // full curated list. Keep the row to a vendor label + endpoint hint.
1769
+ let desc = '';
1770
+ if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
1771
+ else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
1772
+ else if (meta.label && meta.label !== name) desc = meta.label;
1750
1773
  return {
1751
1774
  id: name,
1752
1775
  label: name,
1753
- desc: isCustom
1754
- ? `custom · ${meta.baseUrl || ''}`
1755
- : `models: ${models}`,
1776
+ desc,
1756
1777
  tag: isCustom
1757
1778
  ? '\x1b[38;5;213m[custom]\x1b[0m'
1758
1779
  : (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
@@ -1798,17 +1819,58 @@ async function _pickProviderInteractive() {
1798
1819
  }
1799
1820
 
1800
1821
  // ── Step 3 — model ────────────────────────────────────────────
1801
- const meta = info[provider.id] || {};
1822
+ const picked = await _pickModelInteractive(provider.id, {
1823
+ titlePrefix: 'LazyClaw setup — Step 3 of 3:',
1824
+ onBack: 'restart',
1825
+ });
1826
+ if (picked === 'CANCEL') return null;
1827
+ if (picked === 'BACK') return _pickProviderInteractive();
1828
+ return { provider: provider.id, model: picked };
1829
+ }
1830
+
1831
+ // Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
1832
+ // (provider / model arrow menu) takes over the terminal. The sub-picker
1833
+ // installs its own `keypress` listener and toggles raw mode; the chat's
1834
+ // readline would race it for stdin if we left it active. After `body`
1835
+ // returns we re-emit keypress events, restore raw mode, and re-prompt
1836
+ // so the chat resumes cleanly. `body` is awaited — exceptions propagate.
1837
+ async function _pauseChatForSubMenu(rl, ghost, body) {
1838
+ if (ghost && typeof ghost.suspend === 'function') ghost.suspend();
1839
+ try { rl.pause(); } catch (_) {}
1840
+ // Drop the readline keypress hook so the picker's own listener has
1841
+ // sole ownership while it's open. We re-arm it on the way out.
1842
+ if (process.stdin.setRawMode) {
1843
+ try { process.stdin.setRawMode(false); } catch (_) {}
1844
+ }
1845
+ try {
1846
+ await body();
1847
+ } finally {
1848
+ const readline = await import('node:readline');
1849
+ try { readline.emitKeypressEvents(process.stdin); } catch (_) {}
1850
+ if (process.stdin.setRawMode && process.stdin.isTTY) {
1851
+ try { process.stdin.setRawMode(false); } catch (_) {}
1852
+ }
1853
+ process.stdin.resume();
1854
+ if (process.stdin.ref) process.stdin.ref();
1855
+ if (ghost && typeof ghost.resume === 'function') ghost.resume();
1856
+ try { rl.resume(); } catch (_) {}
1857
+ try { rl.prompt(); } catch (_) {}
1858
+ }
1859
+ }
1860
+
1861
+ // Standalone model picker for the chat REPL's `/model` slash. Returns
1862
+ // the chosen model id (string), 'BACK', or 'CANCEL'. Falls through to
1863
+ // null when the provider has no curated models and no live-fetch surface
1864
+ // (mock) — the caller should treat that as "use the provider default".
1865
+ async function _pickModelInteractive(providerId, opts = {}) {
1866
+ const info = _registryMod.PROVIDER_INFO || {};
1867
+ const meta = info[providerId] || {};
1802
1868
  const baseModels = Array.isArray(meta.suggestedModels) ? meta.suggestedModels.slice() : [];
1803
1869
  const isCustom = !!meta.custom;
1804
- const supportsLiveFetch = !!meta.baseUrl || provider.id === 'openai' || provider.id === 'ollama';
1870
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
1871
+ const supportsLiveFetch = !!meta.baseUrl || providerId === 'openai' || providerId === 'ollama' || isBuiltinCompat;
1805
1872
 
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.
1810
- return { provider: provider.id, model: null };
1811
- }
1873
+ if (!baseModels.length && !supportsLiveFetch) return null;
1812
1874
 
1813
1875
  let dynamicModels = [];
1814
1876
  while (true) {
@@ -1818,7 +1880,7 @@ async function _pickProviderInteractive() {
1818
1880
  modelItems.unshift({
1819
1881
  id: '__fetch_models__',
1820
1882
  label: '↻ Fetch live model list from /v1/models',
1821
- desc: isCustom ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
1883
+ desc: isCustom || isBuiltinCompat ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
1822
1884
  tag: '\x1b[38;5;245m[live]\x1b[0m',
1823
1885
  });
1824
1886
  }
@@ -1832,24 +1894,25 @@ async function _pickProviderInteractive() {
1832
1894
  const defaultIdx = supportsLiveFetch
1833
1895
  ? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
1834
1896
  : Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
1897
+ const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
1835
1898
  const picked = await _arrowMenu({
1836
- title: `LazyClaw setup — Step 3 of 3: pick a model for ${provider.id}`,
1899
+ title: `${titlePrefix}pick a model for ${providerId}`,
1837
1900
  subtitle: `Type to filter ${allModels.length} model(s). Enter to confirm. Backspace clears one char, Ctrl+U clears the filter.`,
1838
1901
  items: modelItems,
1839
1902
  defaultIdx,
1840
1903
  searchable: true,
1841
1904
  });
1842
- if (picked === 'CANCEL') return null;
1843
- if (picked === 'BACK') return _pickProviderInteractive(); // back to step 1
1905
+ if (picked === 'CANCEL') return 'CANCEL';
1906
+ if (picked === 'BACK') return 'BACK';
1844
1907
  if (picked.id === '__custom_model__') {
1845
- const typed = (await _quickPrompt(` model id for ${provider.id}: `)).trim();
1908
+ const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
1846
1909
  if (!typed) continue;
1847
- return { provider: provider.id, model: typed };
1910
+ return typed;
1848
1911
  }
1849
1912
  if (picked.id === '__fetch_models__') {
1850
1913
  try {
1851
- process.stdout.write(`\n fetching ${provider.id} model list…\n`);
1852
- const fetched = await _fetchModelsForProvider(provider.id);
1914
+ process.stdout.write(`\n fetching ${providerId} model list…\n`);
1915
+ const fetched = await _fetchModelsForProvider(providerId);
1853
1916
  if (!fetched.length) {
1854
1917
  process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
1855
1918
  await _quickPrompt(' press Enter to continue ');
@@ -1864,7 +1927,7 @@ async function _pickProviderInteractive() {
1864
1927
  }
1865
1928
  continue;
1866
1929
  }
1867
- return { provider: provider.id, model: picked.id };
1930
+ return picked.id;
1868
1931
  }
1869
1932
  }
1870
1933
 
@@ -1878,6 +1941,12 @@ function _modelCatalogueFor(providerId) {
1878
1941
  const entry = (cfg.customProviders || []).find((p) => p && p.name === providerId) || {};
1879
1942
  return { baseUrl: meta.baseUrl, apiKey: entry.apiKey || cfg['api-key'] || '' };
1880
1943
  }
1944
+ // Built-in OpenAI-compatible vendors (nim / openrouter / groq / together /
1945
+ // xai / deepseek / mistral / fireworks). The registry exposes a baseUrl
1946
+ // and the auth-key resolver already knows about the env-var fallback.
1947
+ if (meta.builtinOpenAICompat && meta.baseUrl) {
1948
+ return { baseUrl: meta.baseUrl, apiKey: _resolveAuthKey(cfg, providerId) };
1949
+ }
1881
1950
  if (providerId === 'openai') {
1882
1951
  return { baseUrl: 'https://api.openai.com/v1', apiKey: _resolveAuthKey(cfg, 'openai') };
1883
1952
  }
@@ -1915,7 +1984,7 @@ async function _addCustomProviderInteractive() {
1915
1984
  process.stdout.write(dim(' · Groq https://api.groq.com/openai/v1') + '\n');
1916
1985
  process.stdout.write(dim(' · vLLM / LM Studio http://localhost:8000/v1') + '\n\n');
1917
1986
 
1918
- const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels } = _registryMod;
1987
+ const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels, isBuiltinOpenAICompatName } = _registryMod;
1919
1988
  let name;
1920
1989
  while (true) {
1921
1990
  const raw = (await _quickPrompt(` ${bold('name')} ${dim('(short id, e.g. "nim", "openrouter"):')} `)).trim();
@@ -1923,8 +1992,24 @@ async function _addCustomProviderInteractive() {
1923
1992
  process.stdout.write(dim(' cancelled — back to the picker.\n'));
1924
1993
  return null;
1925
1994
  }
1926
- try { name = validateCustomProviderName(raw); break; }
1927
- catch (e) { process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`); }
1995
+ try { name = validateCustomProviderName(raw); }
1996
+ catch (e) {
1997
+ process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
1998
+ continue;
1999
+ }
2000
+ // OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
2001
+ // by a custom entry of the same name — both go through
2002
+ // makeOpenAICompatProvider, so the wire format is identical and the
2003
+ // user is just pointing the same alias at a different URL/key. Surface
2004
+ // the override so it isn't a silent surprise.
2005
+ if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
2006
+ process.stdout.write(
2007
+ ` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
2008
+ `your custom entry will override the built-in baseUrl/api-key for this install. ` +
2009
+ `Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
2010
+ );
2011
+ }
2012
+ break;
1928
2013
  }
1929
2014
  const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
1930
2015
  if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
@@ -2133,10 +2218,28 @@ async function cmdChat(flags = {}) {
2133
2218
  // `/provider <name>` switches the active provider for subsequent
2134
2219
  // turns. The conversation history stays put — the next user
2135
2220
  // message goes to the new provider with the existing context.
2136
- // `/provider` (no arg) prints the current name.
2221
+ // `/provider` (no arg) opens the family/provider/model picker so
2222
+ // the user can switch with arrow keys instead of memorising names.
2137
2223
  const arg = line.slice('/provider'.length).trim();
2138
2224
  if (!arg) {
2139
- process.stdout.write(`provider: ${activeProvName}\n`);
2225
+ if (!useTerminal) {
2226
+ process.stdout.write(`provider: ${activeProvName}\n`);
2227
+ return true;
2228
+ }
2229
+ await _pauseChatForSubMenu(rl, _ghost, async () => {
2230
+ const picked = await _pickProviderInteractive();
2231
+ if (picked && picked.provider) {
2232
+ const next = lookupProv(picked.provider);
2233
+ if (!next) {
2234
+ process.stdout.write(`unknown provider: ${picked.provider}\n`);
2235
+ return;
2236
+ }
2237
+ activeProvName = picked.provider;
2238
+ prov = next;
2239
+ if (picked.model) activeModel = picked.model;
2240
+ process.stdout.write(`provider → ${activeProvName}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
2241
+ }
2242
+ });
2140
2243
  return true;
2141
2244
  }
2142
2245
  const next = lookupProv(arg);
@@ -2151,10 +2254,20 @@ async function cmdChat(flags = {}) {
2151
2254
  }
2152
2255
  case '/model': {
2153
2256
  // `/model <name>` updates the active model without touching the
2154
- // provider. `/model` (no arg) prints the current value.
2257
+ // provider. `/model` (no arg) opens the per-provider model picker
2258
+ // — same UX as setup step 3, scoped to the active provider.
2155
2259
  const arg = line.slice('/model'.length).trim();
2156
2260
  if (!arg) {
2157
- process.stdout.write(`model: ${activeModel || '(default)'}\n`);
2261
+ if (!useTerminal) {
2262
+ process.stdout.write(`model: ${activeModel || '(default)'}\n`);
2263
+ return true;
2264
+ }
2265
+ await _pauseChatForSubMenu(rl, _ghost, async () => {
2266
+ const chosen = await _pickModelInteractive(activeProvName, { titlePrefix: 'LazyClaw chat —' });
2267
+ if (chosen === 'CANCEL' || chosen === 'BACK' || !chosen) return;
2268
+ activeModel = chosen;
2269
+ process.stdout.write(`model → ${activeModel}\n`);
2270
+ });
2158
2271
  return true;
2159
2272
  }
2160
2273
  // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
@@ -2259,18 +2372,12 @@ async function cmdChat(flags = {}) {
2259
2372
  }
2260
2373
  };
2261
2374
 
2262
- // Tracks whether the loop ended because the user explicitly typed
2263
- // /exit (vs. natural EOF / Ctrl-D). When set, cmdChat returns the
2264
- // 'LAZYCLAW_EXIT' sentinel so cmdLauncher knows to break its outer
2265
- // menu loop instead of redrawing — /exit means "leave lazyclaw",
2266
- // not "leave just this chat REPL and bounce back to the menu."
2267
- let userRequestedExit = false;
2268
2375
  try { for await (const line of rl) {
2269
2376
  const text = line.trim();
2270
2377
  if (!text) { if (useTerminal) rl.prompt(); continue; }
2271
2378
  if (text.startsWith('/')) {
2272
2379
  const r = await handleSlash(text);
2273
- if (r === 'EXIT') { userRequestedExit = true; break; }
2380
+ if (r === 'EXIT') break;
2274
2381
  if (useTerminal) rl.prompt();
2275
2382
  continue;
2276
2383
  }
@@ -2365,11 +2472,6 @@ async function cmdChat(flags = {}) {
2365
2472
  try { process.stdin.pause(); } catch (_) {}
2366
2473
  try { process.stdin.unref(); } catch (_) {}
2367
2474
  }
2368
- // Sentinel — picked up by cmdLauncher's dispatch loop. Returning
2369
- // anything else (undefined / EOF) leaves the launcher free to
2370
- // redraw its menu, which is the right behavior for natural stream
2371
- // exhaustion (e.g. a script piping prompts on stdin).
2372
- if (userRequestedExit) return 'LAZYCLAW_EXIT';
2373
2475
  }
2374
2476
 
2375
2477
  // Light wrapper around the daemon — meant for users who installed
@@ -4095,7 +4197,7 @@ async function cmdLauncher() {
4095
4197
  process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
4096
4198
  process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
4097
4199
  process.stdout.write('\n');
4098
- process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
4200
+ process.stdout.write(` ${dim('↑/↓ to move · Enter to select · / for slash command (e.g. /exit) · q or Esc to quit')}\n\n`);
4099
4201
  const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
4100
4202
  const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
4101
4203
  const toIdx = Math.min(items.length, fromIdx + rowsAvail);
@@ -4108,10 +4210,74 @@ async function cmdLauncher() {
4108
4210
  process.stdout.write('\n');
4109
4211
  };
4110
4212
 
4213
+ // Slash-command mini prompt rendered just below the menu. Lets users
4214
+ // type `/exit` / `/quit` / `/help` to leave (or get a list of slash
4215
+ // commands) without hunting for the right special key. The menu is
4216
+ // raw-mode and never sees a newline-terminated line, so we accumulate
4217
+ // keystrokes locally instead of round-tripping through readline.
4218
+ let slashBuffer = null; // null = menu mode; string = slash mode (always starts with '/')
4219
+ let slashNotice = ''; // one-line hint shown after the buffer (e.g. "unknown command")
4220
+ const LAUNCHER_SLASH_HELP = [
4221
+ { cmd: '/exit', help: 'leave lazyclaw' },
4222
+ { cmd: '/quit', help: 'alias for /exit' },
4223
+ { cmd: '/help', help: 'list slash commands' },
4224
+ { cmd: '/version', help: 'print version + node + platform' },
4225
+ ];
4226
+ const drawWithSlash = () => {
4227
+ draw();
4228
+ process.stdout.write(` ${dim('slash ›')} ${slashBuffer}`);
4229
+ if (slashNotice) process.stdout.write(` ${slashNotice}`);
4230
+ process.stdout.write('\x1b[?25h'); // show cursor while typing
4231
+ };
4232
+
4111
4233
  draw();
4112
4234
  const picked = await new Promise((resolve) => {
4113
- const onKey = (_str, key) => {
4235
+ const onKey = (str, key) => {
4114
4236
  if (!key) return;
4237
+
4238
+ // ── Slash-command input mode ─────────────────────────────────
4239
+ if (slashBuffer !== null) {
4240
+ if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); return; }
4241
+ if (key.name === 'escape') { slashBuffer = null; slashNotice = ''; draw(); return; }
4242
+ if (key.name === 'return') {
4243
+ const cmd = slashBuffer.trim().toLowerCase();
4244
+ if (cmd === '/exit' || cmd === '/quit') { cleanup(); resolve({ id: 'quit', argv: null }); return; }
4245
+ if (cmd === '/help') {
4246
+ slashBuffer = '/';
4247
+ slashNotice = dim(LAUNCHER_SLASH_HELP.map(c => `${c.cmd} (${c.help})`).join(' · '));
4248
+ drawWithSlash();
4249
+ return;
4250
+ }
4251
+ if (cmd === '/version') {
4252
+ const v = readVersionFromRepo();
4253
+ slashNotice = ok(`v${v} · node ${process.version} · ${process.platform}-${process.arch}`);
4254
+ drawWithSlash();
4255
+ return;
4256
+ }
4257
+ // Unknown command — keep the buffer so the user can edit it
4258
+ // rather than retyping from scratch. Esc / Backspace bails.
4259
+ slashNotice = warn(`unknown — try ${LAUNCHER_SLASH_HELP.map(c => c.cmd).join(' · ')}`);
4260
+ drawWithSlash();
4261
+ return;
4262
+ }
4263
+ if (key.name === 'backspace') {
4264
+ slashNotice = '';
4265
+ if (slashBuffer.length > 1) slashBuffer = slashBuffer.slice(0, -1);
4266
+ else slashBuffer = null;
4267
+ slashBuffer === null ? draw() : drawWithSlash();
4268
+ return;
4269
+ }
4270
+ // Append printable characters. Filter control / meta chords so
4271
+ // Ctrl+L etc. don't pollute the buffer.
4272
+ if (str && str.length === 1 && !key.ctrl && !key.meta && str >= ' ') {
4273
+ slashBuffer += str;
4274
+ slashNotice = '';
4275
+ drawWithSlash();
4276
+ }
4277
+ return;
4278
+ }
4279
+
4280
+ // ── Menu navigation mode ─────────────────────────────────────
4115
4281
  if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
4116
4282
  else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
4117
4283
  else if (key.name === 'home') { idx = 0; draw(); }
@@ -4121,6 +4287,7 @@ async function cmdLauncher() {
4121
4287
  else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
4122
4288
  else if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); }
4123
4289
  else if (key.name === 'escape' || key.name === 'q') { cleanup(); resolve({ id: 'quit', argv: null }); }
4290
+ else if (str === '/') { slashBuffer = '/'; slashNotice = ''; drawWithSlash(); }
4124
4291
  function cleanup() {
4125
4292
  process.stdin.off('keypress', onKey);
4126
4293
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
@@ -4151,18 +4318,11 @@ async function cmdLauncher() {
4151
4318
  // Dispatch. Errors don't terminate the launcher — they're
4152
4319
  // surfaced as a stderr line and the menu redraws. Lets the
4153
4320
  // user recover from a transient API hiccup without a relaunch.
4154
- let dispatchResult;
4155
4321
  try {
4156
- dispatchResult = await _dispatchMenuChoice(argv);
4322
+ await _dispatchMenuChoice(argv);
4157
4323
  } catch (e) {
4158
4324
  process.stderr.write(`\n ${accent('✗')} ${e?.message || String(e)}\n`);
4159
4325
  }
4160
- // Subcommand asked for full lazyclaw exit (currently only chat's
4161
- // /exit). Break the launcher loop so the finally block tears
4162
- // down stdin and the process ends naturally — without this, the
4163
- // user has to /exit out of chat AND pick Quit from the menu to
4164
- // actually leave.
4165
- if (dispatchResult === 'LAZYCLAW_EXIT') return;
4166
4326
 
4167
4327
  // Pause before re-drawing so the user can read the subcommand's
4168
4328
  // output. `chat` is the special case: its REPL has already kept
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.13",
3
+ "version": "3.99.15",
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",
@@ -52,6 +52,164 @@ export const mockProvider = {
52
52
  export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
53
53
  export { makeOpenAICompatProvider, fetchOpenAICompatModels };
54
54
 
55
+ // Built-in OpenAI-compatible vendors. Same wire format → one factory call
56
+ // each. The picker treats these like first-class providers so users don't
57
+ // have to walk through "+ Add a custom endpoint" for the popular ones.
58
+ //
59
+ // Each entry must define baseUrl + envKey (the env var the chat path
60
+ // consults when no api-key is configured) + suggestedModels (curated list
61
+ // shown before the user fetches the live /v1/models catalogue).
62
+ //
63
+ // Adding a new vendor: drop a row here. The PROVIDERS / PROVIDER_INFO loops
64
+ // below pick it up automatically.
65
+ export const OPENAI_COMPAT_BUILTINS = {
66
+ nim: {
67
+ label: 'NVIDIA NIM',
68
+ baseUrl: 'https://integrate.api.nvidia.com/v1',
69
+ envKey: 'NVIDIA_API_KEY',
70
+ altEnvKeys: ['NIM_API_KEY'],
71
+ keyPrefix: 'nvapi-',
72
+ docs: 'NVIDIA NIM hosted catalogue (Llama 3.x, Nemotron, DeepSeek-R1, Mixtral, Phi-3, Qwen, etc.). Auth: NVIDIA_API_KEY env var or in-app api-key. Endpoint speaks the OpenAI v1 wire format.',
73
+ defaultModel: 'meta/llama-3.1-405b-instruct',
74
+ suggestedModels: [
75
+ 'meta/llama-3.1-405b-instruct',
76
+ 'meta/llama-3.1-70b-instruct',
77
+ 'meta/llama-3.1-8b-instruct',
78
+ 'nvidia/llama-3.1-nemotron-70b-instruct',
79
+ 'nvidia/nemotron-mini-4b-instruct',
80
+ 'nvidia/llama-3.3-nemotron-super-49b-v1',
81
+ 'mistralai/mistral-nemo-12b-instruct',
82
+ 'mistralai/mixtral-8x22b-instruct-v0.1',
83
+ 'microsoft/phi-3-medium-4k-instruct',
84
+ 'deepseek-ai/deepseek-r1',
85
+ 'qwen/qwen2.5-7b-instruct',
86
+ 'qwen/qwen2.5-coder-32b-instruct',
87
+ ],
88
+ },
89
+ openrouter: {
90
+ label: 'OpenRouter',
91
+ baseUrl: 'https://openrouter.ai/api/v1',
92
+ envKey: 'OPENROUTER_API_KEY',
93
+ keyPrefix: 'sk-or-',
94
+ docs: 'OpenRouter unified gateway — 200+ models behind one OpenAI-compatible endpoint. Auth: OPENROUTER_API_KEY env var or in-app api-key. Uses x-title/HTTP-Referer headers for attribution.',
95
+ defaultModel: 'anthropic/claude-3.5-sonnet',
96
+ headers: { 'http-referer': 'https://github.com/cmblir/lazyclaude', 'x-title': 'lazyclaw' },
97
+ suggestedModels: [
98
+ 'anthropic/claude-3.5-sonnet',
99
+ 'anthropic/claude-3-opus',
100
+ 'openai/gpt-4o',
101
+ 'openai/gpt-4o-mini',
102
+ 'openai/o1-preview',
103
+ 'meta-llama/llama-3.1-405b-instruct',
104
+ 'meta-llama/llama-3.3-70b-instruct',
105
+ 'google/gemini-2.0-flash-exp:free',
106
+ 'google/gemini-pro-1.5',
107
+ 'deepseek/deepseek-chat',
108
+ 'deepseek/deepseek-r1',
109
+ 'qwen/qwen-2.5-coder-32b-instruct',
110
+ 'mistralai/mistral-large',
111
+ ],
112
+ },
113
+ groq: {
114
+ label: 'Groq',
115
+ baseUrl: 'https://api.groq.com/openai/v1',
116
+ envKey: 'GROQ_API_KEY',
117
+ keyPrefix: 'gsk_',
118
+ docs: 'Groq LPU inference — fastest-token-per-second tier for Llama / Mixtral / Gemma. Auth: GROQ_API_KEY env var or in-app api-key.',
119
+ defaultModel: 'llama-3.3-70b-versatile',
120
+ suggestedModels: [
121
+ 'llama-3.3-70b-versatile',
122
+ 'llama-3.1-70b-versatile',
123
+ 'llama-3.1-8b-instant',
124
+ 'llama-3.2-90b-vision-preview',
125
+ 'mixtral-8x7b-32768',
126
+ 'gemma2-9b-it',
127
+ 'qwen-2.5-coder-32b',
128
+ 'qwen-2.5-32b',
129
+ 'deepseek-r1-distill-llama-70b',
130
+ ],
131
+ },
132
+ together: {
133
+ label: 'Together AI',
134
+ baseUrl: 'https://api.together.xyz/v1',
135
+ envKey: 'TOGETHER_API_KEY',
136
+ docs: 'Together AI hosted inference for open-weight models (Llama, Mixtral, Qwen, DeepSeek, etc.). Auth: TOGETHER_API_KEY env var or in-app api-key.',
137
+ defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
138
+ suggestedModels: [
139
+ 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
140
+ 'meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo',
141
+ 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo',
142
+ 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
143
+ 'mistralai/Mixtral-8x22B-Instruct-v0.1',
144
+ 'mistralai/Mixtral-8x7B-Instruct-v0.1',
145
+ 'Qwen/Qwen2.5-72B-Instruct-Turbo',
146
+ 'Qwen/Qwen2.5-Coder-32B-Instruct',
147
+ 'deepseek-ai/DeepSeek-V3',
148
+ 'deepseek-ai/DeepSeek-R1',
149
+ ],
150
+ },
151
+ xai: {
152
+ label: 'xAI (Grok)',
153
+ baseUrl: 'https://api.x.ai/v1',
154
+ envKey: 'XAI_API_KEY',
155
+ altEnvKeys: ['GROK_API_KEY'],
156
+ keyPrefix: 'xai-',
157
+ docs: 'xAI Grok models. Auth: XAI_API_KEY env var or in-app api-key.',
158
+ defaultModel: 'grok-2-latest',
159
+ suggestedModels: [
160
+ 'grok-2-latest',
161
+ 'grok-2-1212',
162
+ 'grok-2-vision-1212',
163
+ 'grok-beta',
164
+ 'grok-vision-beta',
165
+ ],
166
+ },
167
+ deepseek: {
168
+ label: 'DeepSeek',
169
+ baseUrl: 'https://api.deepseek.com/v1',
170
+ envKey: 'DEEPSEEK_API_KEY',
171
+ keyPrefix: 'sk-',
172
+ docs: 'DeepSeek (deepseek-chat / deepseek-reasoner). Auth: DEEPSEEK_API_KEY env var or in-app api-key.',
173
+ defaultModel: 'deepseek-chat',
174
+ suggestedModels: [
175
+ 'deepseek-chat',
176
+ 'deepseek-reasoner',
177
+ 'deepseek-coder',
178
+ ],
179
+ },
180
+ mistral: {
181
+ label: 'Mistral La Plateforme',
182
+ baseUrl: 'https://api.mistral.ai/v1',
183
+ envKey: 'MISTRAL_API_KEY',
184
+ docs: 'Mistral La Plateforme (mistral-large, codestral, ministral, pixtral). Auth: MISTRAL_API_KEY env var or in-app api-key.',
185
+ defaultModel: 'mistral-large-latest',
186
+ suggestedModels: [
187
+ 'mistral-large-latest',
188
+ 'mistral-small-latest',
189
+ 'codestral-latest',
190
+ 'ministral-8b-latest',
191
+ 'ministral-3b-latest',
192
+ 'pixtral-large-latest',
193
+ 'open-mistral-nemo',
194
+ ],
195
+ },
196
+ fireworks: {
197
+ label: 'Fireworks AI',
198
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
199
+ envKey: 'FIREWORKS_API_KEY',
200
+ docs: 'Fireworks AI hosted models. Auth: FIREWORKS_API_KEY env var or in-app api-key.',
201
+ defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
202
+ suggestedModels: [
203
+ 'accounts/fireworks/models/llama-v3p3-70b-instruct',
204
+ 'accounts/fireworks/models/llama-v3p1-405b-instruct',
205
+ 'accounts/fireworks/models/qwen2p5-coder-32b-instruct',
206
+ 'accounts/fireworks/models/deepseek-r1',
207
+ 'accounts/fireworks/models/deepseek-v3',
208
+ 'accounts/fireworks/models/mixtral-8x22b-instruct',
209
+ ],
210
+ },
211
+ };
212
+
55
213
  // Insertion order is the picker order. The list goes first-to-last in
56
214
  // rough "user-familiar / popular" order so a first-time onboard lands
57
215
  // the cursor on a vendor most users recognise. v3.99.5 reordered per
@@ -63,11 +221,34 @@ export const PROVIDERS = {
63
221
  // Tier 2 — Claude. CLI variant first because it's keyless.
64
222
  'claude-cli': claudeCliProvider,
65
223
  anthropic: anthropicProvider,
66
- // Tier 3 — local + dev/test.
224
+ // Tier 3 — popular OpenAI-compatible aggregators / hosted catalogues.
225
+ // Inserted by the loop below from OPENAI_COMPAT_BUILTINS so the order
226
+ // here mirrors that object's insertion order.
227
+ // Tier 4 — local + dev/test.
67
228
  ollama: ollamaProvider,
68
229
  mock: mockProvider,
69
230
  };
70
231
 
232
+ // Wire each OpenAI-compat builtin into PROVIDERS as a callable provider.
233
+ // Insertion is between Tier 2 (anthropic) and Tier 4 (ollama) by reordering
234
+ // the keys after the loop runs — JS objects honour insertion order and
235
+ // cmdLauncher's families helper relies on that for the picker.
236
+ {
237
+ const local = { ollama: PROVIDERS.ollama, mock: PROVIDERS.mock };
238
+ delete PROVIDERS.ollama;
239
+ delete PROVIDERS.mock;
240
+ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
241
+ PROVIDERS[name] = makeOpenAICompatProvider({
242
+ name,
243
+ baseUrl: def.baseUrl,
244
+ defaultModel: def.defaultModel,
245
+ headers: def.headers,
246
+ });
247
+ }
248
+ PROVIDERS.ollama = local.ollama;
249
+ PROVIDERS.mock = local.mock;
250
+ }
251
+
71
252
  // Static metadata for `lazyclaw providers list/info`. Kept next to PROVIDERS
72
253
  // so adding a provider in one place can't drift from the list shown to users.
73
254
  export const PROVIDER_INFO = {
@@ -167,6 +348,44 @@ export const PROVIDER_INFO = {
167
348
  },
168
349
  };
169
350
 
351
+ // Mirror the OpenAI-compat builtins into PROVIDER_INFO so picker / docs /
352
+ // `lazyclaw providers info` see them with the same shape as the hand-written
353
+ // entries above.
354
+ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
355
+ PROVIDER_INFO[name] = {
356
+ name,
357
+ label: def.label,
358
+ requiresApiKey: true,
359
+ keyPrefix: def.keyPrefix,
360
+ envKey: def.envKey,
361
+ altEnvKeys: Array.isArray(def.altEnvKeys) ? def.altEnvKeys.slice() : [],
362
+ docs: def.docs,
363
+ endpoint: `${def.baseUrl}/chat/completions`,
364
+ defaultModel: def.defaultModel,
365
+ suggestedModels: Array.isArray(def.suggestedModels) ? def.suggestedModels.slice() : [],
366
+ builtin: true,
367
+ builtinOpenAICompat: true,
368
+ baseUrl: def.baseUrl,
369
+ headers: def.headers,
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Resolve an api-key for a built-in OpenAI-compatible provider from the
375
+ * environment, scanning {envKey} then any {altEnvKeys}. Returns '' when
376
+ * no env var is set so the caller can fall through to its config-based
377
+ * lookup chain.
378
+ */
379
+ export function resolveBuiltinEnvKey(provider) {
380
+ const meta = PROVIDER_INFO[provider];
381
+ if (!meta || !meta.builtinOpenAICompat) return '';
382
+ const candidates = [meta.envKey, ...(meta.altEnvKeys || [])].filter(Boolean);
383
+ for (const k of candidates) {
384
+ if (process.env[k]) return process.env[k];
385
+ }
386
+ return '';
387
+ }
388
+
170
389
  /**
171
390
  * Split a unified "provider/model" string (OpenClaw style:
172
391
  * "anthropic/claude-opus-4-7"). Also accepts a bare model id and returns
@@ -194,13 +413,27 @@ export function parseProviderModel(s) {
194
413
  * @param {string|undefined|null} key
195
414
  * @returns {string}
196
415
  */
197
- // Reserved provider names — built-in providers and meta-keywords the picker
198
- // uses internally. Custom registrations must not collide with these.
416
+ // Reserved provider names — names whose factory is bespoke (not the
417
+ // generic OpenAI-compat one) so a custom registration of the same name
418
+ // would silently break the wire format. The OpenAI-compat builtins are
419
+ // deliberately NOT listed: a user can register `nim` / `openrouter` /
420
+ // etc. as a custom entry to override the baseUrl / api-key / headers,
421
+ // because both the built-in and the custom go through
422
+ // `makeOpenAICompatProvider` — overriding is well-defined.
199
423
  const RESERVED_PROVIDER_NAMES = new Set([
200
424
  'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
201
425
  '__add_custom__', '__custom_model__', '__fetch_models__',
202
426
  ]);
203
427
 
428
+ /**
429
+ * Whether the supplied name belongs to one of the OpenAI-compatible
430
+ * builtins. Used by the custom-add interactive flow so it can warn the
431
+ * user that their custom entry will shadow the built-in registration.
432
+ */
433
+ export function isBuiltinOpenAICompatName(name) {
434
+ return Object.prototype.hasOwnProperty.call(OPENAI_COMPAT_BUILTINS, String(name || '').trim().toLowerCase());
435
+ }
436
+
204
437
  /**
205
438
  * Validate a custom provider name. Allowed: lowercase alnum + dash + dot.
206
439
  * Returns the trimmed name on success; throws on collision / bad format.