@opennous/cli 0.18.1 → 0.19.0
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/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/backfill.js +21 -7
- package/src/index.js +56 -16
- package/src/runner.js +13 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @opennous/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Added: read on your own API key
|
|
6
|
+
|
|
7
|
+
`runner install --api-key` reads on your own Anthropic or OpenAI API key instead of your plan. It
|
|
8
|
+
takes the key from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or asks for it without echoing it, and
|
|
9
|
+
keeps it in `~/.nous/config.json`, which only you can read now. Each call gets only that key, for
|
|
10
|
+
that agent. The app shows the runner as reading with an API key (GET-1310).
|
|
11
|
+
|
|
12
|
+
## 0.18.2
|
|
13
|
+
|
|
14
|
+
### Fixed: the runner service finds your Mac's name
|
|
15
|
+
|
|
16
|
+
Started at login, the runner couldn't find `scutil`, so it named your Mac by its network hostname
|
|
17
|
+
instead of its own name. It now calls it by its full path, and saves nothing (GET-1308).
|
|
18
|
+
|
|
3
19
|
## 0.18.1
|
|
4
20
|
|
|
5
21
|
### Fixed: one machine is one runner
|
package/package.json
CHANGED
package/src/backfill.js
CHANGED
|
@@ -120,6 +120,11 @@ export function resetAt(message, now = Date.now()) {
|
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** The agent CLIs installed here, signed in or not: an API key needs only the program (GET-1310). */
|
|
124
|
+
export function installedAgents(run = spawnSync) {
|
|
125
|
+
return ["claude", "codex"].filter((a) => run(a, ["--version"], { encoding: "utf8" }).status === 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
123
128
|
export function detectAgents(run = spawnSync) {
|
|
124
129
|
const found = [];
|
|
125
130
|
if (run("claude", ["auth", "status"], { encoding: "utf8" }).status === 0) found.push("claude");
|
|
@@ -127,16 +132,24 @@ export function detectAgents(run = spawnSync) {
|
|
|
127
132
|
return found;
|
|
128
133
|
}
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
const KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The environment a call runs in. Without a key: none of the API key variables, so the call runs
|
|
139
|
+
* on the plan, not a key billed by the token. With one (GET-1310): only that key, for that agent,
|
|
140
|
+
* so the call is billed to the person's own API account, whatever else is set. Pure.
|
|
141
|
+
*/
|
|
142
|
+
export function callEnv({ agent = null, apiKey = null } = {}, env = process.env) {
|
|
132
143
|
const clean = { ...env, DISABLE_AUTOUPDATER: "1" };
|
|
133
|
-
for (const k of
|
|
144
|
+
for (const k of KEY_VARS) delete clean[k];
|
|
145
|
+
if (apiKey && agent === "claude") clean.ANTHROPIC_API_KEY = apiKey;
|
|
146
|
+
if (apiKey && agent === "codex") { clean.CODEX_API_KEY = apiKey; clean.OPENAI_API_KEY = apiKey; }
|
|
134
147
|
return clean;
|
|
135
148
|
}
|
|
136
149
|
|
|
137
|
-
function runCommand({ cmd, args, input }, cwd) {
|
|
150
|
+
function runCommand({ cmd, args, input }, cwd, env = callEnv()) {
|
|
138
151
|
return new Promise((resolve, reject) => {
|
|
139
|
-
const p = spawn(cmd, args, { cwd, env
|
|
152
|
+
const p = spawn(cmd, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
140
153
|
let out = "", err = "";
|
|
141
154
|
p.stdout.on("data", (d) => { out += d; });
|
|
142
155
|
p.stderr.on("data", (d) => { err += d; });
|
|
@@ -255,9 +268,10 @@ export async function runBackfill({ api, agent, jobId, run, sleep, log, now = Da
|
|
|
255
268
|
}
|
|
256
269
|
|
|
257
270
|
/** The real pieces for runBackfill. */
|
|
258
|
-
export function liveRun() {
|
|
271
|
+
export function liveRun({ agent = null, apiKey = null } = {}) {
|
|
259
272
|
const cwd = mkdtempSync(join(tmpdir(), "nous-backfill-"));
|
|
260
|
-
|
|
273
|
+
const env = callEnv({ agent, apiKey });
|
|
274
|
+
return (command) => runCommand(command, cwd, env);
|
|
261
275
|
}
|
|
262
276
|
|
|
263
277
|
/**
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// the 7 MCP primitives — the CLI never duplicates them.
|
|
8
8
|
|
|
9
9
|
import { program, Command } from "commander";
|
|
10
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "fs";
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, chmodSync } from "fs";
|
|
11
11
|
import { homedir, hostname } from "os";
|
|
12
12
|
import { join, resolve, dirname, relative, sep } from "path";
|
|
13
13
|
import { createInterface } from "readline";
|
|
@@ -104,7 +104,9 @@ function writeConfig(cfg) {
|
|
|
104
104
|
if (!existsSync(dir)) {
|
|
105
105
|
mkdirSync(dir, { recursive: true });
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
// Only you can read it: it holds your Ownable key, and a runner's model API key (GET-1310).
|
|
108
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
109
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on filesystems without modes */ }
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/** A key is there to use: from the environment, or saved by a sign-in. */
|
|
@@ -655,22 +657,28 @@ program
|
|
|
655
657
|
// starts it at login; the app's "Your plan" page shows it as connected once it has polled.
|
|
656
658
|
const runnerCmd = program
|
|
657
659
|
.command("runner")
|
|
658
|
-
.description("Keep reading your new calls and email on your own Claude or ChatGPT plan")
|
|
660
|
+
.description("Keep reading your new calls and email on your own Claude or ChatGPT plan, or your own API key")
|
|
659
661
|
.option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
|
|
662
|
+
.option("--api-key", "read on your own Anthropic or OpenAI API key instead of your plan")
|
|
660
663
|
.option("--workspace <id>", "the workspace to read; the app's command fills this in")
|
|
661
664
|
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
662
665
|
.action(async (opts) => {
|
|
663
|
-
const { detectAgents, liveRun, NO_AGENT } = await import("./backfill.js");
|
|
666
|
+
const { detectAgents, installedAgents, liveRun, NO_AGENT } = await import("./backfill.js");
|
|
664
667
|
const { runRunner } = await import("./runner.js");
|
|
665
|
-
const agent = await pickAgent(opts.agent, detectAgents, NO_AGENT);
|
|
668
|
+
const agent = await pickAgent(opts.agent, opts.apiKey ? installedAgents : detectAgents, NO_AGENT);
|
|
669
|
+
const apiKey = opts.apiKey ? savedModelKey(agent) : null;
|
|
670
|
+
if (opts.apiKey && !apiKey) {
|
|
671
|
+
console.error(`No ${agent === "codex" ? "OpenAI" : "Anthropic"} API key saved. Run \`npx @opennous/cli@latest runner install --api-key\` first.`);
|
|
672
|
+
process.exit(1);
|
|
673
|
+
}
|
|
666
674
|
const host = await runnerHost();
|
|
667
675
|
const key = await runnerKey(opts);
|
|
668
676
|
const api = apiClient(key);
|
|
669
677
|
const service = process.env.NOUS_RUNNER_SERVICE === "1";
|
|
670
678
|
const stamp = (line) => (service ? `${new Date().toISOString()} ${line}` : line);
|
|
671
|
-
console.log(stamp(`Reading on your ${agent} plan. ${service ? "" : "Stop with Ctrl-C; `nous runner install` keeps it going on its own."}`.trim()));
|
|
679
|
+
console.log(stamp(`Reading on your ${apiKey ? `${agent === "codex" ? "OpenAI" : "Anthropic"} API key` : `${agent} plan`}. ${service ? "" : "Stop with Ctrl-C; `nous runner install` keeps it going on its own."}`.trim()));
|
|
672
680
|
await runRunner({
|
|
673
|
-
agent, host, version, run: liveRun(), log: (l) => console.log(stamp(l)),
|
|
681
|
+
agent, host, version, auth: apiKey ? "api_key" : "plan", run: liveRun({ agent, apiKey }), log: (l) => console.log(stamp(l)),
|
|
674
682
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
675
683
|
api: {
|
|
676
684
|
poll: (body) => api.softPost("/v2/runner/poll", body),
|
|
@@ -684,12 +692,15 @@ runnerCmd
|
|
|
684
692
|
.command("install")
|
|
685
693
|
.description("Start the runner now and every time you log in")
|
|
686
694
|
.option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
|
|
695
|
+
.option("--api-key", "read on your own Anthropic or OpenAI API key instead of your plan (asked for once, kept only on this machine)")
|
|
687
696
|
.option("--workspace <id>", "the workspace to read; the app's command fills this in")
|
|
688
697
|
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
689
698
|
.action(async (opts) => {
|
|
690
|
-
const { detectAgents, NO_AGENT } = await import("./backfill.js");
|
|
699
|
+
const { detectAgents, installedAgents, NO_AGENT } = await import("./backfill.js");
|
|
691
700
|
const { installService } = await import("./runner.js");
|
|
692
|
-
const agent = await pickAgent(opts.agent, detectAgents, NO_AGENT);
|
|
701
|
+
const agent = await pickAgent(opts.agent, opts.apiKey ? installedAgents : detectAgents, NO_AGENT);
|
|
702
|
+
// Your own API key (GET-1310): from the environment, or asked for now, and kept in ~/.nous (yours only).
|
|
703
|
+
if (opts.apiKey) await saveModelKey(agent);
|
|
693
704
|
const key = await runnerKey(opts);
|
|
694
705
|
// One poll before installing, so a bad key or plan fails here and not silently in the background.
|
|
695
706
|
try {
|
|
@@ -699,8 +710,9 @@ runnerCmd
|
|
|
699
710
|
process.exit(1);
|
|
700
711
|
}
|
|
701
712
|
try {
|
|
702
|
-
const where = installService({ agent, workspace: opts.workspace ?? null });
|
|
703
|
-
|
|
713
|
+
const where = installService({ agent, workspace: opts.workspace ?? null, apiKey: !!opts.apiKey });
|
|
714
|
+
const on = opts.apiKey ? `your ${agent === "codex" ? "OpenAI" : "Anthropic"} API key` : `your ${agent} plan`;
|
|
715
|
+
console.log(`Connected. Your calls and email are read on ${on}: your history first, then everything new. It starts every time you log in.`);
|
|
704
716
|
if (where.log) console.log(`Log: ${where.log}`);
|
|
705
717
|
console.log("Check on it with `npx @opennous/cli@latest runner status`. Stop it with `npx @opennous/cli@latest runner uninstall`.");
|
|
706
718
|
} catch (e) {
|
|
@@ -731,13 +743,41 @@ runnerCmd
|
|
|
731
743
|
if (s.log) console.log(`Log: ${s.log}`);
|
|
732
744
|
});
|
|
733
745
|
|
|
734
|
-
|
|
746
|
+
const KEY_ENV = { claude: "ANTHROPIC_API_KEY", codex: "OPENAI_API_KEY" };
|
|
747
|
+
|
|
748
|
+
/** The model API key this machine's runner reads with, or null (GET-1310). */
|
|
749
|
+
function savedModelKey(agent) {
|
|
750
|
+
return readConfig().runnerKeys?.[agent] ?? null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Take the key from the environment, or ask for it without echoing it, and keep it in ~/.nous. */
|
|
754
|
+
async function saveModelKey(agent) {
|
|
755
|
+
let value = process.env[KEY_ENV[agent]] || "";
|
|
756
|
+
if (!value) {
|
|
757
|
+
const label = agent === "codex" ? "OpenAI" : "Anthropic";
|
|
758
|
+
process.stdout.write(`${label} API key (kept only on this machine): `);
|
|
759
|
+
value = await new Promise((resolve) => {
|
|
760
|
+
const stdin = process.stdin;
|
|
761
|
+
let typed = "";
|
|
762
|
+
stdin.setRawMode?.(true); stdin.resume(); stdin.setEncoding("utf8");
|
|
763
|
+
const onData = (ch) => {
|
|
764
|
+
if (ch === "\r" || ch === "\n" || ch === "\u0004") { stdin.setRawMode?.(false); stdin.pause(); stdin.off("data", onData); process.stdout.write("\n"); resolve(typed.trim()); }
|
|
765
|
+
else if (ch === "\u0003") { process.stdout.write("\n"); process.exit(130); }
|
|
766
|
+
else if (ch === "\u007f") typed = typed.slice(0, -1);
|
|
767
|
+
else typed += ch;
|
|
768
|
+
};
|
|
769
|
+
stdin.on("data", onData);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
if (!value) { console.error("No key given."); process.exit(1); }
|
|
773
|
+
const cfg = readConfig();
|
|
774
|
+
writeConfig({ ...cfg, runnerKeys: { ...cfg.runnerKeys, [agent]: value } });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** This machine's name for its runner, the same every run (GET-1308). */
|
|
735
778
|
async function runnerHost() {
|
|
736
779
|
const { machineName } = await import("./runner.js");
|
|
737
|
-
|
|
738
|
-
const name = machineName({ saved: cfg.runnerHost ?? null });
|
|
739
|
-
if (name !== cfg.runnerHost) writeConfig({ ...cfg, runnerHost: name });
|
|
740
|
-
return name;
|
|
780
|
+
return machineName();
|
|
741
781
|
}
|
|
742
782
|
|
|
743
783
|
/** The agent to read with: the one asked for, or whichever is signed in. Exits with what to do. */
|
package/src/runner.js
CHANGED
|
@@ -37,12 +37,14 @@ export const RUN_COMMAND = "npx -y @opennous/cli@latest runner";
|
|
|
37
37
|
/**
|
|
38
38
|
* This machine's name for Ownable (GET-1308), the same every run. `os.hostname()` on a Mac follows
|
|
39
39
|
* the network ("Mac.lan" on one, "Bennets-MacBook-Air.local" on another), which made one machine
|
|
40
|
-
* two runners. The Mac's own name (System Settings → General → About) is
|
|
40
|
+
* two runners. The Mac's own name (System Settings → General → About) is the same every run, so it
|
|
41
|
+
* is asked each time; elsewhere the hostname is stable. Nothing is saved: 0.18.1 saved a fallback.
|
|
41
42
|
*/
|
|
42
43
|
export function machineName({ saved = null, os = platform(), run = spawnSync, host = hostname } = {}) {
|
|
43
44
|
if (saved) return saved;
|
|
44
45
|
if (os === "darwin") {
|
|
45
|
-
|
|
46
|
+
// By its full path: a launchd service's PATH has no /usr/sbin (GET-1308, 0.18.2).
|
|
47
|
+
const r = run("/usr/sbin/scutil", ["--get", "ComputerName"], { encoding: "utf8" });
|
|
46
48
|
const name = r.status === 0 ? String(r.stdout).trim() : "";
|
|
47
49
|
if (name) return name;
|
|
48
50
|
}
|
|
@@ -73,7 +75,7 @@ export function pauseLine(agent, reason, until, now = Date.now()) {
|
|
|
73
75
|
* The loop. Injected: `api` ({ poll, answer }), `run` (one command → stdout), `sleep`, `log`.
|
|
74
76
|
* `stop()` → true ends it (tests; the real one runs until the process is stopped).
|
|
75
77
|
*/
|
|
76
|
-
export async function runRunner({ api, agent, host = hostname(), version = null, run, sleep, log, now = Date.now,
|
|
78
|
+
export async function runRunner({ api, agent, host = hostname(), version = null, auth = "plan", run, sleep, log, now = Date.now,
|
|
77
79
|
workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
|
|
78
80
|
let limitedUntil = 0, limitReason = null, answered = 0, lastLine = null, lastError = null, backfillAt = -Infinity;
|
|
79
81
|
const answerOne = async (call, models) => {
|
|
@@ -104,7 +106,7 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
104
106
|
let page;
|
|
105
107
|
const asked = now();
|
|
106
108
|
try {
|
|
107
|
-
page = await api.poll({ agent, host, version, answered, limit: workers, wait_ms: WAIT_MS,
|
|
109
|
+
page = await api.poll({ agent, host, version, auth, answered, limit: workers, wait_ms: WAIT_MS,
|
|
108
110
|
limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null,
|
|
109
111
|
limit_reason: limitedUntil > now() ? limitReason : null });
|
|
110
112
|
if (lastError) { log("Back online."); lastError = null; }
|
|
@@ -159,8 +161,8 @@ export function servicePath(binaries) {
|
|
|
159
161
|
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
160
162
|
|
|
161
163
|
/** The launchd agent for macOS. Pure. */
|
|
162
|
-
export function launchdPlist({ npx, agent, workspace = null, path, log }) {
|
|
163
|
-
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
|
|
164
|
+
export function launchdPlist({ npx, agent, workspace = null, apiKey = false, path, log }) {
|
|
165
|
+
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : []), ...(apiKey ? ["--api-key"] : [])];
|
|
164
166
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
165
167
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
166
168
|
<plist version="1.0">
|
|
@@ -186,8 +188,8 @@ ${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
/** The systemd user unit for Linux. Pure. */
|
|
189
|
-
export function systemdUnit({ npx, agent, workspace = null, path }) {
|
|
190
|
-
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
|
|
191
|
+
export function systemdUnit({ npx, agent, workspace = null, apiKey = false, path }) {
|
|
192
|
+
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : []), ...(apiKey ? ["--api-key"] : [])];
|
|
191
193
|
return `[Unit]
|
|
192
194
|
Description=Ownable runner: reads on your own ${agent === "codex" ? "ChatGPT" : "Claude"} plan
|
|
193
195
|
After=network-online.target
|
|
@@ -212,7 +214,7 @@ export function servicePaths(os = platform(), home = homedir()) {
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
/** Install and start the runner as a service. Returns { kind, file, log } or throws with what to do. */
|
|
215
|
-
export function installService({ agent, workspace = null, run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
217
|
+
export function installService({ agent, workspace = null, apiKey = false, run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
216
218
|
const where = servicePaths(os, home);
|
|
217
219
|
if (!where) throw new Error(`Starting at login isn't supported on ${os} yet. Run \`${RUN_COMMAND}\` in a terminal you keep open instead.`);
|
|
218
220
|
const npx = which("npx", run);
|
|
@@ -221,12 +223,12 @@ export function installService({ agent, workspace = null, run = spawnSync, os =
|
|
|
221
223
|
mkdirSync(dirname(where.file), { recursive: true });
|
|
222
224
|
if (where.kind === "launchd") {
|
|
223
225
|
mkdirSync(dirname(where.log), { recursive: true });
|
|
224
|
-
writeFileSync(where.file, launchdPlist({ npx, agent, workspace, path, log: where.log }));
|
|
226
|
+
writeFileSync(where.file, launchdPlist({ npx, agent, workspace, apiKey, path, log: where.log }));
|
|
225
227
|
run("launchctl", ["unload", where.file], { stdio: "ignore" });
|
|
226
228
|
const r = run("launchctl", ["load", "-w", where.file], { encoding: "utf8" });
|
|
227
229
|
if (r.status !== 0) throw new Error(`launchctl couldn't start it: ${(r.stderr || "").trim()}`);
|
|
228
230
|
} else {
|
|
229
|
-
writeFileSync(where.file, systemdUnit({ npx, agent, workspace, path }));
|
|
231
|
+
writeFileSync(where.file, systemdUnit({ npx, agent, workspace, apiKey, path }));
|
|
230
232
|
run("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
231
233
|
const r = run("systemctl", ["--user", "enable", "--now", "opennous-runner.service"], { encoding: "utf8" });
|
|
232
234
|
if (r.status !== 0) throw new Error(`systemd couldn't start it: ${(r.stderr || "").trim()}`);
|