@opennous/cli 0.18.2 → 0.19.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/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/backfill.js +21 -7
- package/src/index.js +54 -11
- package/src/runner.js +13 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @opennous/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.1
|
|
4
|
+
|
|
5
|
+
### Changed: the first history read goes as far back as your workspace chose
|
|
6
|
+
|
|
7
|
+
The runner's own first read of your history used six months. It now uses the range picked in
|
|
8
|
+
onboarding: 3 months by default, or 6 or 12 (GET-1328).
|
|
9
|
+
|
|
10
|
+
## 0.19.0
|
|
11
|
+
|
|
12
|
+
### Added: read on your own API key
|
|
13
|
+
|
|
14
|
+
`runner install --api-key` reads on your own Anthropic or OpenAI API key instead of your plan. It
|
|
15
|
+
takes the key from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or asks for it without echoing it, and
|
|
16
|
+
keeps it in `~/.nous/config.json`, which only you can read now. Each call gets only that key, for
|
|
17
|
+
that agent. The app shows the runner as reading with an API key (GET-1310).
|
|
18
|
+
|
|
3
19
|
## 0.18.2
|
|
4
20
|
|
|
5
21
|
### Fixed: the runner service finds your Mac's name
|
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,6 +743,37 @@ runnerCmd
|
|
|
731
743
|
if (s.log) console.log(`Log: ${s.log}`);
|
|
732
744
|
});
|
|
733
745
|
|
|
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
|
+
|
|
734
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");
|
package/src/runner.js
CHANGED
|
@@ -75,7 +75,7 @@ export function pauseLine(agent, reason, until, now = Date.now()) {
|
|
|
75
75
|
* The loop. Injected: `api` ({ poll, answer }), `run` (one command → stdout), `sleep`, `log`.
|
|
76
76
|
* `stop()` → true ends it (tests; the real one runs until the process is stopped).
|
|
77
77
|
*/
|
|
78
|
-
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,
|
|
79
79
|
workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
|
|
80
80
|
let limitedUntil = 0, limitReason = null, answered = 0, lastLine = null, lastError = null, backfillAt = -Infinity;
|
|
81
81
|
const answerOne = async (call, models) => {
|
|
@@ -106,7 +106,7 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
106
106
|
let page;
|
|
107
107
|
const asked = now();
|
|
108
108
|
try {
|
|
109
|
-
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,
|
|
110
110
|
limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null,
|
|
111
111
|
limit_reason: limitedUntil > now() ? limitReason : null });
|
|
112
112
|
if (lastError) { log("Back online."); lastError = null; }
|
|
@@ -120,9 +120,11 @@ export async function runRunner({ api, agent, host = hostname(), version = null,
|
|
|
120
120
|
// read itself, on this plan, and answers its calls like any other.
|
|
121
121
|
if (page.needs_backfill && api.startBackfill && now() - backfillAt >= BACKFILL_RETRY_MS) {
|
|
122
122
|
backfillAt = now();
|
|
123
|
+
// How far back comes from the workspace (GET-1328: 3 months unless they chose 6 or 12).
|
|
124
|
+
const days = Number(page.backfill_days) > 0 ? Number(page.backfill_days) : BACKFILL_DAYS;
|
|
123
125
|
try {
|
|
124
|
-
await api.startBackfill({ days
|
|
125
|
-
log(`Reading your last ${
|
|
126
|
+
await api.startBackfill({ days, confirm: "yes" });
|
|
127
|
+
log(`Reading your last ${Math.round(days / 30)} months of calls and email on your plan. This can take a while; everything new is read after.`);
|
|
126
128
|
} catch (err) {
|
|
127
129
|
log(`Your history read can't start yet (${err.message}). Trying again in ${BACKFILL_RETRY_MS / 60000} minutes.`);
|
|
128
130
|
}
|
|
@@ -161,8 +163,8 @@ export function servicePath(binaries) {
|
|
|
161
163
|
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
162
164
|
|
|
163
165
|
/** The launchd agent for macOS. Pure. */
|
|
164
|
-
export function launchdPlist({ npx, agent, workspace = null, path, log }) {
|
|
165
|
-
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
|
|
166
|
+
export function launchdPlist({ npx, agent, workspace = null, apiKey = false, path, log }) {
|
|
167
|
+
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : []), ...(apiKey ? ["--api-key"] : [])];
|
|
166
168
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
167
169
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
168
170
|
<plist version="1.0">
|
|
@@ -188,8 +190,8 @@ ${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
/** The systemd user unit for Linux. Pure. */
|
|
191
|
-
export function systemdUnit({ npx, agent, workspace = null, path }) {
|
|
192
|
-
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
|
|
193
|
+
export function systemdUnit({ npx, agent, workspace = null, apiKey = false, path }) {
|
|
194
|
+
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : []), ...(apiKey ? ["--api-key"] : [])];
|
|
193
195
|
return `[Unit]
|
|
194
196
|
Description=Ownable runner: reads on your own ${agent === "codex" ? "ChatGPT" : "Claude"} plan
|
|
195
197
|
After=network-online.target
|
|
@@ -214,7 +216,7 @@ export function servicePaths(os = platform(), home = homedir()) {
|
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
/** Install and start the runner as a service. Returns { kind, file, log } or throws with what to do. */
|
|
217
|
-
export function installService({ agent, workspace = null, run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
219
|
+
export function installService({ agent, workspace = null, apiKey = false, run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
218
220
|
const where = servicePaths(os, home);
|
|
219
221
|
if (!where) throw new Error(`Starting at login isn't supported on ${os} yet. Run \`${RUN_COMMAND}\` in a terminal you keep open instead.`);
|
|
220
222
|
const npx = which("npx", run);
|
|
@@ -223,12 +225,12 @@ export function installService({ agent, workspace = null, run = spawnSync, os =
|
|
|
223
225
|
mkdirSync(dirname(where.file), { recursive: true });
|
|
224
226
|
if (where.kind === "launchd") {
|
|
225
227
|
mkdirSync(dirname(where.log), { recursive: true });
|
|
226
|
-
writeFileSync(where.file, launchdPlist({ npx, agent, workspace, path, log: where.log }));
|
|
228
|
+
writeFileSync(where.file, launchdPlist({ npx, agent, workspace, apiKey, path, log: where.log }));
|
|
227
229
|
run("launchctl", ["unload", where.file], { stdio: "ignore" });
|
|
228
230
|
const r = run("launchctl", ["load", "-w", where.file], { encoding: "utf8" });
|
|
229
231
|
if (r.status !== 0) throw new Error(`launchctl couldn't start it: ${(r.stderr || "").trim()}`);
|
|
230
232
|
} else {
|
|
231
|
-
writeFileSync(where.file, systemdUnit({ npx, agent, workspace, path }));
|
|
233
|
+
writeFileSync(where.file, systemdUnit({ npx, agent, workspace, apiKey, path }));
|
|
232
234
|
run("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
233
235
|
const r = run("systemctl", ["--user", "enable", "--now", "opennous-runner.service"], { encoding: "utf8" });
|
|
234
236
|
if (r.status !== 0) throw new Error(`systemd couldn't start it: ${(r.stderr || "").trim()}`);
|