@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
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,13 +743,41 @@ runnerCmd
731
743
  if (s.log) console.log(`Log: ${s.log}`);
732
744
  });
733
745
 
734
- /** This machine's name for its runner, kept in the config so it is the same every run (GET-1308). */
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
- const cfg = readConfig();
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 asked once and kept.
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
- const r = run("scutil", ["--get", "ComputerName"], { encoding: "utf8" });
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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()}`);