@allocator-one/harness-cli 1.9.0 → 1.11.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/README.md CHANGED
@@ -52,7 +52,7 @@ the browser login attributes writes to the real user.
52
52
  ## For agents
53
53
 
54
54
  `harness mcp` runs a stdio MCP server exposing every operation as a typed
55
- tool — issue_list, issue_show, issue_create, issue_update, issue_claim,
55
+ tool — issue_list, issue_show, activity_list, issue_create, issue_update, issue_claim,
56
56
  issue_close, issue_comment, issue_comment_edit, issue_comment_delete,
57
57
  relation_list/create/update/delete, relation_add/rm (blocking shorthand),
58
58
  link_list/add/update/rm,
@@ -71,6 +71,12 @@ Example tool call: `issue_list` with `{"ready": true, "team": "ALL"}`
71
71
  returns the ready queue as JSON; `issue_claim` with `{"id": "IO-123"}`
72
72
  claims atomically (a conflict means someone else holds it).
73
73
 
74
+ Read the same mixed timeline shown in an issue's Activity section with
75
+ `harness activity IO-123`. It returns the newest 20 entries and points out
76
+ when older entries exist; pass `--all` to follow the opaque cursors through
77
+ the full history. `activity_list` exposes the same `id`, `limit`, and `all`
78
+ inputs to MCP clients.
79
+
74
80
  Typed issue relations are available without losing the concise blocking
75
81
  workflow. `harness dep add|rm` and the `relation_add`/`relation_rm` MCP tools
76
82
  remain the blocker-first shorthand; the full surface is:
@@ -141,6 +147,23 @@ error rather than a silent precedence, and only one field per invocation may
141
147
  read stdin. Both are checked before anything is read, so a rejected command
142
148
  never consumes your pipe. One trailing newline is dropped, like `$(cat file)`.
143
149
 
150
+ ## Image and video uploads
151
+
152
+ `harness upload ./demo.mp4` prints a reusable markdown reference. Paste it
153
+ into an issue description or comment to show an inline video player. Use
154
+ `--json` for the server's URL, filename, content type, byte size, and markdown.
155
+ The MCP `upload` tool accepts the same local path and returns the same metadata.
156
+
157
+ Images (PNG, JPG, WebP, GIF) can be up to 25 MB. Videos must be `.mp4`, up to
158
+ 100 MB, with H.264 8-bit 4:2:0 video, optional AAC audio, and optional mov_text
159
+ subtitles. The server validates the media; it does not transcode other formats.
160
+
161
+ A newly uploaded video is private to its uploader until that user saves its
162
+ link in an issue description or comment. It then follows the owning issue's
163
+ access policy. Reusing the link elsewhere does not change that ownership.
164
+ `harness show` continues downloading images only, never linked videos.
165
+ The older `attach-image` command retains its 25 MB limit.
166
+
144
167
  ## Repo config
145
168
 
146
169
  A committed `.harness-cli/config.json`, discovered git-style by walking up
@@ -181,10 +204,15 @@ CI regenerates the types and fails on drift.
181
204
 
182
205
  ## Release
183
206
 
184
- Tag a version and push — the Release workflow builds and publishes to npm:
207
+ First bump `package.json` and `package-lock.json` through a pull request. After
208
+ that PR is squash-merged, fetch `main` and tag the merge commit — never the
209
+ pre-merge branch tip. Pushing the tag starts the Release workflow, which builds,
210
+ tests, and publishes to npm:
185
211
 
186
212
  ```sh
187
- git tag v1.0.0 && git push origin v1.0.0
213
+ git fetch origin main
214
+ git tag -a vX.Y.Z MERGE_COMMIT_SHA -m "Release vX.Y.Z"
215
+ git push origin vX.Y.Z
188
216
  ```
189
217
 
190
218
  Publishing requires the `NPM_TOKEN` actions secret (an automation token for
@@ -166,11 +166,16 @@ var Client = class {
166
166
  throw new TransportError(transportMessage(error));
167
167
  }
168
168
  if (text === "") return { data: void 0 };
169
+ let parsed;
169
170
  try {
170
- return JSON.parse(text);
171
+ parsed = JSON.parse(text);
171
172
  } catch {
172
173
  throw new TransportError(`the API answered with a non-JSON body (HTTP ${response.status})`);
173
174
  }
175
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
176
+ throw new TransportError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
177
+ }
178
+ return parsed;
174
179
  }
