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 +4 -6
- package/bin/roforge.js +26 -4
- package/package.json +2 -2
- package/src/config.js +41 -5
- package/src/session.js +12 -0
- package/src/tui/tui.js +31 -1
package/README.md
CHANGED
|
@@ -14,15 +14,13 @@ roforge analyze <file...> official Luau analyzer
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "RoForge
|
|
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
|
-
|
|
12
|
-
export
|
|
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(
|
|
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(
|
|
125
|
-
fs.writeFileSync(
|
|
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 =
|
|
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`);
|