@allocator-one/harness-cli 1.11.0 → 1.12.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/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 |
@@ -75,7 +106,9 @@ Read the same mixed timeline shown in an issue's Activity section with
75
106
  `harness activity IO-123`. It returns the newest 20 entries and points out
76
107
  when older entries exist; pass `--all` to follow the opaque cursors through
77
108
  the full history. `activity_list` exposes the same `id`, `limit`, and `all`
78
- inputs to MCP clients.
109
+ inputs to MCP clients. Assignment, reassignment, and unassignment entries show
110
+ the actor and previous/new assignees when supplied by the server. Unavailable
111
+ identities use safe labels rather than raw user ids.
79
112
 
80
113
  Typed issue relations are available without losing the concise blocking
81
114
  workflow. `harness dep add|rm` and the `relation_add`/`relation_rm` MCP tools
@@ -270,6 +270,8 @@ function activityEntry(entry) {
270
270
  return activityComment(entry);
271
271
  case "state_event":
272
272
  return activityStateEvent(entry);
273
+ case "assignee_event":
274
+ return activityAssigneeEvent(entry);
273
275
  default:
274
276
  throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
275
277
  }
@@ -311,6 +313,31 @@ function activityStateEvent(entry) {
311
313
  return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} \xB7 ${provenance}
312
314
  `;
313
315
  }
316
+ function activityAssigneeEvent(entry) {
317
+ const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
318
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
319
+ let description;
320
+ switch (entry["action"]) {
321
+ case "assigned":
322
+ description = `${actor} assigned the issue to ${assigneeName(entry["to_assignee"])}`;
323
+ break;
324
+ case "reassigned":
325
+ description = `${actor} reassigned the issue from ${assigneeName(entry["from_assignee"])} to ${assigneeName(entry["to_assignee"])}`;
326
+ break;
327
+ case "unassigned":
328
+ description = `${actor} unassigned the issue from ${assigneeName(entry["from_assignee"])}`;
329
+ break;
330
+ default:
331
+ throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
332
+ }
333
+ return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} - ${provenance}
334
+ `;
335
+ }
336
+ function assigneeName(value) {
337
+ const assignee = requiredRecord(value, "activity assignee");
338
+ if (assignee["kind"] === "unknown") return "Unknown assignee";
339
+ return activityActor(assignee);
340
+ }
314
341
  function activityActor(actor) {
315
342
  switch (actor["kind"]) {
316
343
  case "user":
package/dist/cli.js CHANGED
@@ -82,12 +82,194 @@ import {
82
82
  version,
83
83
  workflowStates,
84
84
  writeCredentials
85
- } from "./chunk-WY6F3IJB.js";
85
+ } from "./chunk-VOH6BBJD.js";
86
86
 
87
87
  // src/cli.ts
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
  }
@@ -1435,7 +1617,7 @@ program.command("show").description("full issue detail; downloads attached image
1435
1617
  });
1436
1618
  }
1437
1619
  );
1438
- program.command("activity").description("mixed issue activity: comments, creation, and state changes").argument("[id]").option(
1620
+ program.command("activity").description("mixed issue activity: comments, creation, state and assignee changes").argument("[id]").option(
1439
1621
  "--limit <n>",
1440
1622
  `entries per page (default 20, max ${ACTIVITY_MAX_LIMIT})`,
1441
1623
  activityLimit
@@ -1911,9 +2093,28 @@ program.command("upload").description("upload an image (25 MB) or H.264 MP4 vide
1911
2093
  });
1912
2094
  });
1913
2095
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1914
- const { startMcpServer } = await import("./server-5YNUXJO2.js");
2096
+ const { startMcpServer } = await import("./server-FNNMISOZ.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",
@@ -61,7 +61,7 @@ import {
61
61
  upload,
62
62
  userList,
63
63
  workflowStates
64
- } from "./chunk-WY6F3IJB.js";
64
+ } from "./chunk-VOH6BBJD.js";
65
65
 
66
66
  // src/mcp/server.ts
67
67
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -165,7 +165,7 @@ var TOOLS = [
165
165
  },