175
180
  async toHttpError(response) {
176
181
  let parsed = {};
@@ -255,6 +260,101 @@ ${indent(String(text))}
255
260
  `).join("");
256
261
  return header + fieldLines + sectionText + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
257
262
  }
263
+ function activityEntries(entries) {
264
+ if (entries.length === 0) return "No activity.\n";
265
+ return entries.map(activityEntry).join("\n");
266
+ }
267
+ function activityEntry(entry) {
268
+ switch (entry["kind"]) {
269
+ case "comment":
270
+ return activityComment(entry);
271
+ case "state_event":
272
+ return activityStateEvent(entry);
273
+ default:
274
+ throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
275
+ }
276
+ }
277
+ function activityComment(entry) {
278
+ const comment2 = requiredRecord(entry["comment"], "activity comment");
279
+ const authorValue = comment2["author"];
280
+ const author = authorValue === null ? "unknown" : optionalLabel(requiredRecord(authorValue, "comment author")["name"], "unknown", "comment author name");
281
+ const deleted = comment2["deleted"];
282
+ if (typeof deleted !== "boolean") throw new CommandError("Invalid comment deleted flag.");
283
+ const body = activityCommentBody(comment2["body"], deleted);
284
+ const text = cleanMultiline(body).split("\n").map((line) => " " + line).join("\n");
285
+ return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${clean(author)} (${clean(requiredString(comment2["id"], "comment id"))}):
286
+ ${text}
287
+ `;
288
+ }
289
+ function activityCommentBody(value, deleted) {
290
+ if (deleted) return "[deleted comment]";
291
+ if (value === null || value === "") return "[comment has no text]";
292
+ return requiredString(value, "comment body");
293
+ }
294
+ function activityStateEvent(entry) {
295
+ const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
296
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
297
+ let description;
298
+ switch (entry["action"]) {
299
+ case "created":
300
+ description = `${actor} created the issue in ${stateName(entry["to_state"], "created to_state")}`;
301
+ break;
302
+ case "transition":
303
+ description = `${actor} moved the issue from ${nullableStateName(entry["from_state"], "transition from_state")} to ${stateName(entry["to_state"], "transition to_state")}`;
304
+ break;
305
+ case "milestone":
306
+ description = `${actor} recorded the ${stateName(entry["to_state"], "milestone to_state")} milestone`;
307
+ break;
308
+ default:
309
+ throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
310
+ }
311
+ return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} \xB7 ${provenance}
312
+ `;
313
+ }
314
+ function activityActor(actor) {
315
+ switch (actor["kind"]) {
316
+ case "user":
317
+ return optionalLabel(actor["name"], "Unknown actor", "activity actor name");
318
+ case "system":
319
+ return "System";
320
+ case "deleted_user":
321
+ return "Deleted user";
322
+ case "unknown":
323
+ return "Unknown actor";
324
+ default:
325
+ throw new CommandError(`Unknown activity actor kind: ${clean(actor["kind"])}`);
326
+ }
327
+ }
328
+ function activityProvenance(provenance) {
329
+ switch (provenance["kind"]) {
330
+ case "live":
331
+ case "import":
332
+ case "backfill":
333
+ return String(provenance["kind"]);
334
+ default:
335
+ throw new CommandError(`Unknown activity provenance: ${clean(provenance["kind"])}`);
336
+ }
337
+ }
338
+ function stateName(value, field2) {
339
+ return optionalLabel(requiredRecord(value, field2)["name"], "Unknown state", `${field2} name`);
340
+ }
341
+ function nullableStateName(value, field2) {
342
+ return value === null ? "Unknown state" : stateName(value, field2);
343
+ }
344
+ function optionalLabel(value, fallback, field2) {
345
+ if (value === null || value === "") return fallback;
346
+ return clean(requiredString(value, field2));
347
+ }
348
+ function requiredRecord(value, field2) {
349
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
350
+ throw new CommandError(`Invalid ${field2}.`);
351
+ }
352
+ return value;
353
+ }
354
+ function requiredString(value, field2) {
355
+ if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field2}.`);
356
+ return value;
357
+ }
258
358
  function stats(data) {
259
359
  const teams = data["teams"] ?? [];
260
360
  const totals = data["totals"] ?? {};
@@ -344,8 +444,8 @@ function path(record, ...keys) {
344
444
  }
345
445
 
346
446
  // src/commands/issues.ts
347
- import { openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
348
- import { basename } from "path";
447
+ import { openAsBlob, openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
448
+ import { basename, extname } from "path";
349
449
 
350
450
  // src/core/attachments.ts
351
451
  import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
@@ -594,6 +694,61 @@ async function show(ctx, identifier, opts) {
594
694
  `).join("");
595
695
  return notice === "" ? { json: json2, human } : { json: json2, human, notice };
596
696
  }
697
+ var ACTIVITY_MAX_LIMIT = 100;
698
+ async function activity(ctx, identifier, opts) {
699
+ requireIdentifiers([identifier]);
700
+ let page = await activityPage(ctx, identifier, opts.limit);
701
+ let entries = page.entries;
702
+ if (opts.all === true) {
703
+ const pages = [page.entries];
704
+ const seenCursors = /* @__PURE__ */ new Set();
705
+ while (page.hasMore) {
706
+ const cursor = page.nextCursor;
707
+ if (seenCursors.has(cursor)) {
708
+ throw new CommandError("Activity response repeated a cursor.");
709
+ }
710
+ seenCursors.add(cursor);
711
+ page = await activityPage(ctx, identifier, opts.limit, cursor);
712
+ pages.push(page.entries);
713
+ }
714
+ entries = pages.reverse().flat();
715
+ }
716
+ const notice = opts.all !== true && page.hasMore ? `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
717
+ ` : void 0;
718
+ return { json: entries, human: activityEntries(entries), notice };
719
+ }
720
+ async function activityPage(ctx, identifier, limit, before) {
721
+ const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, { limit, before });
722
+ if (!Array.isArray(body.data)) throw new CommandError("Activity response data is not an array.");
723
+ if (body.data.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry))) {
724
+ throw new CommandError("Activity response contains a non-object entry.");
725
+ }
726
+ const meta = body.meta;
727
+ if (typeof meta !== "object" || meta === null) {
728
+ throw new CommandError("Activity response has no pagination metadata.");
729
+ }
730
+ const hasMore = meta.has_more;
731
+ const nextCursor = meta.next_cursor;
732
+ if (typeof hasMore !== "boolean") {
733
+ throw new CommandError("Activity response has an invalid has_more value.");
734
+ }
735
+ if (nextCursor !== null && typeof nextCursor !== "string") {
736
+ throw new CommandError("Activity response has an invalid next_cursor value.");
737
+ }
738
+ if (hasMore) {
739
+ if (nextCursor === null || nextCursor === "") {
740
+ throw new CommandError("Activity response says older entries exist but provides no cursor.");
741
+ }
742
+ if (body.data.length === 0) {
743
+ throw new CommandError("Activity response says older entries exist but contains no entries.");
744
+ }
745
+ return { entries: body.data, hasMore: true, nextCursor };
746
+ }
747
+ if (nextCursor !== null) {
748
+ throw new CommandError("Activity response provides a cursor when no older entries exist.");
749
+ }
750
+ return { entries: body.data, hasMore: false, nextCursor: null };
751
+ }
597
752
  async function stats2(ctx) {
598
753
  const body = await ctx.client.get("/api/v1/stats");
599
754
  return { json: body.data, human: stats(body.data) };
@@ -935,19 +1090,28 @@ function requireCommentId(commentId) {
935
1090
  throw new UsageError(`not a comment id: ${commentId} (expected a UUID)`);
936
1091
  }
937
1092
  }
938
- var MAX_ATTACHMENT_BYTES2 = 25e6;
1093
+ var MAX_IMAGE_BYTES = 25e6;
1094
+ var MAX_VIDEO_BYTES = 1e8;
939
1095
  async function attachImage(ctx, path2) {
940
- attachableFile(path2);
941
- const bytes = readFileSync2(path2);
1096
+ return uploadAttachment(ctx, path2, MAX_IMAGE_BYTES);
1097
+ }
1098
+ async function attachFile(ctx, path2) {
1099
+ const maxBytes = extname(path2).toLowerCase() === ".mp4" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
1100
+ return uploadAttachment(ctx, path2, maxBytes);
1101
+ }
1102
+ async function uploadAttachment(ctx, path2, maxBytes) {
1103
+ attachableFile(path2, maxBytes);
1104
+ const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync2(path2)], basename(path2));
1105
+ requireAttachmentSize(path2, file.size, maxBytes);
942
1106
  const form = new FormData();
943
- form.set("file", new File([bytes], basename(path2)));
944
- const body = await ctx.client.postMultipart("/api/v1/uploads", form, bytes.byteLength);
1107
+ form.set("file", file);
1108
+ const body = await ctx.client.postMultipart("/api/v1/uploads", form, file.size);
945
1109
  const attachment = body.data;
946
1110
  const markdown = attachment["markdown"];
947
1111
  if (typeof markdown !== "string") throw new CommandError("the upload response carried no markdown reference");
948
1112
  return { json: attachment, human: clean(markdown) + "\n" };
949
1113
  }
950
- function attachableFile(path2) {
1114
+ function attachableFile(path2, maxBytes) {
951
1115
  let fileStats;
952
1116
  try {
953
1117
  fileStats = statSync2(path2);
@@ -958,17 +1122,18 @@ function attachableFile(path2) {
958
1122
  const kind = fileStats.isDirectory() ? "directory" : "special file";
959
1123
  throw new LocalError(`${path2} is a ${kind}, not a file.`);
960
1124
  }
961
- if (fileStats.size > MAX_ATTACHMENT_BYTES2) {
962
- throw new LocalError(
963
- `${path2} is too large: ${fileStats.size} bytes, and attachments are capped at ${MAX_ATTACHMENT_BYTES2}.`
964
- );
965
- }
1125
+ requireAttachmentSize(path2, fileStats.size, maxBytes);
966
1126
  try {
967
1127
  closeSync(openSync(path2, "r"));
968
1128
  } catch (error) {
969
1129
  throw new LocalError(`cannot read ${path2}: ${posixDetail(error)}`);
970
1130
  }
971
1131
  }
1132
+ function requireAttachmentSize(path2, size, maxBytes) {
1133
+ if (size > maxBytes) {
1134
+ throw new LocalError(`${path2} is too large: ${size} bytes, and attachments are capped at ${maxBytes}.`);
1135
+ }
1136
+ }
972
1137
  function posixDetail(error) {
973
1138
  const code = error.code;
974
1139
  if (code === "ENOENT") return "no such file or directory";
@@ -992,19 +1157,19 @@ async function claimFirst(ctx, issues) {
992
1157
  }
993
1158
  throw new CommandError("No claimable ready issues.");
994
1159
  }
995
- async function setState(ctx, identifier, stateName) {
1160
+ async function setState(ctx, identifier, stateName2) {
996
1161
  const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
997
1162
  const teamKey = str2((showBody.data["team"] ?? {})["key"]);
998
1163
  const statesBody = await ctx.client.get(
999
1164
  `/api/v1/teams/${teamKey}/workflow_states`
1000
1165
  );
1001
- const wanted = stateName.toLowerCase();
1166
+ const wanted = stateName2.toLowerCase();
1002
1167
  const state = statesBody.data.find(
1003
1168
  (candidate) => str2(candidate["name"]).toLowerCase() === wanted || str2(candidate["type"]) === wanted
1004
1169
  );
1005
1170
  if (state === void 0) {
1006
1171
  const names = statesBody.data.map((candidate) => str2(candidate["name"])).join(", ");
1007
- throw new CommandError(`No workflow state matches "${stateName}". States: ${names}`);
1172
+ throw new CommandError(`No workflow state matches "${stateName2}". States: ${names}`);
1008
1173
  }
1009
1174
  const body = await ctx.client.put(`/api/v1/issues/${identifier}/state`, { state_id: state["id"] });
1010
1175
  return body.data;
@@ -1690,7 +1855,7 @@ function requireYes(command, id, yes) {
1690
1855
  if (yes !== true) throw new UsageError(`${command} ${id} is destructive \u2014 pass --yes to confirm`);
1691
1856
  }
1692
1857
  async function upload(ctx, path2) {
1693
- return attachImage(ctx, path2);
1858
+ return attachFile(ctx, path2);
1694
1859
  }
1695
1860
  function indent2(text) {
1696
1861
  return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
@@ -1951,6 +2116,8 @@ export {
1951
2116
  list,
1952
2117
  search,
1953
2118
  show,
2119
+ ACTIVITY_MAX_LIMIT,
2120
+ activity,
1954
2121
  stats2 as stats,
1955
2122
  prime,
1956
2123
  version,
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ACTIVITY_MAX_LIMIT,
3
4
  CLI_VERSION,
4
5
  Client,
5
6
  CommandError,
@@ -11,6 +12,7 @@ import {
11
12
  TransportError,
12
13
  UpgradeRequiredError,
13
14
  UsageError,
15
+ activity,
14
16
  archive,
15
17
  attachImage,
16
18
  blocked,
@@ -80,7 +82,7 @@ import {
80
82
  version,
81
83
  workflowStates,
82
84
  writeCredentials
83
- } from "./chunk-NQVQNKMC.js";
85
+ } from "./chunk-WY6F3IJB.js";
84
86
 
85
87
  // src/cli.ts
86
88
  import { createInterface as createInterface2 } from "readline";
@@ -1238,6 +1240,15 @@ function integer(value) {
1238
1240
  throw new InvalidArgumentError("expected a non-negative integer");
1239
1241
  return Number.parseInt(value, 10);
1240
1242
  }
1243
+ function activityLimit(value) {
1244
+ const limit = integer(value);
1245
+ if (limit < 1 || limit > ACTIVITY_MAX_LIMIT) {
1246
+ throw new InvalidArgumentError(
1247
+ `expected an integer from 1 to ${ACTIVITY_MAX_LIMIT}`
1248
+ );
1249
+ }
1250
+ return limit;
1251
+ }
1241
1252
  function collect(value, previous) {
1242
1253
  return [...previous, value];
1243
1254
  }
@@ -1424,6 +1435,17 @@ program.command("show").description("full issue detail; downloads attached image
1424
1435
  });
1425
1436
  }
1426
1437
  );
1438
+ program.command("activity").description("mixed issue activity: comments, creation, and state changes").argument("[id]").option(
1439
+ "--limit <n>",
1440
+ `entries per page (default 20, max ${ACTIVITY_MAX_LIMIT})`,
1441
+ activityLimit
1442
+ ).option("--all", "read all older activity").option("--json", "emit JSON").action(async (id, opts) => {
1443
+ await withConfig(opts.json === true, false, (ctx) => {
1444
+ if (id === void 0) throw new UsageError("usage: harness activity <id>");
1445
+ requireIdentifiers2([id]);
1446
+ return activity(ctx, id, opts);
1447
+ });
1448
+ });
1427
1449
  program.command("stats").description("per-team and total counts").option("--json", "emit JSON").action(async (opts) => {
1428
1450
  await withConfig(opts.json === true, false, (ctx) => stats(ctx));
1429
1451
  });
@@ -1879,17 +1901,17 @@ user.command("list").option("--json", "emit JSON").action(async (opts) => {
1879
1901
  (ctx) => userList(ctx)
1880
1902
  );
1881
1903
  });
1882
- program.command("upload").description("upload a file; prints markdown to reference it").argument("[path]").option("--json", "emit JSON").action(async (path, opts) => {
1904
+ program.command("upload").description("upload an image (25 MB) or H.264 MP4 video (100 MB, optional AAC audio); prints markdown").argument("[path]").option("--json", "emit JSON").action(async (path, opts) => {
1883
1905
  await withConfig(opts.json === true, true, (ctx) => {
1884
1906
  if (path === void 0)
1885
1907
  throw new UsageError(
1886
- "usage: harness upload <path> (png/jpg/webp/gif, up to 25 MB)"
1908
+ "usage: harness upload <path> (png/jpg/webp/gif up to 25 MB; H.264 MP4 with optional AAC audio up to 100 MB)"
1887
1909
  );
1888
1910
  return upload(ctx, path);
1889
1911
  });
1890
1912
  });
1891
1913
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1892
- const { startMcpServer } = await import("./server-FWNT3HMX.js");
1914
+ const { startMcpServer } = await import("./server-5YNUXJO2.js");
1893
1915
  await startMcpServer();
1894
1916
  });
1895
1917
  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(
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ACTIVITY_MAX_LIMIT,
3
4
  CLI_VERSION,
4
5
  Client,
5
6
  HttpError,
@@ -7,6 +8,7 @@ import {
7
8
  RELATION_UPDATE_TYPES,
8
9
  TransportError,
9
10
  UsageError,
11
+ activity,
10
12
  archive,
11
13
  blocked,
12
14
  close,
@@ -59,7 +61,7 @@ import {
59
61
  upload,
60
62
  userList,
61
63
  workflowStates
62
- } from "./chunk-NQVQNKMC.js";
64
+ } from "./chunk-WY6F3IJB.js";
63
65
 
64
66
  // src/mcp/server.ts
65
67
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -161,6 +163,17 @@ var TOOLS = [
161
163
  shape: { id: identifier, images: z.boolean().optional() },
162
164
  handler: (ctx, args) => show(ctx, args["id"], { images: args["images"] === true })
163
165
  },
166
+ {
167
+ name: "activity_list",
168
+ description: "Mixed issue activity: comments, creation, and state changes. Returns the newest page unless all=true.",
169
+ readOnly: true,
170
+ shape: {
171
+ id: identifier,
172
+ limit: z.number().int().min(1).max(ACTIVITY_MAX_LIMIT).optional().describe(`entries per page (default 20, max ${ACTIVITY_MAX_LIMIT})`),
173
+ all: z.boolean().optional().describe("follow cursors through all older activity")
174
+ },
175
+ handler: (ctx, args) => activity(ctx, args["id"], args)
176
+ },
164
177
  {
165
178
  name: "issue_create",
166
179
  description: "Create an issue. type becomes a type/ label.",
@@ -646,7 +659,7 @@ var TOOLS = [
646
659
  // ── Uploads ──
647
660
  {
648
661
  name: "upload",
649
- description: "Upload an image file (png/jpg/webp/gif, \u2264 25 MB) from a local path; returns markdown to reference it in issue fields or comments.",
662
+ description: "Upload a local image (png/jpg/webp/gif, up to 25 MB) or H.264 MP4 video (8-bit 4:2:0, optional AAC audio and mov_text subtitles, up to 100 MB). Returns metadata and markdown: images use image markdown; videos use ordinary links rendered as inline players in Harness issues. A video is private to its uploader until that user saves its link in an issue description or comment. Reading an issue does not automatically download videos.",
650
663
  readOnly: false,
651
664
  shape: { path: z.string().describe("local file path") },
652
665
  handler: (ctx, args) => upload(ctx, args["path"])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
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
@@ -425,7 +425,7 @@
425
425
  "get": {
426
426
  "callbacks": {},
427
427
  "deprecated": false,
428
- "description": "Requires the `harness:read` scope. Streams the attachment bytes (binary response).",
428
+ "description": "Requires the `harness:read` scope. Streams attachment bytes. Videos recheck the owning organization's and issue's access on every request and use private, no-store caching. Supports one bytes Range: bounded, open-ended, or suffix; returns 206 with Content-Range, or 416 for an unsatisfiable range. Malformed, multipart, and conditional ranges fall back to a full 200. Images retain full-response behavior. Add ?mode=download for attachment disposition.",
429
429
  "parameters": [
430
430
  {
431
431
  "description": "Attachment UUID (the directory segment of the upload's URL)",
@@ -463,6 +463,12 @@
463
463
  "404": {
464
464
  "description": "Not found"
465
465
  },
466
+ "206": {
467
+ "description": "Requested video byte range, with Content-Range and Content-Length"
468
+ },
469
+ "416": {
470
+ "description": "Unsatisfiable range; Content-Range contains the actual file size"
471
+ },
466
472
  "422": {
467
473
  "description": "Validation error"
468
474
  }
@@ -791,7 +797,7 @@
791
797
  "post": {
792
798
  "callbacks": {},
793
799
  "deprecated": false,
794
- "description": "Requires the `harness:write` scope. Multipart upload of a png, jpg, webp, or gif (max 25 MB). Returns the attachment URL and a ready-to-paste `![alt](url)` markdown snippet for issue descriptions and comments. Attachments are private: the URL serves admins with a session; API clients fetch the bytes via `GET /api/v1/attachments/{attachment_id}/{file}`.",
800
+ "description": "Requires the `harness:write` scope. Multipart upload of a png, jpg, webp, or gif (25 MB), or H.264 MP4 video (100 MB; 8-bit 4:2:0 video, optional AAC audio and mov_text subtitles). No transcoding. Returns data.url, filename, content_type, byte_size, and markdown. Images use `![alt](url)`; videos use ordinary `[filename](url)` links. Supply an optional issue identifier to bind the video to that issue's access policy. Without it, the video is private to its uploader until that user saves its link in an issue description or comment. Copying a bound video link does not change its owning issue. Browser URLs require a session; API clients use GET /api/v1/attachments/{attachment_id}/{file} with a read-scoped token bound to the owning organization. Invalid media answers 422; an inaccessible issue answers 404. Existing image access remains unchanged.",
795
801
  "parameters": [],
796
802
  "requestBody": {
797
803
  "content": {
@@ -799,9 +805,13 @@
799
805
  "schema": {
800
806
  "properties": {
801
807
  "file": {
802
- "description": "png, jpg, webp, or gif up to 25 MB",
808
+ "description": "png, jpg, webp, or gif up to 25 MB; H.264 MP4 up to 100 MB",
803
809
  "format": "binary",
804
810
  "type": "string"
811
+ },
812
+ "issue": {
813
+ "description": "Optional owning issue identifier, for example IO-123. Must be accessible in the token's organization.",
814
+ "type": "string"
805
815
  }
806
816
  },
807
817
  "required": [
@@ -836,7 +846,7 @@
836
846
  "description": "File storage failed (transient) — retry"
837
847
  }
838
848
  },
839
- "summary": "Upload an image attachment",
849
+ "summary": "Upload an image or video attachment",
840
850
  "tags": []
841
851
  }
842
852
  },
@@ -973,6 +983,9 @@
973
983
  "type": "string"
974
984
  }
975
985
  },
986
+ "required": [
987
+ "url"
988
+ ],
976
989
  "type": "object"
977
990
  }
978
991
  }
@@ -1280,6 +1293,361 @@
1280
1293
  "tags": []
1281
1294
  }
1282
1295
  },
1296
+ "/api/v1/issues/{identifier}/activity": {
1297
+ "get": {
1298
+ "callbacks": {},
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.",
1301
+ "parameters": [
1302
+ {
1303
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
1304
+ "in": "path",
1305
+ "name": "identifier",
1306
+ "required": true,
1307
+ "schema": {
1308
+ "type": "string"
1309
+ }
1310
+ },
1311
+ {
1312
+ "description": "Opaque cursor returned as `meta.next_cursor` by the preceding newer page",
1313
+ "in": "query",
1314
+ "name": "before",
1315
+ "required": false,
1316
+ "schema": {
1317
+ "type": "string"
1318
+ }
1319
+ },
1320
+ {
1321
+ "description": "Entries per page (default 20, max 100)",
1322
+ "in": "query",
1323
+ "name": "limit",
1324
+ "required": false,
1325
+ "schema": {
1326
+ "maximum": 100,
1327
+ "minimum": 1,
1328
+ "type": "integer"
1329
+ }
1330
+ }
1331
+ ],
1332
+ "responses": {
1333
+ "200": {
1334
+ "content": {
1335
+ "application/json": {
1336
+ "schema": {
1337
+ "properties": {
1338
+ "data": {
1339
+ "items": {
1340
+ "oneOf": [
1341
+ {
1342
+ "additionalProperties": false,
1343
+ "properties": {
1344
+ "comment": {
1345
+ "properties": {
1346
+ "author": {
1347
+ "nullable": true,
1348
+ "properties": {
1349
+ "email": {
1350
+ "nullable": true,
1351
+ "type": "string"
1352
+ },
1353
+ "id": {
1354
+ "format": "uuid",
1355
+ "nullable": true,
1356
+ "type": "string"
1357
+ },
1358
+ "name": {
1359
+ "nullable": true,
1360
+ "type": "string"
1361
+ }
1362
+ },
1363
+ "required": [
1364
+ "id",
1365
+ "name",
1366
+ "email"
1367
+ ],
1368
+ "type": "object"
1369
+ },
1370
+ "body": {
1371
+ "description": "Comment text; null may mean a deleted tombstone or no stored text. Use `deleted` to identify tombstones",
1372
+ "nullable": true,
1373
+ "type": "string"
1374
+ },
1375
+ "deleted": {
1376
+ "description": "Whether the comment is a deleted tombstone",
1377
+ "type": "boolean"
1378
+ },
1379
+ "id": {
1380
+ "format": "uuid",
1381
+ "type": "string"
1382
+ },
1383
+ "inserted_at": {
1384
+ "format": "date-time",
1385
+ "type": "string"
1386
+ },
1387
+ "mentions": {
1388
+ "items": {
1389
+ "properties": {
1390
+ "email": {
1391
+ "nullable": true,
1392
+ "type": "string"
1393
+ },
1394
+ "name": {
1395
+ "nullable": true,
1396
+ "type": "string"
1397
+ },
1398
+ "user_id": {
1399
+ "format": "uuid",
1400
+ "nullable": true,
1401
+ "type": "string"
1402
+ }
1403
+ },
1404
+ "required": [
1405
+ "user_id",
1406
+ "name",
1407
+ "email"
1408
+ ],
1409
+ "type": "object"
1410
+ },
1411
+ "type": "array"
1412
+ },
1413
+ "reactions": {
1414
+ "items": {
1415
+ "properties": {
1416
+ "count": {
1417
+ "type": "integer"
1418
+ },
1419
+ "emoji": {
1420
+ "type": "string"
1421
+ },
1422
+ "reacted_by_current_actor": {
1423
+ "type": "boolean"
1424
+ }
1425
+ },
1426
+ "required": [
1427
+ "emoji",
1428
+ "count",
1429
+ "reacted_by_current_actor"
1430
+ ],
1431
+ "type": "object"
1432
+ },
1433
+ "type": "array"
1434
+ },
1435
+ "updated_at": {
1436
+ "format": "date-time",
1437
+ "type": "string"
1438
+ }
1439
+ },
1440
+ "required": [
1441
+ "id",
1442
+ "body",
1443
+ "deleted",
1444
+ "author",
1445
+ "mentions",
1446
+ "reactions",
1447
+ "inserted_at",
1448
+ "updated_at"
1449
+ ],
1450
+ "type": "object"
1451
+ },
1452
+ "id": {
1453
+ "type": "string"
1454
+ },
1455
+ "kind": {
1456
+ "enum": [
1457
+ "comment"
1458
+ ],
1459
+ "type": "string"
1460
+ },
1461
+ "occurred_at": {
1462
+ "format": "date-time",
1463
+ "type": "string"
1464
+ }
1465
+ },
1466
+ "required": [
1467
+ "id",
1468
+ "kind",
1469
+ "occurred_at",
1470
+ "comment"
1471
+ ],
1472
+ "type": "object"
1473
+ },
1474
+ {
1475
+ "additionalProperties": false,
1476
+ "properties": {
1477
+ "action": {
1478
+ "enum": [
1479
+ "created",
1480
+ "milestone",
1481
+ "transition"
1482
+ ],
1483
+ "type": "string"
1484
+ },
1485
+ "actor": {
1486
+ "properties": {
1487
+ "kind": {
1488
+ "enum": [
1489
+ "system",
1490
+ "user",
1491
+ "deleted_user",
1492
+ "unknown"
1493
+ ],
1494
+ "type": "string"
1495
+ },
1496
+ "name": {
1497
+ "nullable": true,
1498
+ "type": "string"
1499
+ }
1500
+ },
1501
+ "required": [
1502
+ "kind",
1503
+ "name"
1504
+ ],
1505
+ "type": "object"
1506
+ },
1507
+ "from_state": {
1508
+ "nullable": true,
1509
+ "properties": {
1510
+ "key": {
1511
+ "nullable": true,
1512
+ "type": "string"
1513
+ },
1514
+ "name": {
1515
+ "nullable": true,
1516
+ "type": "string"
1517
+ },
1518
+ "type": {
1519
+ "nullable": true,
1520
+ "type": "string"
1521
+ }
1522
+ },
1523
+ "required": [
1524
+ "key",
1525
+ "name",
1526
+ "type"
1527
+ ],
1528
+ "type": "object"
1529
+ },
1530
+ "id": {
1531
+ "type": "string"
1532
+ },
1533
+ "kind": {
1534
+ "enum": [
1535
+ "state_event"
1536
+ ],
1537
+ "type": "string"
1538
+ },
1539
+ "occurred_at": {
1540
+ "format": "date-time",
1541
+ "type": "string"
1542
+ },
1543
+ "provenance": {
1544
+ "properties": {
1545
+ "kind": {
1546
+ "enum": [
1547
+ "live",
1548
+ "import",
1549
+ "backfill"
1550
+ ],
1551
+ "type": "string"
1552
+ },
1553
+ "witnessed": {
1554
+ "type": "boolean"
1555
+ }
1556
+ },
1557
+ "required": [
1558
+ "kind",
1559
+ "witnessed"
1560
+ ],
1561
+ "type": "object"
1562
+ },
1563
+ "to_state": {
1564
+ "properties": {
1565
+ "key": {
1566
+ "nullable": true,
1567
+ "type": "string"
1568
+ },
1569
+ "name": {
1570
+ "nullable": true,
1571
+ "type": "string"
1572
+ },
1573
+ "type": {
1574
+ "nullable": true,
1575
+ "type": "string"
1576
+ }
1577
+ },
1578
+ "required": [
1579
+ "key",
1580
+ "name",
1581
+ "type"
1582
+ ],
1583
+ "type": "object"
1584
+ }
1585
+ },
1586
+ "required": [
1587
+ "id",
1588
+ "kind",
1589
+ "occurred_at",
1590
+ "action",
1591
+ "actor",
1592
+ "provenance",
1593
+ "from_state",
1594
+ "to_state"
1595
+ ],
1596
+ "type": "object"
1597
+ }
1598
+ ]
1599
+ },
1600
+ "type": "array"
1601
+ },
1602
+ "meta": {
1603
+ "properties": {
1604
+ "has_more": {
1605
+ "description": "Whether older entries exist",
1606
+ "type": "boolean"
1607
+ },
1608
+ "next_cursor": {
1609
+ "description": "Opaque cursor to pass as `before`; null when no older entries exist",
1610
+ "nullable": true,
1611
+ "type": "string"
1612
+ }
1613
+ },
1614
+ "required": [
1615
+ "has_more",
1616
+ "next_cursor"
1617
+ ],
1618
+ "type": "object"
1619
+ }
1620
+ },
1621
+ "required": [
1622
+ "data",
1623
+ "meta"
1624
+ ],
1625
+ "type": "object"
1626
+ }
1627
+ }
1628
+ },
1629
+ "description": "Success"
1630
+ },
1631
+ "400": {
1632
+ "description": "Invalid input. Error code is `invalid_cursor` for an invalid or expired cursor, or `bad_request` for a malformed `before` value or invalid limit."
1633
+ },
1634
+ "401": {
1635
+ "description": "Missing or invalid token"
1636
+ },
1637
+ "403": {
1638
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
1639
+ },
1640
+ "404": {
1641
+ "description": "Not found"
1642
+ },
1643
+ "422": {
1644
+ "description": "Validation error"
1645
+ }
1646
+ },
1647
+ "summary": "List issue activity",
1648
+ "tags": []
1649
+ }
1650
+ },
1283
1651
  "/api/v1/users": {
1284
1652
  "get": {
1285
1653
  "callbacks": {},
@@ -2479,9 +2847,12 @@
2479
2847
  "type": "object"
2480
2848
  },
2481
2849
  "body": {
2850
+ "description": "Comment text; null may mean a deleted tombstone or no stored text. Use `deleted` to identify tombstones",
2851
+ "nullable": true,
2482
2852
  "type": "string"
2483
2853
  },
2484
2854
  "deleted": {
2855
+ "description": "Whether the comment is a deleted tombstone",
2485
2856
  "type": "boolean"
2486
2857
  },
2487
2858
  "id": {