@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
4
4
  "description": "Ownable CLI — set up Ownable, connect it to your coding agent, and read the Context API from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
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
- /** Without the API key variables, so the call runs on the plan, not a key billed by the token. */
131
- function planEnv(env = process.env) {
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 ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"]) delete clean[k];
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: planEnv(), stdio: ["pipe", "pipe", "pipe"] });
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
- return (command) => runCommand(command, cwd);
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
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
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
- console.log(`Connected. Your calls and email are read on your ${agent} plan: your history first, then everything new. It starts every time you log in.`);
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: BACKFILL_DAYS, confirm: "yes" });
125
- log(`Reading your last ${BACKFILL_DAYS / 30} months of calls and email on your plan. This can take a while; everything new is read after.`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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()}`);