166
166
  {
167
167
  name: "activity_list",
168
- description: "Mixed issue activity: comments, creation, and state changes. Returns the newest page unless all=true.",
168
+ description: "Mixed issue activity: comments, creation, state and assignee changes. Returns the newest page unless all=true.",
169
169
  readOnly: true,
170
170
  shape: {
171
171
  id: identifier,
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.1",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/spec/openapi.json CHANGED
@@ -1297,7 +1297,7 @@
1297
1297
  "get": {
1298
1298
  "callbacks": {},
1299
1299
  "deprecated": false,
1300
- "description": "Requires the `harness:read` scope. The newest mixed page of comments and issue-state events, returned oldest-first. `has_more` means older entries exist. Pass the opaque `next_cursor` back as `before`; cursors are bound to the issue and viewer and expire after 24 hours.",
1300
+ "description": "Requires the `harness:read` scope. The newest mixed page of comments, issue-state events, and assignee changes, returned oldest-first. `has_more` means older entries exist. Pass the opaque `next_cursor` back as `before`; cursors are bound to the issue and viewer and expire after 24 hours.",
1301
1301
  "parameters": [
1302
1302
  {
1303
1303
  "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
@@ -1594,6 +1594,131 @@
1594
1594
  "to_state"
1595
1595
  ],
1596
1596
  "type": "object"
1597
+ },
1598
+ {
1599
+ "additionalProperties": false,
1600
+ "properties": {
1601
+ "action": {
1602
+ "enum": [
1603
+ "assigned",
1604
+ "reassigned",
1605
+ "unassigned"
1606
+ ],
1607
+ "type": "string"
1608
+ },
1609
+ "actor": {
1610
+ "properties": {
1611
+ "kind": {
1612
+ "enum": [
1613
+ "system",
1614
+ "user",
1615
+ "deleted_user",
1616
+ "unknown"
1617
+ ],
1618
+ "type": "string"
1619
+ },
1620
+ "name": {
1621
+ "nullable": true,
1622
+ "type": "string"
1623
+ }
1624
+ },
1625
+ "required": [
1626
+ "kind",
1627
+ "name"
1628
+ ],
1629
+ "type": "object"
1630
+ },
1631
+ "from_assignee": {
1632
+ "nullable": true,
1633
+ "properties": {
1634
+ "kind": {
1635
+ "enum": [
1636
+ "system",
1637
+ "user",
1638
+ "deleted_user",
1639
+ "unknown"
1640
+ ],
1641
+ "type": "string"
1642
+ },
1643
+ "name": {
1644
+ "nullable": true,
1645
+ "type": "string"
1646
+ }
1647
+ },
1648
+ "required": [
1649
+ "kind",
1650
+ "name"
1651
+ ],
1652
+ "type": "object"
1653
+ },
1654
+ "id": {
1655
+ "type": "string"
1656
+ },
1657
+ "kind": {
1658
+ "enum": [
1659
+ "assignee_event"
1660
+ ],
1661
+ "type": "string"
1662
+ },
1663
+ "occurred_at": {
1664
+ "format": "date-time",
1665
+ "type": "string"
1666
+ },
1667
+ "provenance": {
1668
+ "properties": {
1669
+ "kind": {
1670
+ "enum": [
1671
+ "live",
1672
+ "import",
1673
+ "backfill"
1674
+ ],
1675
+ "type": "string"
1676
+ },
1677
+ "witnessed": {
1678
+ "type": "boolean"
1679
+ }
1680
+ },
1681
+ "required": [
1682
+ "kind",
1683
+ "witnessed"
1684
+ ],
1685
+ "type": "object"
1686
+ },
1687
+ "to_assignee": {
1688
+ "nullable": true,
1689
+ "properties": {
1690
+ "kind": {
1691
+ "enum": [
1692
+ "system",
1693
+ "user",
1694
+ "deleted_user",
1695
+ "unknown"
1696
+ ],
1697
+ "type": "string"
1698
+ },
1699
+ "name": {
1700
+ "nullable": true,
1701
+ "type": "string"
1702
+ }
1703
+ },
1704
+ "required": [
1705
+ "kind",
1706
+ "name"
1707
+ ],
1708
+ "type": "object"
1709
+ }
1710
+ },
1711
+ "required": [
1712
+ "id",
1713
+ "kind",
1714
+ "occurred_at",
1715
+ "action",
1716
+ "actor",
1717
+ "provenance",
1718
+ "from_assignee",
1719
+ "to_assignee"
1720
+ ],
1721
+ "type": "object"
1597
1722
  }
1598
1723
  ]
1599
1724
  },