@allocator-one/harness-cli 1.11.0 → 1.12.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.
Files changed (3) hide show
  1. package/README.md +31 -0
  2. package/dist/cli.js +206 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,6 +39,37 @@ Headless contexts (agents, CI): mint an `aone_…` API token (admin → API
39
39
  tokens) and set `HARNESS_API_TOKEN`. Do not set it in interactive sessions —
40
40
  the browser login attributes writes to the real user.
41
41
 
42
+ ### Provider keys
43
+
44
+ Admins store the org's provider API keys (Anthropic, OpenAI, Google,
45
+ OpenRouter, Brave Search, Perplexity) in Harness under `Settings → Model
46
+ keys`. The CLI hands them to tooling without ever printing them:
47
+
48
+ - `harness keys list` — provider slugs the org has keys for and the env var
49
+ each maps to (`--json` for `{providers: [{provider, env}]}`); values are
50
+ never shown.
51
+ - `harness keys exec [--only <slugs>] [--override] -- <command> [args…]` —
52
+ runs the command with the keys in its environment and exits with the
53
+ command's exit code (128 + signal number when it was signalled). `--only
54
+ brave,perplexity` passes just those providers and withholds your shell's
55
+ own copies of the other provider variables, so the tool sees no more than
56
+ it needs; a requested provider the org has no key for keeps your shell's
57
+ variable (and says so on stderr). Slugs are trimmed and case-insensitive,
58
+ and an allowlist that names nothing (`--only ,`) is a usage error, not
59
+ "everything". Otherwise a variable already set in your shell wins unless
60
+ `--override` is given; a blank variable counts as unset. `--json` is
61
+ accepted for uniformity but changes nothing: stdout belongs to the command. A SIGTERM sent to
62
+ `harness` is forwarded to the command; Ctrl+C reaches the command from the
63
+ terminal directly and `harness` waits to report its exit. macOS and Linux
64
+ only.
65
+
66
+ Slug → variable, the same table rcl uses: anthropic → `ANTHROPIC_API_KEY`,
67
+ openai → `OPENAI_API_KEY`, google → `GEMINI_API_KEY`, openrouter →
68
+ `OPENROUTER_API_KEY`, brave → `BRAVE_API_KEY`, perplexity →
69
+ `PERPLEXITY_API_KEY`. Reading keys needs the `model-keys:read` scope: login
70
+ tokens carry it, `aone_` tokens must be minted with it. There is deliberately
71
+ no MCP tool for this — a tool result is model-visible text.
72
+
42
73
  ## Environment variables
43
74
 
44
75
  | Variable | Effect |
package/dist/cli.js CHANGED
@@ -88,6 +88,188 @@ import {
88
88
  import { createInterface as createInterface2 } from "readline";
89
89
  import { Command, CommanderError, InvalidArgumentError } from "commander";
90
90
 
91
+ // src/commands/keys.ts
92
+ import { spawn } from "child_process";
93
+ import { constants } from "os";
94
+ var KEY_ENV_VARS = /* @__PURE__ */ new Map([
95
+ ["anthropic", "ANTHROPIC_API_KEY"],
96
+ ["openai", "OPENAI_API_KEY"],
97
+ ["google", "GEMINI_API_KEY"],
98
+ ["openrouter", "OPENROUTER_API_KEY"],
99
+ ["brave", "BRAVE_API_KEY"],
100
+ ["perplexity", "PERPLEXITY_API_KEY"]
101
+ ]);
102
+ function isRecord(value) {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
105
+ async function fetchKeys(ctx) {
106
+ let body;
107
+ try {
108
+ body = await ctx.client.get("/api/v1/model-keys");
109
+ } catch (error) {
110
+ if (error instanceof HttpError && error.status === 403 && error.message.includes("model-keys:read")) {
111
+ throw new CommandError(
112
+ `${error.message} \u2014 login tokens carry model-keys:read; aone_ API tokens must be minted with it`
113
+ );
114
+ }
115
+ throw error;
116
+ }
117
+ const data = isRecord(body) ? body["data"] : void 0;
118
+ const keys2 = isRecord(data) ? data["keys"] : void 0;
119
+ if (!isRecord(keys2)) throw new CommandError("unexpected model-keys response: no keys map");
120
+ const stored = /* @__PURE__ */ new Map();
121
+ for (const [slug, value] of Object.entries(keys2)) {
122
+ const canonical = slug.trim().toLowerCase();
123
+ if (stored.has(canonical)) continue;
124
+ if (typeof value === "string" && value.trim() !== "" && !value.includes("\0")) stored.set(canonical, value);
125
+ }
126
+ return stored;
127
+ }
128
+ function envVarFor(slug) {
129
+ return KEY_ENV_VARS.get(slug.trim().toLowerCase());
130
+ }
131
+ function normalizeSlugs(only) {
132
+ return [...new Set(only.map((slug) => slug.trim().toLowerCase()).filter((slug) => slug !== ""))];
133
+ }
134
+ function selectSlugs(stored, only) {
135
+ if (only === void 0 || only.length === 0) return new Map(stored);
136
+ const wanted = new Set(only.map((slug) => slug.trim().toLowerCase()).filter((slug) => slug !== ""));
137
+ return new Map([...stored].filter(([slug]) => wanted.has(slug)));
138
+ }
139
+ function unknownSlugs(only) {
140
+ return only.map((slug) => slug.trim()).filter((slug) => slug !== "" && !KEY_ENV_VARS.has(slug.toLowerCase()));
141
+ }
142
+ function withholdOthers(base, requested) {
143
+ const keep = new Set(requested.map((slug) => envVarFor(slug)));
144
+ const env = { ...base };
145
+ for (const name of KEY_ENV_VARS.values()) {
146
+ if (!keep.has(name)) delete env[name];
147
+ }
148
+ return env;
149
+ }
150
+ function unstoredSlugs(requested, stored) {
151
+ const canonical = [...new Set(requested.map((slug) => slug.trim().toLowerCase()).filter((slug) => slug !== ""))];
152
+ return canonical.filter((slug) => !stored.has(slug));
153
+ }
154
+ function toEnv(stored) {
155
+ const env = {};
156
+ for (const [slug, value] of stored) {
157
+ const name = envVarFor(slug);
158
+ if (name !== void 0) env[name] = value;
159
+ }
160
+ return env;
161
+ }
162
+ function childEnv(base, fetched, override) {
163
+ const env = {};
164
+ for (const [name, value] of Object.entries(base)) {
165
+ if (value !== void 0) env[name] = value;
166
+ }
167
+ for (const [name, value] of Object.entries(fetched)) {
168
+ const existing = env[name];
169
+ if (override || existing === void 0 || existing.trim() === "") env[name] = value;
170
+ }
171
+ return env;
172
+ }
173
+ function exitCodeFor(code, signal) {
174
+ if (code !== null) return code;
175
+ if (signal !== null) {
176
+ const number = constants.signals[signal];
177
+ if (typeof number === "number") return 128 + number;
178
+ }
179
+ return 1;
180
+ }
181
+ async function list2(ctx) {
182
+ const stored = await fetchKeys(ctx);
183
+ const providers = [...stored.keys()].sort().map((provider) => ({ provider, env: envVarFor(provider) ?? null }));
184
+ const human = providers.map(({ provider, env }) => `${clean(provider)} ${env ?? "(no env var)"}
185
+ `).join("");
186
+ return { json: { providers }, human };
187
+ }
188
+ var FORWARDED_SIGNALS = ["SIGTERM"];
189
+ var HELD_SIGNALS = ["SIGINT"];
190
+ async function exec(ctx, opts) {
191
+ const [program2, ...args] = opts.command;
192
+ if (program2 === void 0 || program2 === "") {
193
+ throw new UsageError("keys exec: name the command to run after `--`, e.g. `harness keys exec -- npm test`");
194
+ }
195
+ if (process.platform === "win32") {
196
+ throw new LocalError("keys exec is available on macOS and Linux only");
197
+ }
198
+ const onlyGiven = opts.only !== void 0 && opts.only.length > 0;
199
+ if (onlyGiven && normalizeSlugs(opts.only ?? []).length === 0) {
200
+ throw new UsageError(`keys exec: --only needs at least one provider slug \u2014 known: ${[...KEY_ENV_VARS.keys()].join(", ")}`);
201
+ }
202
+ const unknown = unknownSlugs(opts.only ?? []);
203
+ if (unknown.length > 0) {
204
+ throw new UsageError(
205
+ `keys exec: unknown provider slug ${unknown.map(clean).join(", ")} \u2014 known: ${[...KEY_ENV_VARS.keys()].join(", ")}`
206
+ );
207
+ }
208
+ const stored = await fetchKeys(ctx);
209
+ const requested = normalizeSlugs(opts.only ?? []);
210
+ const narrowed = requested.length > 0;
211
+ if (narrowed) {
212
+ const missing = unstoredSlugs(requested, stored);
213
+ if (missing.length > 0) {
214
+ process.stderr.write(
215
+ `harness: no key stored for ${missing.join(", ")} \u2014 the command gets your shell's own variable for those, if any
216
+ `
217
+ );
218
+ }
219
+ }
220
+ const env = childEnv(
221
+ narrowed ? withholdOthers(process.env, requested) : process.env,
222
+ toEnv(selectSlugs(stored, requested)),
223
+ opts.override
224
+ );
225
+ const name = clean(program2);
226
+ const exitCode = await new Promise((resolve, reject) => {
227
+ let child;
228
+ try {
229
+ child = spawn(program2, args, { stdio: "inherit", env });
230
+ } catch (error) {
231
+ const code = error.code ?? "invalid arguments";
232
+ reject(new CommandError(`cannot start ${name}: ${code}`));
233
+ return;
234
+ }
235
+ const handlers = [];
236
+ for (const signal of FORWARDED_SIGNALS) {
237
+ const handler = () => {
238
+ child.kill(signal);
239
+ };
240
+ process.on(signal, handler);
241
+ handlers.push([signal, handler]);
242
+ }
243
+ for (const signal of HELD_SIGNALS) {
244
+ const handler = () => {
245
+ };
246
+ process.on(signal, handler);
247
+ handlers.push([signal, handler]);
248
+ }
249
+ let settled = false;
250
+ let spawned = false;
251
+ const settle = (outcome) => {
252
+ if (settled) return;
253
+ settled = true;
254
+ for (const [signal, handler] of handlers) process.off(signal, handler);
255
+ outcome();
256
+ };
257
+ child.once("spawn", () => {
258
+ spawned = true;
259
+ });
260
+ child.on("error", (error) => {
261
+ settle(() => {
262
+ const code = error.code ?? error.message;
263
+ reject(new CommandError(spawned ? `${name} failed: ${code}` : `cannot run ${name}: ${code}`));
264
+ });
265
+ });
266
+ child.on("exit", (code, signal) => {
267
+ settle(() => resolve(exitCodeFor(code, signal)));
268
+ });
269
+ });
270
+ return { json: { exit_code: exitCode }, human: "", exitCode };
271
+ }
272
+
91
273
  // src/core/auth.ts
92
274
  import { createHash, randomBytes } from "crypto";
93
275
  import { execFile } from "child_process";
@@ -744,10 +926,10 @@ function guardBrokenPipes(streams = [process.stdout, process.stderr]) {
744
926
  }
745
927
 
746
928
  // src/core/upgrade.ts
747
- import { spawn, spawnSync } from "child_process";
929
+ import { spawn as spawn2, spawnSync } from "child_process";
748
930
  import {
749
931
  accessSync as accessSync2,
750
- constants,
932
+ constants as constants2,
751
933
  existsSync,
752
934
  linkSync,
753
935
  mkdirSync,
@@ -925,7 +1107,7 @@ function spawnRefresh() {
925
1107
  const self = process.argv[1];
926
1108
  if (self === void 0 || self === "") return;
927
1109
  try {
928
- const child = spawn(process.execPath, [self, UPDATE_CHECK_COMMAND], {
1110
+ const child = spawn2(process.execPath, [self, UPDATE_CHECK_COMMAND], {
929
1111
  detached: true,
930
1112
  stdio: "ignore"
931
1113
  });
@@ -1046,7 +1228,7 @@ function npmInstallGlobal(install, stillHeld) {
1046
1228
  };
1047
1229
  }
1048
1230
  try {
1049
- accessSync2(globalRoot, constants.W_OK);
1231
+ accessSync2(globalRoot, constants2.W_OK);
1050
1232
  } catch {
1051
1233
  return {
1052
1234
  ok: false,
@@ -1328,7 +1510,7 @@ async function execute(config, jsonFlag, mutating, run) {
1328
1510
  process.stderr.write(hostNotice);
1329
1511
  if (output.notice !== void 0) process.stderr.write(output.notice);
1330
1512
  process.stdout.write(jsonFlag ? json(output.json) : output.human);
1331
- return exitWith(output.partial === true ? 1 : 0);
1513
+ return exitWith(output.exitCode ?? (output.partial === true ? 1 : 0));
1332
1514
  } catch (error) {
1333
1515
  return fail(error, hostNotice);
1334
1516
  }
@@ -1914,6 +2096,25 @@ program.command("mcp").description("run the MCP server over stdio (for agents)")
1914
2096
  const { startMcpServer } = await import("./server-5YNUXJO2.js");
1915
2097
  await startMcpServer();
1916
2098
  });
2099
+ var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
2100
+ keys.command("list").description("provider slugs the org has keys for and the env var each maps to \u2014 never the values").option("--json", "emit JSON").action(async (opts) => {
2101
+ await withConfig(opts.json === true, false, (ctx) => list2(ctx));
2102
+ });
2103
+ keys.command("exec").description(
2104
+ "run a command with the org's keys in its environment and exit with its exit code: harness keys exec [--override] -- <command> [args...]"
2105
+ ).argument("[command...]", "the command and its arguments").option("--override", "let fetched keys replace variables already set in this shell").option(
2106
+ "--only <slugs>",
2107
+ "pass only these provider slugs (comma-separated, repeatable); default: everything stored",
2108
+ collect,
2109
+ []
2110
+ ).option("--json", "accepted for uniformity; stdout belongs to the command, so it changes nothing").passThroughOptions().action(async (command, opts) => {
2111
+ const only = opts.only.flatMap((value) => value.split(","));
2112
+ await withConfig(
2113
+ false,
2114
+ false,
2115
+ (ctx) => exec(ctx, { command, override: opts.override === true, only })
2116
+ );
2117
+ });
1917
2118
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(
1918
2119
  "--timeout <ms>",
1919
2120
  "how long to wait for the browser callback",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",