roforge-cli 0.3.0 → 0.3.1

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
@@ -14,15 +14,13 @@ roforge analyze <file...> official Luau analyzer
14
14
  ## Install
15
15
 
16
16
  ```bash
17
- # GitHub Packages (published — any GitHub token with read:packages works)
18
- npm config set @hacvilke:registry https://npm.pkg.github.com
19
- echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc
20
- npm i -g @hacvilke/roforge-cli
21
-
22
- # …or clone the repo and run `node cli/bin/roforge.js`
17
+ npm i -g roforge-cli
23
18
  roforge
24
19
  ```
25
20
 
21
+ Also on **GitHub Packages** as `@hacvilke/roforge-cli` (any GitHub token with
22
+ `read:packages` works), or clone the repo and run `node cli/bin/roforge.js`.
23
+
26
24
  Requires Node ≥ 18.17. No other dependencies.
27
25
 
28
26
  ## Free tiers (no card needed)
package/bin/roforge.js CHANGED
@@ -84,12 +84,26 @@ async function main() {
84
84
  const promptKey = async (label) => {
85
85
  process.stderr.write(`Paste ${label} (input hidden): `);
86
86
  let val = "";
87
- if (process.stdin.isTTY) {
88
- process.stdin.setRawMode(true);
87
+ // raw mode hides input; legacy Windows consoles may not support it —
88
+ // fall back to a visible paste instead of crashing
89
+ let raw = false;
90
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
91
+ try {
92
+ process.stdin.setRawMode(true);
93
+ raw = true;
94
+ } catch {
95
+ raw = false;
96
+ }
97
+ }
98
+ if (raw) {
89
99
  for await (const chunk of process.stdin) {
90
100
  for (const ch of String(chunk)) {
91
101
  if (ch === "\r" || ch === "\n" || ch === "\x04") {
92
- process.stdin.setRawMode(false);
102
+ try {
103
+ process.stdin.setRawMode(false);
104
+ } catch {
105
+ /* ignore */
106
+ }
93
107
  console.error("");
94
108
  return val;
95
109
  }
@@ -98,6 +112,7 @@ async function main() {
98
112
  }
99
113
  }
100
114
  } else {
115
+ process.stderr.write(dim("(raw input unsupported — paste the key and press Enter)\n"));
101
116
  for await (const chunk of process.stdin) val += String(chunk);
102
117
  return val.trim();
103
118
  }
@@ -336,7 +351,14 @@ async function oneShot(cfg, prompt) {
336
351
  },
337
352
  });
338
353
  await session.init();
339
- const out = await session.send(prompt);
354
+ let out;
355
+ try {
356
+ out = await session.send(prompt);
357
+ } catch (e) {
358
+ console.error(red(`error: ${e.message || e}`) + "\n");
359
+ bridge.stop();
360
+ process.exit(1);
361
+ }
340
362
  process.stdout.write("\n");
341
363
  bridge.stop();
342
364
  process.exit(out.ok ? 0 : 1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "roforge-cli",
3
- "version": "0.3.0",
4
- "description": "RoForge Claude-Code-style local AI agent for Roblox Studio. BYOK, zero backend, zero dependencies.",
3
+ "version": "0.3.1",
4
+ "description": "RoForge \u2014 Claude-Code-style local AI agent for Roblox Studio. BYOK, zero backend, zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "roforge": "bin/roforge.js"
package/src/config.js CHANGED
@@ -8,8 +8,16 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { randomToken } from "./util.js";
10
10
 
11
- export const CONFIG_DIR = process.env.ROFORGE_CONFIG_DIR || path.join(os.homedir(), ".roforge");
12
- export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
11
+ // Resolved lazily so ROFORGE_CONFIG_DIR changes (tests, late env) are honored.
12
+ export function configDir() {
13
+ return process.env.ROFORGE_CONFIG_DIR || path.join(os.homedir(), ".roforge");
14
+ }
15
+ export function configFile() {
16
+ return path.join(configDir(), "config.json");
17
+ }
18
+ // (static snapshots — display only; read/write go through configFile())
19
+ export const CONFIG_DIR = configDir();
20
+ export const CONFIG_FILE = configFile();
13
21
 
14
22
  // Provider registry. "auto" (default) picks the first configured provider,
15
23
  // free-tier providers first (gemini → groq → openrouter → anthropic → openai).
@@ -112,7 +120,7 @@ const DEFAULTS = {
112
120
 
113
121
  export function loadFileConfig() {
114
122
  try {
115
- return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
123
+ return JSON.parse(fs.readFileSync(configFile(), "utf8"));
116
124
  } catch {
117
125
  return {};
118
126
  }
@@ -121,8 +129,8 @@ export function loadFileConfig() {
121
129
  export function saveFileConfig(patch) {
122
130
  const current = loadFileConfig();
123
131
  const next = deepMerge(current, patch);
124
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
125
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
132
+ fs.mkdirSync(configDir(), { recursive: true });
133
+ fs.writeFileSync(configFile(), JSON.stringify(next, null, 2));
126
134
  return next;
127
135
  }
128
136
 
@@ -134,11 +142,39 @@ function deepMerge(a, b) {
134
142
  return out;
135
143
  }
136
144
 
145
+ // Lenient import: env-var-style keys (GEMINI_API_KEY, OPENROUTER_API_KEY, …)
146
+ // accepted from ANYWHERE in the config file (top-level or nested, e.g. under
147
+ // "bridge"), mapped onto the canonical key fields.
148
+ const ENV_STYLE_KEY_FIELDS = {
149
+ GEMINI_API_KEY: "geminiKey",
150
+ GROQ_API_KEY: "groqKey",
151
+ OPENROUTER_API_KEY: "openrouterKey",
152
+ ANTHROPIC_API_KEY: "anthropicKey",
153
+ OPENAI_API_KEY: "openaiKey",
154
+ };
155
+ function collectEnvStyleKeys(node, out) {
156
+ if (!node || typeof node !== "object") return out;
157
+ for (const [k, v] of Object.entries(node)) {
158
+ if (typeof v === "string" && v && ENV_STYLE_KEY_FIELDS[k] && !out[ENV_STYLE_KEY_FIELDS[k]]) {
159
+ out[ENV_STYLE_KEY_FIELDS[k]] = v;
160
+ } else if (v && typeof v === "object") {
161
+ collectEnvStyleKeys(v, out);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
137
167
  // Resolved, runtime config (env overrides applied, defaults filled).
138
168
  export function resolveConfig() {
139
169
  const file = loadFileConfig();
140
170
  const cfg = deepMerge(DEFAULTS, file);
141
171
 
172
+ // env-var-style keys anywhere in the file (e.g. {"bridge": {"OPENROUTER_API_KEY": "…"}})
173
+ const envStyle = collectEnvStyleKeys(file, {});
174
+ for (const [field, val] of Object.entries(envStyle)) {
175
+ if (!cfg[field]) cfg[field] = val;
176
+ }
177
+
142
178
  cfg.geminiKey = process.env.GEMINI_API_KEY || cfg.geminiKey || "";
143
179
  cfg.groqKey = process.env.GROQ_API_KEY || cfg.groqKey || "";
144
180
  cfg.openrouterKey = process.env.OPENROUTER_API_KEY || cfg.openrouterKey || "";
package/src/session.js CHANGED
@@ -32,6 +32,9 @@ export class Session {
32
32
  return effectiveProvider(this.cfg) || this.cfg.provider || "auto";
33
33
  }
34
34
  get provider() {
35
+ // "auto" with no configured keys → null (caller shows a friendly
36
+ // "no API key" error instead of silently hitting Anthropic).
37
+ if (this.providerName === "auto" && !effectiveProvider(this.cfg)) return null;
35
38
  return PROVIDER_MODULES[this.providerName] || Anthropic;
36
39
  }
37
40
  get model() {
@@ -80,6 +83,15 @@ Current state:
80
83
  }
81
84
 
82
85
  async send(userText) {
86
+ if (!this.provider) {
87
+ const prov = this.cfg.provider && this.cfg.provider !== "auto" ? this.cfg.provider : "auto";
88
+ throw new Error(
89
+ `No API key found for ${prov === "auto" ? "any provider" : prov}. ` +
90
+ "Run `roforge login --provider <gemini|groq|openrouter|anthropic|openai>` " +
91
+ "(free keys: aistudio.google.com, console.groq.com, openrouter.ai) or set the " +
92
+ "matching *_API_KEY env var / key in your config."
93
+ );
94
+ }
83
95
  this.turns++;
84
96
  this.aborted = false;
85
97
  this._controller = new AbortController();
package/src/tui/tui.js CHANGED
@@ -1,11 +1,25 @@
1
1
  // RoForge TUI — Claude-Code-style interactive terminal session.
2
2
  // Append-style rendering (terminal scrollback preserved), single status line
3
3
  // with a spinner, approval prompts, slash commands, input history.
4
+ import { createRequire } from "node:module";
4
5
  import { bold, dim, red, green, yellow, cyan, magenta, gray, wrap, SPINNER_FRAMES, CLEAR_LINE } from "./ansi.js";
5
6
  import { parseModelRef, PROVIDERS } from "../config.js";
6
7
  import { MarkdownStream } from "./markdown.js";
7
8
 
8
- const VERSION = "0.2.0";
9
+ const VERSION = (() => {
10
+ try {
11
+ return createRequire(import.meta.url)("../../package.json").version;
12
+ } catch {
13
+ return "dev";
14
+ }
15
+ })();
16
+
17
+ // Known model names (for a soft /model warning; anything else is allowed).
18
+ const KNOWN_MODELS = new Set();
19
+ for (const p of Object.values(PROVIDERS)) {
20
+ if (p.defaultModel) KNOWN_MODELS.add(p.defaultModel);
21
+ if (p.freeModel) KNOWN_MODELS.add(p.freeModel);
22
+ }
9
23
 
10
24
  export class TUI {
11
25
  constructor(session, { out = process.stdout, err = process.stderr } = {}) {
@@ -132,6 +146,17 @@ export class TUI {
132
146
  this._slash(line);
133
147
  return;
134
148
  }
149
+ // Shell commands typed into the TUI go to the model and fail
150
+ // confusingly — catch the common ones and point at the terminal.
151
+ if (/^(roforge|npm|node|npx|git)\b(\s|$)/.test(line)) {
152
+ this.out.write(
153
+ yellow(
154
+ "that's a shell command, not a chat message — /exit first, then run it in your terminal " +
155
+ "(e.g. `roforge login`). In the TUI use /help for commands.\n"
156
+ )
157
+ );
158
+ return;
159
+ }
135
160
  this.history.push(line);
136
161
  this.historyIndex = -1;
137
162
  this._runTurn(line);
@@ -193,6 +218,11 @@ export class TUI {
193
218
  }
194
219
  const free = PROVIDERS[this.session.providerName]?.hasFreeTier ? dim(" · free tier") : "";
195
220
  this.out.write(`model → ${this.session.cfg._activeModel} (${this.session.providerName}${free})\n`);
221
+ if (!ref && !KNOWN_MODELS.has(arg)) {
222
+ this.out.write(
223
+ dim(` (unrecognized model name — double-check the spelling, or pin explicitly: /model provider:model, e.g. /model gemini:gemini-2.5-flash)\n`)
224
+ );
225
+ }
196
226
  } else {
197
227
  const free = PROVIDERS[this.session.providerName]?.hasFreeTier ? dim(" · free tier") : "";
198
228
  this.out.write(`current: ${this.session.model} (${this.session.providerName}${free})\n`);