@allocator-one/harness-cli 1.5.0 → 1.5.2

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
@@ -73,6 +73,33 @@ Auth resolution is identical to the CLI. Without a credential, tools return
73
73
  an error telling the agent to run `harness login` in a terminal — the
74
74
  server never opens a browser itself.
75
75
 
76
+ ### Text from a file, never from a shell argument
77
+
78
+ Every free-text option has a `--<field>-file` companion, and `-` means stdin:
79
+
80
+ ```sh
81
+ harness create --title "…" --description-file ./body.md
82
+ harness update IO-123 --notes-file ./notes.md --acceptance-file ./ac.md
83
+ harness comment IO-123 --body-file ./comment.md
84
+ git log -1 --format=%B | harness close IO-123 --reason-file -
85
+ ```
86
+
87
+ `create` and `update` cover `--title`, `--description`, `--design`, `--notes`
88
+ and `--acceptance` (plus `--append-notes` on `update`); `close` takes
89
+ `--reason-file`, `comment` takes `--body-file`.
90
+
91
+ Use these whenever the text is not something you authored inline. Prose that
92
+ reaches a shell argument has to survive quoting, and quoting can be defeated —
93
+ a `"` closes the string, `$(…)` substitutes, and a heredoc ends at whatever
94
+ flush-left delimiter line the text happens to contain. Handing over a path
95
+ removes the question, the way `git commit -F` does. This matters most for
96
+ agents relaying issue text they did not write.
97
+
98
+ Passing both an inline value and its `-file` for the same field is a usage
99
+ error rather than a silent precedence, and only one field per invocation may
100
+ read stdin. Both are checked before anything is read, so a rejected command
101
+ never consumes your pipe. One trailing newline is dropped, like `$(cat file)`.
102
+
76
103
  ## Repo config
77
104
 
78
105
  A committed `.harness-cli/config.json`, discovered git-style by walking up
@@ -514,12 +514,19 @@ function pageCount(meta) {
514
514
  function dedupe(records) {
515
515
  const seen = /* @__PURE__ */ new Set();
516
516
  return records.filter((record) => {
517
- const key = record["id"] ?? JSON.stringify(record);
517
+ const id = record["id"];
518
+ const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
518
519
  if (seen.has(key)) return false;
519
520
  seen.add(key);
520
521
  return true;
521
522
  });
522
523
  }
524
+ function stableKey(value) {
525
+ return JSON.stringify(
526
+ value,
527
+ (_field, nested) => nested !== null && typeof nested === "object" && !Array.isArray(nested) ? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a < b ? -1 : 1)) : nested
528
+ );
529
+ }
523
530
 
524
531
  // src/commands/issues.ts
525
532
  var PRIORITY_NAMES = { urgent: 1, high: 2, medium: 3, low: 4, none: 0 };
package/dist/cli.js CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  version,
71
71
  workflowStates,
72
72
  writeCredentials
73
- } from "./chunk-FGBXAAKW.js";
73
+ } from "./chunk-CIW4OU5G.js";
74
74
 
75
75
  // src/cli.ts
76
76
  import { createInterface as createInterface2 } from "readline";
@@ -383,6 +383,113 @@ function defaultOpenBrowser(url) {
383
383
  }
384
384
  }
385
385
 
