rlm-cli 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/interactive.js +257 -57
  2. package/package.json +1 -1
@@ -91,16 +91,121 @@ function resolveModel(modelId) {
91
91
  }
92
92
  return undefined;
93
93
  }
94
+ // Provider → env var mapping for well-known providers
95
+ const PROVIDER_KEYS = {
96
+ anthropic: "ANTHROPIC_API_KEY",
97
+ openai: "OPENAI_API_KEY",
98
+ google: "GOOGLE_API_KEY",
99
+ "google-gemini-cli": "GOOGLE_API_KEY",
100
+ "google-vertex": "GOOGLE_VERTEX_API_KEY",
101
+ groq: "GROQ_API_KEY",
102
+ xai: "XAI_API_KEY",
103
+ mistral: "MISTRAL_API_KEY",
104
+ openrouter: "OPENROUTER_API_KEY",
105
+ huggingface: "HUGGINGFACE_API_KEY",
106
+ cerebras: "CEREBRAS_API_KEY",
107
+ };
108
+ // User-facing provider list for setup & /provider command
109
+ const SETUP_PROVIDERS = [
110
+ { name: "Anthropic", label: "Claude", env: "ANTHROPIC_API_KEY", piProvider: "anthropic" },
111
+ { name: "OpenAI", label: "GPT", env: "OPENAI_API_KEY", piProvider: "openai" },
112
+ { name: "Google", label: "Gemini", env: "GOOGLE_API_KEY", piProvider: "google" },
113
+ { name: "Groq", label: "Groq", env: "GROQ_API_KEY", piProvider: "groq" },
114
+ { name: "xAI", label: "Grok", env: "XAI_API_KEY", piProvider: "xai" },
115
+ { name: "Mistral", label: "Mistral", env: "MISTRAL_API_KEY", piProvider: "mistral" },
116
+ { name: "OpenRouter", label: "OpenRouter", env: "OPENROUTER_API_KEY", piProvider: "openrouter" },
117
+ ];
118
+ function providerEnvKey(provider) {
119
+ return PROVIDER_KEYS[provider] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
120
+ }
94
121
  function detectProvider() {
95
- if (process.env.ANTHROPIC_API_KEY)
96
- return "anthropic";
97
- if (process.env.OPENAI_API_KEY) {
98
- if (process.env.OPENAI_BASE_URL?.includes("openrouter"))
99
- return "openrouter";
100
- return "openai";
122
+ for (const provider of Object.keys(PROVIDER_KEYS)) {
123
+ if (process.env[PROVIDER_KEYS[provider]])
124
+ return provider;
101
125
  }
102
126
  return "unknown";
103
127
  }
128
+ function hasAnyApiKey() {
129
+ return detectProvider() !== "unknown";
130
+ }
131
+ /** Returns the pi-ai provider name + model for a given model ID, searching all providers.
132
+ * Prioritises SETUP_PROVIDERS so e.g. "gpt-4o" resolves to "openai" not "azure-openai-responses". */
133
+ function resolveModelWithProvider(modelId) {
134
+ // First pass: check well-known (setup) providers
135
+ const knownNames = new Set(SETUP_PROVIDERS.map((p) => p.piProvider));
136
+ for (const provider of getProviders()) {
137
+ if (!knownNames.has(provider))
138
+ continue;
139
+ for (const m of getModels(provider)) {
140
+ if (m.id === modelId)
141
+ return { model: m, provider };
142
+ }
143
+ }
144
+ // Second pass: all remaining providers
145
+ for (const provider of getProviders()) {
146
+ if (knownNames.has(provider))
147
+ continue;
148
+ for (const m of getModels(provider)) {
149
+ if (m.id === modelId)
150
+ return { model: m, provider };
151
+ }
152
+ }
153
+ return undefined;
154
+ }
155
+ /** Returns the first model ID from a given pi-ai provider. */
156
+ function getDefaultModelForProvider(provider) {
157
+ const models = getModels(provider);
158
+ return models.length > 0 ? models[0].id : undefined;
159
+ }
160
+ /** Wrap rl.question with ESC-to-cancel. Returns user input or null on ESC/empty. */
161
+ function questionWithEsc(rlInstance, promptText) {
162
+ return new Promise((resolve) => {
163
+ let escaped = false;
164
+ const onKeypress = (_str, key) => {
165
+ if (key?.name === "escape" && !escaped) {
166
+ escaped = true;
167
+ stdin.removeListener("keypress", onKeypress);
168
+ // Clear the prompt line visually
169
+ process.stdout.write("\r\x1b[2K");
170
+ // Programmatically submit empty to close the pending question
171
+ rlInstance.write("\n");
172
+ }
173
+ };
174
+ stdin.on("keypress", onKeypress);
175
+ rlInstance.question(promptText, (answer) => {
176
+ stdin.removeListener("keypress", onKeypress);
177
+ resolve(escaped ? null : answer.trim() || null);
178
+ });
179
+ });
180
+ }
181
+ /** Prompt user for a provider's API key if not already set.
182
+ * Returns true (got key), false (empty input), or null (ESC pressed). */
183
+ async function promptForProviderKey(rlInstance, providerInfo) {
184
+ if (process.env[providerInfo.env])
185
+ return true;
186
+ const key = await questionWithEsc(rlInstance, ` ${c.cyan}${providerInfo.env}:${c.reset} `);
187
+ if (key === null)
188
+ return null; // ESC
189
+ if (!key)
190
+ return false; // empty
191
+ process.env[providerInfo.env] = key;
192
+ // Save to shell profile
193
+ const shellRc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
194
+ const rcPath = shellRc.replace("~", process.env.HOME || "~");
195
+ try {
196
+ fs.appendFileSync(rcPath, `\nexport ${providerInfo.env}=${key}\n`);
197
+ console.log(`\n ${c.green}✓${c.reset} ${providerInfo.name} key saved to ${c.dim}${shellRc}${c.reset}`);
198
+ }
199
+ catch {
200
+ console.log(`\n ${c.yellow}!${c.reset} Could not write to ${shellRc}. Add manually:`);
201
+ console.log(` ${c.yellow}export ${providerInfo.env}=${key}${c.reset}`);
202
+ }
203
+ return true;
204
+ }
205
+ /** Find the SETUP_PROVIDERS entry that owns a given pi-ai provider name. */
206
+ function findSetupProvider(piProvider) {
207
+ return SETUP_PROVIDERS.find((p) => p.piProvider === piProvider);
208
+ }
104
209
  // ── Paste detection ─────────────────────────────────────────────────────────
105
210
  function isMultiLineInput(input) {
106
211
  return input.includes("\n");
@@ -160,8 +265,9 @@ ${c.bold}Context${c.reset}
160
265
  ${c.cyan}/clear-context${c.reset} Unload context
161
266
 
162
267
  ${c.bold}Model${c.reset}
163
- ${c.cyan}/model${c.reset} List available models
268
+ ${c.cyan}/model${c.reset} List models for current provider
164
269
  ${c.cyan}/model${c.reset} <#|id> Switch model by number or ID
270
+ ${c.cyan}/provider${c.reset} Switch provider
165
271
 
166
272
  ${c.bold}Tools${c.reset}
167
273
  ${c.cyan}/trajectories${c.reset} List saved runs
@@ -362,8 +468,20 @@ function displaySubQueryResult(info) {
362
468
  function getAvailableModels() {
363
469
  const items = [];
364
470
  for (const provider of getProviders()) {
365
- const providerKey = `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
366
- if (!process.env[providerKey] && provider !== detectProvider())
471
+ const key = providerEnvKey(provider);
472
+ if (!process.env[key] && provider !== detectProvider())
473
+ continue;
474
+ for (const m of getModels(provider)) {
475
+ items.push({ id: m.id, provider });
476
+ }
477
+ }
478
+ return items;
479
+ }
480
+ /** Get models for a specific provider (matching by pi-ai provider name or SETUP_PROVIDERS piProvider). */
481
+ function getModelsForProvider(providerName) {
482
+ const items = [];
483
+ for (const provider of getProviders()) {
484
+ if (provider !== providerName)
367
485
  continue;
368
486
  for (const m of getModels(provider)) {
369
487
  items.push({ id: m.id, provider });
@@ -611,44 +729,51 @@ async function detectAndLoadUrl(input) {
611
729
  // ── Main interactive loop ───────────────────────────────────────────────────
612
730
  async function interactive() {
613
731
  // Validate env
614
- const hasApiKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
615
- if (!hasApiKey) {
732
+ if (!hasAnyApiKey()) {
616
733
  printBanner();
617
734
  console.log(` ${c.red}No API key found.${c.reset}\n`);
618
- console.log(` ${c.bold}Paste your API key to get started:${c.reset}\n`);
619
735
  const setupRl = readline.createInterface({ input: stdin, output: stdout, terminal: true });
620
- const key = await new Promise((resolve) => {
621
- setupRl.question(` ${c.cyan}API key:${c.reset} `, (answer) => {
736
+ let setupDone = false;
737
+ while (!setupDone) {
738
+ console.log(` ${c.bold}Select your provider:${c.reset}\n`);
739
+ for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
740
+ console.log(` ${c.dim}${i + 1}${c.reset} ${SETUP_PROVIDERS[i].name} ${c.dim}(${SETUP_PROVIDERS[i].label})${c.reset}`);
741
+ }
742
+ console.log();
743
+ const choice = await questionWithEsc(setupRl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} `);
744
+ if (choice === null) {
745
+ // ESC at provider selection → exit
746
+ console.log(`\n ${c.dim}Exiting.${c.reset}\n`);
622
747
  setupRl.close();
623
- resolve(answer.trim());
624
- });
625
- });
626
- if (!key) {
627
- console.log(`\n ${c.dim}No key provided. Exiting.${c.reset}\n`);
628
- process.exit(0);
629
- }
630
- // Detect provider from key format
631
- let envVar;
632
- if (key.startsWith("sk-ant-")) {
633
- envVar = "ANTHROPIC_API_KEY";
634
- process.env.ANTHROPIC_API_KEY = key;
635
- }
636
- else {
637
- envVar = "OPENAI_API_KEY";
638
- process.env.OPENAI_API_KEY = key;
639
- }
640
- // Save to shell profile
641
- const shellRc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
642
- const rcPath = shellRc.replace("~", process.env.HOME || "~");
643
- try {
644
- fs.appendFileSync(rcPath, `\nexport ${envVar}=${key}\n`);
645
- console.log(`\n ${c.green}✓${c.reset} Key saved to ${c.dim}${shellRc}${c.reset}`);
646
- }
647
- catch {
648
- console.log(`\n ${c.yellow}!${c.reset} Could not write to ${shellRc}. Add manually:`);
649
- console.log(` ${c.yellow}export ${envVar}=${key}${c.reset}`);
748
+ process.exit(0);
749
+ }
750
+ const idx = parseInt(choice, 10) - 1;
751
+ if (isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
752
+ console.log(`\n ${c.dim}Invalid choice.${c.reset}\n`);
753
+ continue;
754
+ }
755
+ const provider = SETUP_PROVIDERS[idx];
756
+ const gotKey = await promptForProviderKey(setupRl, provider);
757
+ if (gotKey === null) {
758
+ // ESC at key entry → back to provider selection
759
+ console.log();
760
+ continue;
761
+ }
762
+ if (!gotKey) {
763
+ console.log(`\n ${c.dim}No key provided. Exiting.${c.reset}\n`);
764
+ setupRl.close();
765
+ process.exit(0);
766
+ }
767
+ // Auto-select default model for chosen provider
768
+ const defaultModel = getDefaultModelForProvider(provider.piProvider);
769
+ if (defaultModel) {
770
+ currentModelId = defaultModel;
771
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
772
+ }
773
+ console.log();
774
+ setupDone = true;
650
775
  }
