rlm-cli 0.2.9 → 0.2.11

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/dist/env.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Load .env file into process.env.
2
+ * Load env vars into process.env.
3
3
  * Must be imported BEFORE any module that reads env vars (e.g. pi-ai).
4
4
  *
5
- * Supports:
6
- * - ANTHROPIC_API_KEY
7
- * - RLM_MODEL (model name, e.g. claude-sonnet-4-5-20250929)
5
+ * Load order (later wins):
6
+ * 1. ~/.rlm/credentials — persistent keys saved by first-run setup
7
+ * 2. .env in package root local overrides
8
8
  */
9
9
  export {};
package/dist/env.js CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
- * Load .env file into process.env.
2
+ * Load env vars into process.env.
3
3
  * Must be imported BEFORE any module that reads env vars (e.g. pi-ai).
4
4
  *
5
- * Supports:
6
- * - ANTHROPIC_API_KEY
7
- * - RLM_MODEL (model name, e.g. claude-sonnet-4-5-20250929)
5
+ * Load order (later wins):
6
+ * 1. ~/.rlm/credentials — persistent keys saved by first-run setup
7
+ * 2. .env in package root local overrides
8
8
  */
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
- // Load .env file from the package root (not CWD, which could be untrusted)
12
- const __dir = path.dirname(new URL(import.meta.url).pathname);
13
- const envPath = path.resolve(__dir, "..", ".env");
14
- if (fs.existsSync(envPath)) {
15
- const content = fs.readFileSync(envPath, "utf-8");
11
+ import * as os from "node:os";
12
+ function loadEnvFile(filePath) {
13
+ if (!fs.existsSync(filePath))
14
+ return;
15
+ const content = fs.readFileSync(filePath, "utf-8");
16
16
  for (const line of content.split("\n")) {
17
17
  const trimmed = line.trim();
18
18
  if (!trimmed || trimmed.startsWith("#"))
@@ -22,13 +22,18 @@ if (fs.existsSync(envPath)) {
22
22
  continue;
23
23
  const key = trimmed.slice(0, eqIndex).trim();
24
24
  const value = trimmed.slice(eqIndex + 1).trim();
25
- if (key) {
25
+ if (key && !process.env[key]) {
26
26
  process.env[key] = value;
27
27
  }
28
28
  }
29
29
  }
30
+ // 1. Load persistent credentials (~/.rlm/credentials)
31
+ loadEnvFile(path.join(os.homedir(), ".rlm", "credentials"));
32
+ // 2. Load .env from package root (local overrides)
33
+ const __dir = path.dirname(new URL(import.meta.url).pathname);
34
+ loadEnvFile(path.resolve(__dir, "..", ".env"));
30
35
  // Default model
31
36
  if (!process.env.RLM_MODEL) {
32
- process.env.RLM_MODEL = "claude-sonnet-4-5-20250929";
37
+ process.env.RLM_MODEL = "claude-sonnet-4-6";
33
38
  }
34
39
  //# sourceMappingURL=env.js.map
@@ -10,8 +10,18 @@
10
10
  import "./env.js";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import * as os from "node:os";
13
14
  import * as readline from "node:readline";
14
15
  import { stdin, stdout } from "node:process";
16
+ // Global error handlers — prevent raw stack traces from leaking to terminal
17
+ process.on("uncaughtException", (err) => {
18
+ console.error(`\n \x1b[31mUnexpected error: ${err.message}\x1b[0m\n`);
19
+ process.exit(1);
20
+ });
21
+ process.on("unhandledRejection", (err) => {
22
+ console.error(`\n \x1b[31mUnexpected error: ${err?.message || err}\x1b[0m\n`);
23
+ process.exit(1);
24
+ });
15
25
  const { getModels, getProviders } = await import("@mariozechner/pi-ai");
16
26
  const { PythonRepl } = await import("./repl.js");
17
27
  const { runRlmLoop } = await import("./rlm.js");
@@ -68,7 +78,8 @@ class Spinner {
68
78
  }
69
79
  // ── Constants ───────────────────────────────────────────────────────────────
70
80
  const DEFAULT_MODEL = process.env.RLM_MODEL || "claude-sonnet-4-6";
71
- const TRAJ_DIR = path.resolve(process.cwd(), "trajectories");
81
+ const RLM_HOME = path.join(os.homedir(), ".rlm");
82
+ const TRAJ_DIR = path.join(RLM_HOME, "trajectories");
72
83
  const W = Math.min(process.stdout.columns || 80, 100);
73
84
  // ── Session state ───────────────────────────────────────────────────────────
74
85
  let currentModelId = DEFAULT_MODEL;
@@ -200,21 +211,31 @@ function questionWithEsc(rlInstance, promptText) {
200
211
  async function promptForProviderKey(rlInstance, providerInfo) {
201
212
  if (process.env[providerInfo.env])
202
213
  return true;
203
- const key = await questionWithEsc(rlInstance, ` ${c.cyan}${providerInfo.env}:${c.reset} `);
204
- if (key === null)
214
+ const rawKey = await questionWithEsc(rlInstance, ` ${c.cyan}${providerInfo.env}:${c.reset} `);
215
+ if (rawKey === null)
205
216
  return null; // ESC
206
- if (!key)
217
+ if (!rawKey)
207
218
  return false; // empty
219
+ // Sanitize: strip newlines, control chars, whitespace
220
+ const key = rawKey.replace(/[\r\n\x00-\x1f]/g, "").trim();
221
+ if (!key)
222
+ return false;
208
223
  process.env[providerInfo.env] = key;
209
- // Save to shell profile
210
- const shellRc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
211
- const rcPath = shellRc.replace("~", process.env.HOME || "~");
224
+ // Save to ~/.rlm/credentials (persistent across sessions)
225
+ const credPath = path.join(RLM_HOME, "credentials");
212
226
  try {
213
- fs.appendFileSync(rcPath, `\nexport ${providerInfo.env}=${key}\n`);
214
- console.log(`\n ${c.green}✓${c.reset} ${providerInfo.name} key saved to ${c.dim}${shellRc}${c.reset}`);
227
+ if (!fs.existsSync(RLM_HOME))
228
+ fs.mkdirSync(RLM_HOME, { recursive: true });
229
+ fs.appendFileSync(credPath, `${providerInfo.env}=${key}\n`);
230
+ // Restrict permissions (owner-only read/write)
231
+ try {
232
+ fs.chmodSync(credPath, 0o600);
233
+ }
234
+ catch { /* Windows etc. */ }
235
+ console.log(`\n ${c.green}✓${c.reset} ${providerInfo.name} key saved to ${c.dim}~/.rlm/credentials${c.reset}`);
215
236
  }
216
237
  catch {
217
- console.log(`\n ${c.yellow}!${c.reset} Could not write to ${shellRc}. Add manually:`);
238
+ console.log(`\n ${c.yellow}!${c.reset} Could not save key. Add manually:`);
218
239
  console.log(` ${c.yellow}export ${providerInfo.env}=${key}${c.reset}`);
219
240
  }
220
241
  return true;
@@ -308,10 +329,15 @@ async function handleFile(arg) {
308
329
  console.log(` ${c.red}File not found: ${filePath}${c.reset}`);
309
330
  return;
310
331
  }
311
- contextText = fs.readFileSync(filePath, "utf-8");
312
- contextSource = arg;
313
- const lines = contextText.split("\n").length;
314
- console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${arg}${c.reset}`);
332
+ try {
333
+ contextText = fs.readFileSync(filePath, "utf-8");
334
+ contextSource = arg;
335
+ const lines = contextText.split("\n").length;
336
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${arg}${c.reset}`);
337
+ }
338
+ catch (err) {
339
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
340
+ }
315
341
  }
316
342
  async function handleUrl(arg) {
317
343
  if (!arg) {
@@ -687,12 +713,17 @@ async function runQuery(query) {
687
713
  console.log(boxBottom(c.green));
688
714
  console.log();
689
715
  // Save trajectory
690
- if (!fs.existsSync(TRAJ_DIR))
691
- fs.mkdirSync(TRAJ_DIR, { recursive: true });
692
- const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
693
- const trajFile = `trajectory-${ts}.json`;
694
- fs.writeFileSync(path.join(TRAJ_DIR, trajFile), JSON.stringify(trajectory, null, 2), "utf-8");
695
- console.log(` ${c.dim}Saved: ${trajFile}${c.reset}\n`);
716
+ try {
717
+ if (!fs.existsSync(TRAJ_DIR))
718
+ fs.mkdirSync(TRAJ_DIR, { recursive: true });
719
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
720
+ const trajFile = `trajectory-${ts}.json`;
721
+ fs.writeFileSync(path.join(TRAJ_DIR, trajFile), JSON.stringify(trajectory, null, 2), "utf-8");
722
+ console.log(` ${c.dim}Saved: ~/.rlm/trajectories/${trajFile}${c.reset}\n`);
723
+ }
724
+ catch {
725
+ console.log(` ${c.yellow}Could not save trajectory.${c.reset}\n`);
726
+ }
696
727
  }
697
728
  catch (err) {
698
729
  spinner.stop();
@@ -751,11 +782,17 @@ function expandAtFiles(input) {
751
782
  if (atMatch) {
752
783
  const filePath = path.resolve(atMatch[1]);
753
784
  if (fs.existsSync(filePath)) {
754
- contextText = fs.readFileSync(filePath, "utf-8");
755
- contextSource = atMatch[1];
756
- const lines = contextText.split("\n").length;
757
- console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${atMatch[1]}${c.reset}`);
758
- return atMatch[2] || "";
785
+ try {
786
+ contextText = fs.readFileSync(filePath, "utf-8");
787
+ contextSource = atMatch[1];
788
+ const lines = contextText.split("\n").length;
789
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${atMatch[1]}${c.reset}`);
790
+ return atMatch[2] || "";
791
+ }
792
+ catch (err) {
793
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
794
+ return "";
795
+ }
759
796
  }
760
797
  else {
761
798
  console.log(` ${c.red}File not found: ${atMatch[1]}${c.reset}`);
@@ -867,251 +904,257 @@ async function interactive() {
867
904
  };
868
905
  rl.prompt();
869
906
  rl.on("line", async (rawLine) => {
870
- if (isRunning)
871
- return; // ignore input while a query is active
872
- const line = rawLine.trim();
873
- // URL auto-detect
874
- if (line.startsWith("http://") || line.startsWith("https://")) {
875
- const loaded = await detectAndLoadUrl(line);
876
- if (loaded) {
877
- printStatusLine();
878
- console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
879
- rl.prompt();
880
- return;
907
+ try {
908
+ if (isRunning)
909
+ return; // ignore input while a query is active
910
+ const line = rawLine.trim();
911
+ // URL auto-detect
912
+ if (line.startsWith("http://") || line.startsWith("https://")) {
913
+ const loaded = await detectAndLoadUrl(line);
914
+ if (loaded) {
915
+ printStatusLine();
916
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
917
+ rl.prompt();
918
+ return;
919
+ }
881
920
  }
882
- }
883
- // Multi-line paste detect
884
- if (isMultiLineInput(rawLine)) {
885
- const result = handleMultiLineAsContext(rawLine);
886
- if (result) {
887
- contextText = result.context;
888
- contextSource = "(pasted)";
889
- printStatusLine();
890
- console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
921
+ // Multi-line paste detect
922
+ if (isMultiLineInput(rawLine)) {
923
+ const result = handleMultiLineAsContext(rawLine);
924
+ if (result) {
925
+ contextText = result.context;
926
+ contextSource = "(pasted)";
927
+ printStatusLine();
928
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
929
+ rl.prompt();
930
+ return;
931
+ }
932
+ }
933
+ if (!line) {
891
934
  rl.prompt();
892
935
  return;
893
936
  }
894
- }
895
- if (!line) {
896
- rl.prompt();
897
- return;
898
- }
899
- // Slash commands
900
- if (line.startsWith("/")) {
901
- const [cmd, ...rest] = line.slice(1).split(/\s+/);
902
- const arg = rest.join(" ");
903
- switch (cmd) {
904
- case "help":
905
- case "h":
906
- printCommandHelp();
907
- break;
908
- case "file":
909
- case "f":
910
- await handleFile(arg);
911
- break;
912
- case "url":
913
- case "u":
914
- await handleUrl(arg);
915
- break;
916
- case "paste":
917
- case "p":
918
- await handlePaste(rl);
919
- break;
920
- case "context":
921
- case "ctx":
922
- handleContext();
923
- break;
924
- case "clear-context":
925
- case "cc":
926
- contextText = "";
927
- contextSource = "";
928
- console.log(` ${c.green}✓${c.reset} Context cleared.`);
929
- break;
930
- case "model":
931
- case "m": {
932
- const curProvider = detectProvider();
933
- if (arg) {
934
- // Accept a number (from current provider list) or a model ID
935
- const curModels = getModelsForProvider(curProvider);
936
- let pick;
937
- if (/^\d+$/.test(arg)) {
938
- pick = curModels[parseInt(arg, 10) - 1]?.id;
937
+ // Slash commands
938
+ if (line.startsWith("/")) {
939
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
940
+ const arg = rest.join(" ");
941
+ switch (cmd) {
942
+ case "help":
943
+ case "h":
944
+ printCommandHelp();
945
+ break;
946
+ case "file":
947
+ case "f":
948
+ await handleFile(arg);
949
+ break;
950
+ case "url":
951
+ case "u":
952
+ await handleUrl(arg);
953
+ break;
954
+ case "paste":
955
+ case "p":
956
+ await handlePaste(rl);
957
+ break;
958
+ case "context":
959
+ case "ctx":
960
+ handleContext();
961
+ break;
962
+ case "clear-context":
963
+ case "cc":
964
+ contextText = "";
965
+ contextSource = "";
966
+ console.log(` ${c.green}✓${c.reset} Context cleared.`);
967
+ break;
968
+ case "model":
969
+ case "m": {
970
+ const curProvider = detectProvider();
971
+ if (arg) {
972
+ // Accept a number (from current provider list) or a model ID
973
+ const curModels = getModelsForProvider(curProvider);
974
+ let pick;
975
+ if (/^\d+$/.test(arg)) {
976
+ pick = curModels[parseInt(arg, 10) - 1]?.id;
977
+ }
978
+ else {
979
+ pick = arg;
980
+ }
981
+ if (!pick) {
982
+ console.log(` ${c.red}Invalid selection.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
983
+ break;
984
+ }
985
+ // Check if this model belongs to a different provider
986
+ const resolved = resolveModelWithProvider(pick);
987
+ if (!resolved) {
988
+ console.log(` ${c.red}Model "${arg}" not found.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
989
+ break;
990
+ }
991
+ if (resolved.provider !== curProvider) {
992
+ // Cross-provider switch
993
+ const setupInfo = findSetupProvider(resolved.provider);
994
+ const envVar = setupInfo?.env || providerEnvKey(resolved.provider);
995
+ const provName = setupInfo?.name || resolved.provider;
996
+ if (!process.env[envVar]) {
997
+ console.log(` ${c.yellow}That model requires ${provName}.${c.reset}`);
998
+ const gotKey = await promptForProviderKey(rl, { name: provName, env: envVar });
999
+ if (!gotKey) {
1000
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
1001
+ break;
1002
+ }
1003
+ }
1004
+ }
1005
+ currentModelId = pick;
1006
+ currentModel = resolved.model;
1007
+ console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${currentModelId}${c.reset}`);
1008
+ console.log();
1009
+ printStatusLine();
939
1010
  }
940
1011
  else {
941
- pick = arg;
1012
+ // List models for current provider
1013
+ const models = getModelsForProvider(curProvider);
1014
+ const provLabel = findSetupProvider(curProvider)?.name || curProvider;
1015
+ console.log(`\n ${c.bold}Current model:${c.reset} ${c.cyan}${currentModelId}${c.reset} ${c.dim}(${provLabel})${c.reset}\n`);
1016
+ const pad = String(models.length).length;
1017
+ for (let i = 0; i < models.length; i++) {
1018
+ const m = models[i];
1019
+ const num = String(i + 1).padStart(pad);
1020
+ const dot = m.id === currentModelId ? `${c.green}●${c.reset}` : ` `;
1021
+ const label = m.id === currentModelId
1022
+ ? `${c.cyan}${m.id}${c.reset}`
1023
+ : `${c.dim}${m.id}${c.reset}`;
1024
+ console.log(` ${c.dim}${num}${c.reset} ${dot} ${label}`);
1025
+ }
1026
+ console.log(`\n ${c.dim}${models.length} models · scroll up to see full list.${c.reset}`);
1027
+ 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}`);
1028
+ console.log(` ${c.dim}Type${c.reset} ${c.cyan}/provider${c.reset} ${c.dim}to switch provider.${c.reset}`);
1029
+ }
1030
+ break;
1031
+ }
1032
+ case "provider":
1033
+ case "prov": {
1034
+ const curProvider = detectProvider();
1035
+ const curLabel = findSetupProvider(curProvider)?.name || curProvider;
1036
+ console.log(`\n ${c.bold}Current provider:${c.reset} ${c.cyan}${curLabel}${c.reset}\n`);
1037
+ for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
1038
+ const p = SETUP_PROVIDERS[i];
1039
+ const isCurrent = p.piProvider === curProvider;
1040
+ const dot = isCurrent ? `${c.green}●${c.reset}` : ` `;
1041
+ // Only show ✓ for non-current providers that have a key
1042
+ const hasKey = !isCurrent && process.env[p.env] ? `${c.green}✓${c.reset}` : ` `;
1043
+ const label = isCurrent
1044
+ ? `${c.cyan}${p.name}${c.reset} ${c.dim}(${p.label})${c.reset}`
1045
+ : `${p.name} ${c.dim}(${p.label})${c.reset}`;
1046
+ console.log(` ${c.dim}${i + 1}${c.reset} ${dot}${hasKey} ${label}`);
942
1047
  }
943
- if (!pick) {
944
- console.log(` ${c.red}Invalid selection.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
1048
+ console.log();
1049
+ const provChoice = await questionWithEsc(rl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
1050
+ if (provChoice === null)
1051
+ break; // ESC
1052
+ const idx = parseInt(provChoice, 10) - 1;
1053
+ if (isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
1054
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
945
1055
  break;
946
1056
  }
947
- // Check if this model belongs to a different provider
948
- const resolved = resolveModelWithProvider(pick);
949
- if (!resolved) {
950
- console.log(` ${c.red}Model "${arg}" not found.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
1057
+ const chosen = SETUP_PROVIDERS[idx];
1058
+ const gotKey = await promptForProviderKey(rl, chosen);
1059
+ if (!gotKey) {
1060
+ // null (ESC) or false (empty) cancel
951
1061
  break;
952
1062
  }
953
- if (resolved.provider !== curProvider) {
954
- // Cross-provider switch
955
- const setupInfo = findSetupProvider(resolved.provider);
956
- const envVar = setupInfo?.env || providerEnvKey(resolved.provider);
957
- const provName = setupInfo?.name || resolved.provider;
958
- if (!process.env[envVar]) {
959
- console.log(` ${c.yellow}That model requires ${provName}.${c.reset}`);
960
- const gotKey = await promptForProviderKey(rl, { name: provName, env: envVar });
961
- if (!gotKey) {
962
- console.log(` ${c.dim}Cancelled.${c.reset}`);
963
- break;
964
- }
965
- }
1063
+ // Auto-select first model from new provider
1064
+ const defaultModel = getDefaultModelForProvider(chosen.piProvider);
1065
+ if (defaultModel) {
1066
+ currentModelId = defaultModel;
1067
+ currentModel = resolveModel(currentModelId);
1068
+ console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${chosen.name}${c.reset}`);
1069
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
1070
+ console.log();
1071
+ printStatusLine();
966
1072
  }
967
- currentModelId = pick;
968
- currentModel = resolved.model;
969
- console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${currentModelId}${c.reset}`);
970
- console.log();
971
- printStatusLine();
972
- }
973
- else {
974
- // List models for current provider
975
- const models = getModelsForProvider(curProvider);
976
- const provLabel = findSetupProvider(curProvider)?.name || curProvider;
977
- console.log(`\n ${c.bold}Current model:${c.reset} ${c.cyan}${currentModelId}${c.reset} ${c.dim}(${provLabel})${c.reset}\n`);
978
- const pad = String(models.length).length;
979
- for (let i = 0; i < models.length; i++) {
980
- const m = models[i];
981
- const num = String(i + 1).padStart(pad);
982
- const dot = m.id === currentModelId ? `${c.green}●${c.reset}` : ` `;
983
- const label = m.id === currentModelId
984
- ? `${c.cyan}${m.id}${c.reset}`
985
- : `${c.dim}${m.id}${c.reset}`;
986
- console.log(` ${c.dim}${num}${c.reset} ${dot} ${label}`);
1073
+ else {
1074
+ console.log(` ${c.red}No models available for ${chosen.name}.${c.reset}`);
987
1075
  }
988
- console.log(`\n ${c.dim}${models.length} models · scroll up to see full list.${c.reset}`);
989
- 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}`);
990
- console.log(` ${c.dim}Type${c.reset} ${c.cyan}/provider${c.reset} ${c.dim}to switch provider.${c.reset}`);
991
- }
992
- break;
993
- }
994
- case "provider":
995
- case "prov": {
996
- const curProvider = detectProvider();
997
- const curLabel = findSetupProvider(curProvider)?.name || curProvider;
998
- console.log(`\n ${c.bold}Current provider:${c.reset} ${c.cyan}${curLabel}${c.reset}\n`);
999
- for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
1000
- const p = SETUP_PROVIDERS[i];
1001
- const isCurrent = p.piProvider === curProvider;
1002
- const dot = isCurrent ? `${c.green}●${c.reset}` : ` `;
1003
- // Only show ✓ for non-current providers that have a key
1004
- const hasKey = !isCurrent && process.env[p.env] ? `${c.green}✓${c.reset}` : ` `;
1005
- const label = isCurrent
1006
- ? `${c.cyan}${p.name}${c.reset} ${c.dim}(${p.label})${c.reset}`
1007
- : `${p.name} ${c.dim}(${p.label})${c.reset}`;
1008
- console.log(` ${c.dim}${i + 1}${c.reset} ${dot}${hasKey} ${label}`);
1009
- }
1010
- console.log();
1011
- const provChoice = await questionWithEsc(rl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
1012
- if (provChoice === null)
1013
- break; // ESC
1014
- const idx = parseInt(provChoice, 10) - 1;
1015
- if (isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
1016
- console.log(` ${c.dim}Cancelled.${c.reset}`);
1017
1076
  break;
1018
1077
  }
1019
- const chosen = SETUP_PROVIDERS[idx];
1020
- const gotKey = await promptForProviderKey(rl, chosen);
1021
- if (!gotKey) {
1022
- // null (ESC) or false (empty) → cancel
1078
+ case "trajectories":
1079
+ case "traj":
1080
+ handleTrajectories();
1023
1081
  break;
1024
- }
1025
- // Auto-select first model from new provider
1026
- const defaultModel = getDefaultModelForProvider(chosen.piProvider);
1027
- if (defaultModel) {
1028
- currentModelId = defaultModel;
1029
- currentModel = resolveModel(currentModelId);
1030
- console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${chosen.name}${c.reset}`);
1031
- console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
1032
- console.log();
1033
- printStatusLine();
1034
- }
1035
- else {
1036
- console.log(` ${c.red}No models available for ${chosen.name}.${c.reset}`);
1037
- }
1038
- break;
1082
+ case "clear":
1083
+ printWelcome();
1084
+ break;
1085
+ case "quit":
1086
+ case "q":
1087
+ case "exit":
1088
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
1089
+ process.exit(0);
1090
+ break;
1091
+ default:
1092
+ console.log(` ${c.red}Unknown command: /${cmd}${c.reset}. Type ${c.cyan}/help${c.reset} for commands.`);
1039
1093
  }
1040
- case "trajectories":
1041
- case "traj":
1042
- handleTrajectories();
1043
- break;
1044
- case "clear":
1045
- printWelcome();
1046
- break;
1047
- case "quit":
1048
- case "q":
1049
- case "exit":
1050
- console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
1051
- process.exit(0);
1052
- break;
1053
- default:
1054
- console.log(` ${c.red}Unknown command: /${cmd}${c.reset}. Type ${c.cyan}/help${c.reset} for commands.`);
1094
+ rl.prompt();
1095
+ return;
1055
1096
  }
1056
- rl.prompt();
1057
- return;
1058
- }
1059
- // @file shorthand
1060
- let query = expandAtFiles(line);
1061
- if (!query && line.startsWith("@")) {
1062
- rl.prompt();
1063
- return;
1064
- }
1065
- if (!query)
1066
- query = line;
1067
- // Inline URL detection — extract URL from query, fetch as context
1068
- if (!contextText) {
1069
- const urlInline = query.match(/(https?:\/\/\S+)/);
1070
- if (urlInline) {
1071
- const url = urlInline[1];
1072
- const queryWithoutUrl = query.replace(url, "").trim();
1073
- console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
1074
- try {
1075
- const resp = await fetch(url);
1076
- if (!resp.ok)
1077
- throw new Error(`${resp.status} ${resp.statusText}`);
1078
- contextText = await resp.text();
1079
- contextSource = url;
1080
- const lines = contextText.split("\n").length;
1081
- console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from URL`);
1082
- if (queryWithoutUrl) {
1083
- query = queryWithoutUrl;
1097
+ // @file shorthand
1098
+ let query = expandAtFiles(line);
1099
+ if (!query && line.startsWith("@")) {
1100
+ rl.prompt();
1101
+ return;
1102
+ }
1103
+ if (!query)
1104
+ query = line;
1105
+ // Inline URL detection — extract URL from query, fetch as context
1106
+ if (!contextText) {
1107
+ const urlInline = query.match(/(https?:\/\/\S+)/);
1108
+ if (urlInline) {
1109
+ const url = urlInline[1];
1110
+ const queryWithoutUrl = query.replace(url, "").trim();
1111
+ console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
1112
+ try {
1113
+ const resp = await fetch(url);
1114
+ if (!resp.ok)
1115
+ throw new Error(`${resp.status} ${resp.statusText}`);
1116
+ contextText = await resp.text();
1117
+ contextSource = url;
1118
+ const lines = contextText.split("\n").length;
1119
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from URL`);
1120
+ if (queryWithoutUrl) {
1121
+ query = queryWithoutUrl;
1122
+ }
1123
+ else {
1124
+ // URL only, no query prompt for one
1125
+ printStatusLine();
1126
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
1127
+ rl.prompt();
1128
+ return;
1129
+ }
1084
1130
  }
1085
- else {
1086
- // URL only, no query — prompt for one
1087
- printStatusLine();
1088
- console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
1089
- rl.prompt();
1090
- return;
1131
+ catch (err) {
1132
+ console.log(` ${c.red}Failed to fetch URL: ${err.message}${c.reset}`);
1133
+ console.log(` ${c.dim}Running query as-is...${c.reset}`);
1091
1134
  }
1092
1135
  }
1093
- catch (err) {
1094
- console.log(` ${c.red}Failed to fetch URL: ${err.message}${c.reset}`);
1095
- console.log(` ${c.dim}Running query as-is...${c.reset}`);
1136
+ }
1137
+ // Auto-detect file paths
1138
+ if (!contextText) {
1139
+ const { filePath, query: extractedQuery } = extractFilePath(query);
1140
+ if (filePath) {
1141
+ contextText = fs.readFileSync(filePath, "utf-8");
1142
+ contextSource = path.basename(filePath);
1143
+ const lines = contextText.split("\n").length;
1144
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${filePath}${c.reset}`);
1145
+ query = extractedQuery || query;
1096
1146
  }
1097
1147
  }
1148
+ // Run query
1149
+ await runQuery(query);
1150
+ printStatusLine();
1151
+ console.log();
1152
+ rl.prompt();
1098
1153
  }
1099
- // Auto-detect file paths
1100
- if (!contextText) {
1101
- const { filePath, query: extractedQuery } = extractFilePath(query);
1102
- if (filePath) {
1103
- contextText = fs.readFileSync(filePath, "utf-8");
1104
- contextSource = path.basename(filePath);
1105
- const lines = contextText.split("\n").length;
1106
- console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${filePath}${c.reset}`);
1107
- query = extractedQuery || query;
1108
- }
1154
+ catch (err) {
1155
+ console.log(`\n ${c.red}Error: ${err?.message || err}${c.reset}\n`);
1156
+ rl.prompt();
1109
1157
  }
1110
- // Run query
1111
- await runQuery(query);
1112
- printStatusLine();
1113
- console.log();
1114
- rl.prompt();
1115
1158
  });
1116
1159
  // Ctrl+C: abort running query, or double-tap to exit
1117
1160
  let lastSigint = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlm-cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Standalone CLI for Recursive Language Models (RLMs) — implements Algorithm 1 from arXiv:2512.24601",
5
5
  "type": "module",
6
6
  "bin": {