386
+ // src/core/textinput.ts
387
+ import { readFileSync, statSync } from "fs";
388
+ import { isatty } from "tty";
389
+ var STDIN_PATH = "-";
390
+ var MAX_TEXT_BYTES = 1e6;
391
+ function flagOf(name) {
392
+ return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
393
+ }
394
+ function resolveTextSources(sources) {
395
+ const resolved = {};
396
+ const toRead = [];
397
+ let stdinClaimedBy;
398
+ for (const { name, inline, file } of sources) {
399
+ if (inline !== void 0 && file !== void 0) {
400
+ throw new UsageError(
401
+ `--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
402
+ );
403
+ }
404
+ if (file === void 0) {
405
+ if (inline !== void 0) resolved[name] = inline;
406
+ continue;
407
+ }
408
+ if (file === STDIN_PATH) {
409
+ if (isatty(0)) {
410
+ throw new UsageError(
411
+ `--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
412
+ );
413
+ }
414
+ if (stdinClaimedBy !== void 0) {
415
+ throw new UsageError(
416
+ `--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
417
+ );
418
+ }
419
+ stdinClaimedBy = name;
420
+ }
421
+ if (file !== STDIN_PATH) checkReadable(file, name);
422
+ toRead.push({ name, file });
423
+ }
424
+ for (const { name, file } of [...toRead].sort(byStdinLast)) {
425
+ resolved[name] = stripOneTrailingNewline(readTextFile(file, name));
426
+ }
427
+ return resolved;
428
+ }
429
+ function readTextFile(path, name) {
430
+ if (path === STDIN_PATH) {
431
+ let piped;
432
+ try {
433
+ piped = readFileSync(0);
434
+ } catch (error) {
435
+ throw new UsageError(`--${flagOf(name)}-file -: cannot read stdin (${describe(error)})`);
436
+ }
437
+ if (piped.length > MAX_TEXT_BYTES) {
438
+ throw new UsageError(oversize(`--${flagOf(name)}-file -`));
439
+ }
440
+ return decodeUtf8(piped, `--${flagOf(name)}-file -`);
441
+ }
442
+ const flag = `--${flagOf(name)}-file ${path}`;
443
+ let bytes;
444
+ try {
445
+ bytes = readFileSync(path);
446
+ } catch (error) {
447
+ throw new UsageError(`${flag}: ${describe(error)}`);
448
+ }
449
+ if (bytes.length > MAX_TEXT_BYTES) throw new UsageError(oversize(flag));
450
+ return decodeUtf8(bytes, flag);
451
+ }
452
+ function decodeUtf8(bytes, flag) {
453
+ try {
454
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
455
+ } catch {
456
+ throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
457
+ }
458
+ }
459
+ function oversize(what) {
460
+ return `${what}: over ${MAX_TEXT_BYTES / 1e6} MB \u2014 that is not prose; check the path`;
461
+ }
462
+ function checkReadable(path, name) {
463
+ const flag = `--${flagOf(name)}-file ${path}`;
464
+ let stats2;
465
+ try {
466
+ stats2 = statSync(path);
467
+ } catch (error) {
468
+ throw new UsageError(`${flag}: ${describe(error)}`);
469
+ }
470
+ if (!stats2.isFile()) {
471
+ throw new UsageError(
472
+ `${flag}: ${stats2.isDirectory() ? "is a directory" : "not a regular file"}`
473
+ );
474
+ }
475
+ if (stats2.size > MAX_TEXT_BYTES) throw new UsageError(oversize(flag));
476
+ }
477
+ function byStdinLast(a, b) {
478
+ return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
479
+ }
480
+ function stripOneTrailingNewline(text) {
481
+ if (text.endsWith("\r\n")) return text.slice(0, -2);
482
+ if (text.endsWith("\n")) return text.slice(0, -1);
483
+ return text;
484
+ }
485
+ function describe(error) {
486
+ const code = error?.code;
487
+ if (code === "ENOENT") return "no such file";
488
+ if (code === "EISDIR") return "is a directory";
489
+ if (code === "EACCES") return "permission denied";
490
+ return error instanceof Error ? error.message : String(error);
491
+ }
492
+
386
493
  // src/core/stdio.ts
387
494
  var BROKEN_PIPE_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
388
495
  function guardBrokenPipes(streams = [process.stdout, process.stderr]) {
@@ -401,11 +508,11 @@ import {
401
508
  existsSync,
402
509
  linkSync,
403
510
  mkdirSync,
404
- readFileSync,
511
+ readFileSync as readFileSync2,
405
512
  realpathSync,
406
513
  renameSync,
407
514
  rmSync,
408
- statSync,
515
+ statSync as statSync2,
409
516
  writeFileSync
410
517
  } from "fs";
411
518
  import { basename, dirname, join as join2, parse, sep } from "path";
@@ -448,7 +555,7 @@ function findPackageRoot(from) {
448
555
  }
449
556
  function packageNameAt(path) {
450
557
  try {
451
- const parsed = JSON.parse(readFileSync(path, "utf8"));
558
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
452
559
  if (typeof parsed !== "object" || parsed === null) return null;
453
560
  const name = parsed["name"];
454
561
  return typeof name === "string" ? name : null;
@@ -459,7 +566,7 @@ function packageNameAt(path) {
459
566
  function versionAt(packageRoot) {
460
567
  try {
461
568
  const parsed = JSON.parse(
462
- readFileSync(join2(packageRoot, "package.json"), "utf8")
569
+ readFileSync2(join2(packageRoot, "package.json"), "utf8")
463
570
  );
464
571
  if (typeof parsed !== "object" || parsed === null) return null;
465
572
  const version2 = parsed["version"];
@@ -524,7 +631,7 @@ function stampPath(env = process.env) {
524
631
  }
525
632
  function readStamp(env = process.env) {
526
633
  try {
527
- const parsed = JSON.parse(readFileSync(stampPath(env), "utf8"));
634
+ const parsed = JSON.parse(readFileSync2(stampPath(env), "utf8"));
528
635
  if (typeof parsed !== "object" || parsed === null) return null;
529
636
  const record = parsed;
530
637
  const checkedAt = record["checkedAt"];
@@ -829,8 +936,8 @@ function releaseLock(lock) {
829
936
  }
830
937
  function readHolder(path) {
831
938
  try {
832
- const mtimeMs = statSync(path).mtimeMs;
833
- const pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
939
+ const mtimeMs = statSync2(path).mtimeMs;
940
+ const pid = Number.parseInt(readFileSync2(path, "utf8").trim(), 10);
834
941
  return { pid: Number.isInteger(pid) && pid > 0 ? pid : null, mtimeMs };
835
942
  } catch {
836
943
  return null;
@@ -1081,17 +1188,43 @@ program.command("version").description("print CLI and server API versions").opti
1081
1188
  );
1082
1189
  await exitWith(0);
1083
1190
  });
1084
- program.command("create").description("create an issue").option("--title <text>", "issue title (required)").option("--description <text>", "problem statement").option("--design <text>", "design notes").option("--notes <text>", "working notes").option("--acceptance <text>", "acceptance criteria").option(
1191
+ function issueTextSources(opts) {
1192
+ return [
1193
+ { name: "title", inline: opts.title, file: opts.titleFile },
1194
+ { name: "description", inline: opts.description, file: opts.descriptionFile },
1195
+ { name: "design", inline: opts.design, file: opts.designFile },
1196
+ { name: "notes", inline: opts.notes, file: opts.notesFile },
1197
+ { name: "acceptance", inline: opts.acceptance, file: opts.acceptanceFile }
1198
+ ];
1199
+ }
1200
+ function requireNonBlankInlineTitle(title) {
1201
+ if (title !== void 0 && title.trim() === "") {
1202
+ throw new UsageError("--title is empty \u2014 an issue needs a title");
1203
+ }
1204
+ }
1205
+ function requireNonBlankTitle(text, titleFile) {
1206
+ if ((text["title"] ?? "").trim() !== "") return;
1207
+ throw new UsageError(
1208
+ titleFile === void 0 ? "--title is empty \u2014 an issue needs a title" : `--title-file ${titleFile} produced an empty title`
1209
+ );
1210
+ }
1211
+ program.command("create").description("create an issue").option("--title <text>", "issue title (required)").option("--description <text>", "problem statement").option("--design <text>", "design notes").option("--notes <text>", "working notes").option("--acceptance <text>", "acceptance criteria").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'read the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option(
1085
1212
  "--type <type>",
1086
1213
  "task | bug | feature | epic (applied as a type/ label)"
1087
1214
  ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option("--label <name>", "label to apply (repeatable)", collect, []).option("--json", "emit JSON").action(async (opts) => {
1088
- await withConfig(
1089
- opts.json === true,
1090
- true,
1091
- (ctx) => create(ctx, opts)
1092
- );
1215
+ await withConfig(opts.json === true, true, (ctx) => {
1216
+ if (opts.title === void 0 && opts.titleFile === void 0) {
1217
+ throw new UsageError(
1218
+ "usage: harness create --title <title> | --title-file <path> [--team <key>] \u2026"
1219
+ );
1220
+ }
1221
+ requireNonBlankInlineTitle(opts.title);
1222
+ const text = resolveTextSources(issueTextSources(opts));
1223
+ requireNonBlankTitle(text, opts.titleFile);
1224
+ return create(ctx, { ...opts, ...text });
1225
+ });
1093
1226
  });
1094
- program.command("update").description("update an issue; --claim takes it atomically").argument("[id]").option("--claim", "claim atomically and move to the first started state").option("--state <name>", "workflow state by name or type").option("--title <text>").option("--description <text>").option("--design <text>").option("--notes <text>", "replace the notes").option("--append-notes <text>", "append to the notes").option("--acceptance <text>").option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", 'YYYY-MM-DD; "" clears').option("--parent <id>").option(
1227
+ program.command("update").description("update an issue; --claim takes it atomically").argument("[id]").option("--claim", "claim atomically and move to the first started state").option("--state <name>", "workflow state by name or type").option("--title <text>").option("--description <text>").option("--design <text>").option("--notes <text>", "replace the notes").option("--append-notes <text>", "append to the notes").option("--acceptance <text>").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'replace the notes from a file ("-" for stdin)').option("--append-notes-file <path>", 'append to the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", 'YYYY-MM-DD; "" clears').option("--parent <id>").option(
1095
1228
  "--assignee <who>",
1096
1229
  'me, a user id, an email, or a full name; "" unassigns'
1097
1230
  ).option("--json", "emit JSON").action(
@@ -1103,20 +1236,34 @@ program.command("update").description("update an issue; --claim takes it atomica
1103
1236
  );
1104
1237
  }
1105
1238
  requireIdentifiers2([id]);
1106
- return update(ctx, id, opts);
1239
+ requireNonBlankInlineTitle(opts.title);
1240
+ const text = resolveTextSources([
1241
+ ...issueTextSources(opts),
1242
+ { name: "appendNotes", inline: opts.appendNotes, file: opts.appendNotesFile }
1243
+ ]);
1244
+ if (Object.hasOwn(text, "title")) requireNonBlankTitle(text, opts.titleFile);
1245
+ return update(ctx, id, { ...opts, ...text });
1107
1246
  });
1108
1247
  }
1109
1248
  );
1110
- program.command("close").description("close issues; the response lists newly unblocked issues").argument("[ids...]").option("--reason <text>", "why it is done (e.g. the commit hash)").option("--cancel", "close as canceled instead of completed").option("--json", "emit JSON").action(
1249
+ program.command("close").description("close issues; the response lists newly unblocked issues").argument("[ids...]").option("--reason <text>", "why it is done (e.g. the commit hash)").option("--reason-file <path>", 'read the reason from a file ("-" for stdin)').option("--cancel", "close as canceled instead of completed").option("--json", "emit JSON").action(
1111
1250
  async (ids, opts) => {
1112
1251
  await withConfig(opts.json === true, true, (ctx) => {
1113
1252
  if (ids.length === 0) {
1114
1253
  throw new UsageError(
1115
- "usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel]"
1254
+ "usage: harness close <id> [<id>\u2026] [--reason <text> | --reason-file <path>] [--cancel]"
1116
1255
  );
1117
1256
  }
1118
1257
  requireIdentifiers2(ids);
1119
- return close(ctx, ids, opts);
1258
+ const text = resolveTextSources([
1259
+ { name: "reason", inline: opts.reason, file: opts.reasonFile }
1260
+ ]);
1261
+ if (Object.hasOwn(text, "reason") && (text["reason"] ?? "").trim() === "") {
1262
+ throw new UsageError(
1263
+ opts.reasonFile === void 0 ? "--reason is empty \u2014 omit it, or give a reason" : `--reason-file ${opts.reasonFile} produced an empty reason`
1264
+ );
1265
+ }
1266
+ return close(ctx, ids, { ...opts, ...text });
1120
1267
  });
1121
1268
  }
1122
1269
  );
@@ -1151,17 +1298,31 @@ program.command("dep").description("blocking dependencies: dep add|rm <issue> <b
1151
1298
  });
1152
1299
  }
1153
1300
  );
1154
- program.command("comment").description("comment on an issue").argument("[id]").argument("[text...]").option("--mention <name>", "notify a person (repeatable)", collect, []).option("--json", "emit JSON").action(
1301
+ program.command("comment").description("comment on an issue").argument("[id]").argument("[text...]").option("--body-file <path>", 'read the comment body from a file ("-" for stdin)').option("--mention <name>", "notify a person (repeatable)", collect, []).option("--json", "emit JSON").action(
1155
1302
  async (id, text, opts) => {
1156
1303
  await withConfig(opts.json === true, true, (ctx) => {
1157
- if (id === void 0 || text.length === 0) {
1304
+ if (id === void 0 || opts.bodyFile === void 0 && text.length === 0) {
1158
1305
  throw new UsageError(
1159
- "usage: harness comment <id> <text> [--mention NAME]\u2026"
1306
+ "usage: harness comment <id> <text> [--mention NAME]\u2026 | harness comment <id> --body-file <path>"
1160
1307
  );
1161
1308
  }
1162
1309
  requireIdentifiers2([id]);
1310
+ if (opts.bodyFile !== void 0 && text.length > 0) {
1311
+ throw new UsageError(
1312
+ "comment text and --body-file both given \u2014 pass one; there is no precedence between them"
1313
+ );
1314
+ }
1315
+ const fromFile = resolveTextSources([
1316
+ { name: "body", inline: void 0, file: opts.bodyFile }
1317
+ ]).body;
1318
+ const body = fromFile === void 0 ? text : [fromFile];
1319
+ if (body.join(" ").trim() === "") {
1320
+ throw new UsageError(
1321
+ opts.bodyFile === void 0 ? "the comment text is empty \u2014 nothing to post" : `--body-file ${opts.bodyFile} produced an empty comment \u2014 nothing to post`
1322
+ );
1323
+ }
1163
1324
  const mentions = opts.mention.filter((name) => name.trim() !== "");
1164
- return comment(ctx, id, text, mentions);
1325
+ return comment(ctx, id, body, mentions);
1165
1326
  });
1166
1327
  }
1167
1328
  );
@@ -1391,7 +1552,7 @@ program.command("upload").description("upload a file; prints markdown to referen
1391
1552
  });
1392
1553
  });
1393
1554
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1394
- const { startMcpServer } = await import("./server-ZRD4RTFU.js");
1555
+ const { startMcpServer } = await import("./server-J2V5JXFY.js");
1395
1556
  await startMcpServer();
1396
1557
  });
1397
1558
  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(
@@ -50,7 +50,7 @@ import {
50
50
  upload,
51
51
  userList,
52
52
  workflowStates
53
- } from "./chunk-FGBXAAKW.js";
53
+ } from "./chunk-CIW4OU5G.js";
54
54
 
55
55
  // src/mcp/server.ts
56
56
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",