651
- console.log();
776
+ setupRl.close();
652
777
  }
653
778
  // Resolve model
654
779
  currentModel = resolveModel(currentModelId);
@@ -743,27 +868,55 @@ async function interactive() {
743
868
  break;
744
869
  case "model":
745
870
  case "m": {
746
- const available = getAvailableModels();
871
+ const curProvider = detectProvider();
747
872
  if (arg) {
748
- // Accept a number or a model ID
749
- const pick = /^\d+$/.test(arg) ? available[parseInt(arg, 10) - 1]?.id : arg;
750
- const newModel = pick ? resolveModel(pick) : undefined;
751
- if (newModel && pick) {
752
- currentModelId = pick;
753
- currentModel = newModel;
754
- console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${currentModelId}${c.reset}`);
755
- console.log();
756
- printStatusLine();
873
+ // Accept a number (from current provider list) or a model ID
874
+ const curModels = getModelsForProvider(curProvider);
875
+ let pick;
876
+ if (/^\d+$/.test(arg)) {
877
+ pick = curModels[parseInt(arg, 10) - 1]?.id;
757
878
  }
758
879
  else {
880
+ pick = arg;
881
+ }
882
+ if (!pick) {
883
+ console.log(` ${c.red}Invalid selection.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
884
+ break;
885
+ }
886
+ // Check if this model belongs to a different provider
887
+ const resolved = resolveModelWithProvider(pick);
888
+ if (!resolved) {
759
889
  console.log(` ${c.red}Model "${arg}" not found.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
890
+ break;
760
891
  }
892
+ if (resolved.provider !== curProvider) {
893
+ // Cross-provider switch
894
+ const setupInfo = findSetupProvider(resolved.provider);
895
+ const envVar = setupInfo?.env || providerEnvKey(resolved.provider);
896
+ const provName = setupInfo?.name || resolved.provider;
897
+ if (!process.env[envVar]) {
898
+ console.log(` ${c.yellow}That model requires ${provName}.${c.reset}`);
899
+ const gotKey = await promptForProviderKey(rl, { name: provName, env: envVar });
900
+ if (!gotKey) {
901
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
902
+ break;
903
+ }
904
+ }
905
+ }
906
+ currentModelId = pick;
907
+ currentModel = resolved.model;
908
+ console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${currentModelId}${c.reset}`);
909
+ console.log();
910
+ printStatusLine();
761
911
  }
762
912
  else {
763
- console.log(`\n ${c.bold}Current model:${c.reset} ${c.cyan}${currentModelId}${c.reset}\n`);
764
- const pad = String(available.length).length;
765
- for (let i = 0; i < available.length; i++) {
766
- const m = available[i];
913
+ // List models for current provider
914
+ const models = getModelsForProvider(curProvider);
915
+ const provLabel = findSetupProvider(curProvider)?.name || curProvider;
916
+ console.log(`\n ${c.bold}Current model:${c.reset} ${c.cyan}${currentModelId}${c.reset} ${c.dim}(${provLabel})${c.reset}\n`);
917
+ const pad = String(models.length).length;
918
+ for (let i = 0; i < models.length; i++) {
919
+ const m = models[i];
767
920
  const num = String(i + 1).padStart(pad);
768
921
  const dot = m.id === currentModelId ? `${c.green}●${c.reset}` : ` `;
769
922
  const label = m.id === currentModelId
@@ -771,8 +924,55 @@ async function interactive() {
771
924
  : `${c.dim}${m.id}${c.reset}`;
772
925
  console.log(` ${c.dim}${num}${c.reset} ${dot} ${label}`);
773
926
  }
774
- console.log(`\n ${c.dim}${available.length} models · scroll up to see full list.${c.reset}`);
927
+ console.log(`\n ${c.dim}${models.length} models · scroll up to see full list.${c.reset}`);
775
928
  console.log(` ${c.dim}Type${c.reset} ${c.cyan}/model <number>${c.reset} ${c.dim}or${c.reset} ${c.cyan}/model <id>${c.reset} ${c.dim}to switch.${c.reset}`);
929
+ console.log(` ${c.dim}Type${c.reset} ${c.cyan}/provider${c.reset} ${c.dim}to switch provider.${c.reset}`);
930
+ }
931
+ break;
932
+ }
933
+ case "provider":
934
+ case "prov": {
935
+ const curProvider = detectProvider();
936
+ const curLabel = findSetupProvider(curProvider)?.name || curProvider;
937
+ console.log(`\n ${c.bold}Current provider:${c.reset} ${c.cyan}${curLabel}${c.reset}\n`);
938
+ for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
939
+ const p = SETUP_PROVIDERS[i];
940
+ const isCurrent = p.piProvider === curProvider;
941
+ const dot = isCurrent ? `${c.green}●${c.reset}` : ` `;
942
+ // Only show ✓ for non-current providers that have a key
943
+ const hasKey = !isCurrent && process.env[p.env] ? `${c.green}✓${c.reset}` : ` `;
944
+ const label = isCurrent
945
+ ? `${c.cyan}${p.name}${c.reset} ${c.dim}(${p.label})${c.reset}`
946
+ : `${p.name} ${c.dim}(${p.label})${c.reset}`;
947
+ console.log(` ${c.dim}${i + 1}${c.reset} ${dot}${hasKey} ${label}`);
948
+ }
949
+ console.log();
950
+ const provChoice = await questionWithEsc(rl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
951
+ if (provChoice === null)
952
+ break; // ESC
953
+ const idx = parseInt(provChoice, 10) - 1;
954
+ if (isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
955
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
956
+ break;
957
+ }
958
+ const chosen = SETUP_PROVIDERS[idx];
959
+ const gotKey = await promptForProviderKey(rl, chosen);
960
+ if (!gotKey) {
961
+ // null (ESC) or false (empty) → cancel
962
+ break;
963
+ }
964
+ // Auto-select first model from new provider
965
+ const defaultModel = getDefaultModelForProvider(chosen.piProvider);
966
+ if (defaultModel) {
967
+ currentModelId = defaultModel;
968
+ currentModel = resolveModel(currentModelId);
969
+ console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${chosen.name}${c.reset}`);
970
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
971
+ console.log();
972
+ printStatusLine();
973
+ }
974
+ else {
975
+ console.log(` ${c.red}No models available for ${chosen.name}.${c.reset}`);
776
976
  }
777
977
  break;
778
978
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlm-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Standalone CLI for Recursive Language Models (RLMs) — implements Algorithm 1 from arXiv:2512.24601",
5
5
  "type": "module",
6
6
  "bin": {