@allocator-one/harness-cli 1.5.1 → 1.6.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
@@ -69,10 +69,55 @@ Example tool call: `issue_list` with `{"ready": true, "team": "ALL"}`
69
69
  returns the ready queue as JSON; `issue_claim` with `{"id": "IO-123"}`
70
70
  claims atomically (a conflict means someone else holds it).
71
71
 
72
+ Issue creation resolves `--label` names within the target team first, then at
73
+ workspace scope. Missing names are created at workspace scope before the issue
74
+ is filed, and the final label ids travel in the single issue-create request, so
75
+ a preparation or permission failure cannot leave an unlabeled issue behind.
76
+ Creating a missing label requires Harness administrator standing.
77
+
78
+ Label repair is additive and repeatable; it never implicitly replaces the
79
+ whole set:
80
+
81
+ ```sh
82
+ harness update IO-123 --add-label security --add-label needs-review
83
+ harness update IO-123 --remove-label security
84
+ ```
85
+
86
+ Both update flags accept a label name or id. Additions allow only labels from
87
+ the issue's team or workspace. Removal can target an attached legacy foreign
88
+ label; when attached normalized names are ambiguous, use the label id.
89
+
72
90
  Auth resolution is identical to the CLI. Without a credential, tools return
73
91
  an error telling the agent to run `harness login` in a terminal — the
74
92
  server never opens a browser itself.
75
93
 
94
+ ### Text from a file, never from a shell argument
95
+
96
+ Every free-text option has a `--<field>-file` companion, and `-` means stdin:
97
+
98
+ ```sh
99
+ harness create --title "…" --description-file ./body.md
100
+ harness update IO-123 --notes-file ./notes.md --acceptance-file ./ac.md
101
+ harness comment IO-123 --body-file ./comment.md
102
+ git log -1 --format=%B | harness close IO-123 --reason-file -
103
+ ```
104
+
105
+ `create` and `update` cover `--title`, `--description`, `--design`, `--notes`
106
+ and `--acceptance` (plus `--append-notes` on `update`); `close` takes
107
+ `--reason-file`, `comment` takes `--body-file`.
108
+
109
+ Use these whenever the text is not something you authored inline. Prose that
110
+ reaches a shell argument has to survive quoting, and quoting can be defeated —
111
+ a `"` closes the string, `$(…)` substitutes, and a heredoc ends at whatever
112
+ flush-left delimiter line the text happens to contain. Handing over a path
113
+ removes the question, the way `git commit -F` does. This matters most for
114
+ agents relaying issue text they did not write.
115
+
116
+ Passing both an inline value and its `-file` for the same field is a usage
117
+ error rather than a silent precedence, and only one field per invocation may
118
+ read stdin. Both are checked before anything is read, so a rejected command
119
+ never consumes your pipe. One trailing newline is dropped, like `$(cat file)`.
120
+
76
121
  ## Repo config
77
122
 
78
123
  A committed `.harness-cli/config.json`, discovered git-style by walking up
@@ -619,7 +619,6 @@ async function version(config) {
619
619
  ` };
620
620
  }
621
621
  async function create(ctx, opts) {
622
- const labels = wantedLabels(opts);
623
622
  if (opts.title === void 0) {
624
623
  throw new UsageError("usage: harness create --title <title> [--team <key>] \u2026");
625
624
  }
@@ -627,6 +626,8 @@ async function create(ctx, opts) {
627
626
  const team = requiredTeam(ctx, opts.team);
628
627
  const priority = optionalPriority(opts.priority);
629
628
  const dueDate = optionalDueDate(opts.dueDate);
629
+ const labels = requestedLabelNames(wantedLabels(opts));
630
+ const labelIds = await prepareCreateLabelIds(ctx, team, labels);
630
631
  let body = { title: opts.title };
631
632
  body = putOpt(body, "description", opts.description);
632
633
  body = putOpt(body, "design", opts.design);
@@ -636,23 +637,14 @@ async function create(ctx, opts) {
636
637
  body = putPresent(body, "priority", priority);
637
638
  body = putDueDate(body, dueDate);
638
639
  body = putAssignee(body, opts.assignee);
640
+ if (labelIds.length > 0) body["label_ids"] = labelIds;
639
641
  const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body);
640
642
  const issue = created.data;
641
- try {
642
- const labeled = await applyLabels(ctx, issue, labels);
643
- return {
644
- json: labeled,
645
- human: `Created ${str2(labeled["identifier"])}: ${clean(labeled["title"])}
643
+ return {
644
+ json: issue,
645
+ human: `Created ${str2(issue["identifier"])}: ${clean(issue["title"])}
646
646
  `
647
- };
648
- } catch (error) {
649
- if (error instanceof HttpError || error instanceof TransportError) {
650
- throw new CommandError(
651
- `created ${str2(issue["identifier"])}, but applying labels failed: ${errorText(error)}`
652
- );
653
- }
654
- throw error;
655
- }
647
+ };
656
648
  }
657
649
  async function update(ctx, identifier, opts) {
658
650
  requireIdentifiers([identifier]);
@@ -666,10 +658,15 @@ async function update(ctx, identifier, opts) {
666
658
  }
667
659
  const priority = optionalPriority(opts.priority);
668
660
  const dueDate = optionalDueDate(opts.dueDate);
669
- let issue = null;
661
+ const addLabels = requestedLabelInputs(opts.addLabel ?? []);
662
+ const removeLabels = requestedLabelInputs(opts.removeLabel ?? []);
663
+ const labelPlan = addLabels.length > 0 || removeLabels.length > 0 ? await prepareLabelUpdate(ctx, identifier, addLabels, removeLabels) : null;
664
+ let issue = labelPlan?.issue ?? null;
665
+ let wrote = false;
670
666
  if (opts.claim === true) {
671
667
  const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
672
668
  issue = body.data;
669
+ wrote = true;
673
670
  }
674
671
  let patch = {};
675
672
  patch = putOpt(patch, "title", opts.title);
@@ -692,15 +689,26 @@ ${opts.appendNotes}`;
692
689
  if (Object.keys(patch).length > 0) {
693
690
  const body = await ctx.client.patch(`/api/v1/issues/${identifier}`, patch);
694
691
  issue = body.data;
692
+ wrote = true;
695
693
  }
696
694
  if (opts.state !== void 0) {
697
695
  issue = await setState(ctx, identifier, opts.state);
696
+ wrote = true;
697
+ }
698
+ if (labelPlan?.changed === true) {
699
+ const body = await ctx.client.put(`/api/v1/issues/${identifier}/labels`, {
700
+ label_ids: labelPlan.ids
701
+ });
702
+ issue = body.data;
703
+ wrote = true;
698
704
  }
699
705
  if (issue === null) {
700
706
  throw new UsageError("usage: harness update <id> --claim | --title \u2026 | --state <name> | \u2026");
701
707
  }
702
- return { json: issue, human: `Updated ${str2(issue["identifier"])}
703
- ` };
708
+ const human = wrote ? `Updated ${str2(issue["identifier"])}
709
+ ` : `Labels already up to date for ${str2(issue["identifier"])}
710
+ `;
711
+ return { json: issue, human };
704
712
  }
705
713
  async function close(ctx, identifiers, opts) {
706
714
  if (identifiers.length === 0) {
@@ -902,27 +910,137 @@ function validateType(type) {
902
910
  throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
903
911
  }
904
912
  }
905
- async function applyLabels(ctx, issue, names) {
906
- if (names.length === 0) return issue;
907
- const labelsBody = await ctx.client.get("/api/v1/labels");
908
- const byName = new Map(labelsBody.data.map((label) => [str2(label["name"]), label["id"]]));
913
+ var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
914
+ function requestedLabelNames(names) {
915
+ const unique = /* @__PURE__ */ new Map();
916
+ for (const name of requestedLabelInputs(names)) {
917
+ const normalized = normalizeLabelName(name);
918
+ if (!unique.has(normalized)) unique.set(normalized, name);
919
+ }
920
+ return [...unique.values()];
921
+ }
922
+ function requestedLabelInputs(values) {
923
+ return values.map((value) => {
924
+ const trimmed = value.trim();
925
+ if (trimmed === "") throw new UsageError("label names and ids cannot be empty");
926
+ return trimmed;
927
+ });
928
+ }
929
+ function normalizeLabelName(value) {
930
+ return str2(value).trim().toLowerCase();
931
+ }
932
+ async function prepareCreateLabelIds(ctx, teamKey, names) {
933
+ if (names.length === 0) return [];
934
+ const teams = await ctx.client.get("/api/v1/teams");
935
+ const target = teams.data.find(
936
+ (team) => str2(team["key"]).toLowerCase() === teamKey.toLowerCase()
937
+ );
938
+ if (target === void 0) throw new CommandError(`No team matches key "${teamKey}".`);
939
+ const teamId = str2(target["id"]);
940
+ if (teamId === "") throw new CommandError(`team ${teamKey} carried no id`);
941
+ return prepareApplicableLabelIds(ctx, teamId, names);
942
+ }
943
+ async function prepareApplicableLabelIds(ctx, teamId, names) {
944
+ let labels = (await ctx.client.get("/api/v1/labels")).data;
909
945
  const ids = [];
910
946
  for (const name of names) {
911
- const existing = byName.get(name);
912
- if (existing !== void 0) {
913
- ids.push(existing);
914
- } else {
915
- const created = await ctx.client.post("/api/v1/labels", {
916
- name,
917
- color: "gray"
918
- });
919
- ids.push(created.data["id"]);
947
+ const resolved = await resolveOrCreateApplicableLabel(ctx, labels, name, teamId);
948
+ labels = resolved.catalog;
949
+ ids.push(labelId(resolved.label));
950
+ }
951
+ return [...new Set(ids)];
952
+ }
953
+ function applicableLabel(labels, input, teamId) {
954
+ const normalized = normalizeLabelName(input);
955
+ const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
956
+ const teamMatches = named.filter((label) => label["team_id"] === teamId);
957
+ if (teamMatches.length > 1) {
958
+ throw new CommandError(`more than one team label matches "${input}"; use a label id`);
959
+ }
960
+ if (teamMatches.length === 1) return teamMatches[0];
961
+ const workspaceMatches = named.filter(
962
+ (label) => label["team_id"] === null || label["team_id"] === void 0
963
+ );
964
+ if (workspaceMatches.length > 1) {
965
+ throw new CommandError(`more than one workspace label matches "${input}"; use a label id`);
966
+ }
967
+ return workspaceMatches[0];
968
+ }
969
+ async function resolveOrCreateApplicableLabel(ctx, catalog, name, teamId) {
970
+ const existing = applicableLabel(catalog, name, teamId);
971
+ if (existing !== void 0) return { label: existing, catalog };
972
+ try {
973
+ const created = await ctx.client.post("/api/v1/labels", {
974
+ name,
975
+ color: "gray"
976
+ });
977
+ return { label: created.data, catalog: [...catalog, created.data] };
978
+ } catch (error) {
979
+ if (!(error instanceof HttpError) || error.status !== 422) throw error;
980
+ const refreshed = (await ctx.client.get("/api/v1/labels")).data;
981
+ const winner = applicableLabel(refreshed, name, teamId);
982
+ if (winner === void 0) throw error;
983
+ return { label: winner, catalog: refreshed };
984
+ }
985
+ }
986
+ function labelId(label) {
987
+ const id = str2(label["id"]);
988
+ if (id === "") throw new CommandError("the label response carried no id");
989
+ return id;
990
+ }
991
+ async function prepareLabelUpdate(ctx, identifier, addInputs, removeInputs) {
992
+ const issue = (await ctx.client.get(`/api/v1/issues/${identifier}`)).data;
993
+ const attached = Array.isArray(issue["labels"]) ? issue["labels"] : [];
994
+ const originalIds = attached.map(labelId);
995
+ const removed = /* @__PURE__ */ new Set();
996
+ for (const input of removeInputs) {
997
+ const idInput = UUID_FORMAT.test(input);
998
+ const exact = attached.find((label) => {
999
+ const id = labelId(label);
1000
+ return idInput ? id.toLowerCase() === input.toLowerCase() : id === input;
1001
+ });
1002
+ if (exact !== void 0) {
1003
+ removed.add(labelId(exact));
1004
+ continue;
1005
+ }
1006
+ const normalized = normalizeLabelName(input);
1007
+ const matches = attached.filter((label) => normalizeLabelName(label["name"]) === normalized);
1008
+ if (matches.length > 1) {
1009
+ throw new CommandError(`more than one attached label matches "${input}"; remove by label id`);
1010
+ }
1011
+ if (matches.length === 1) removed.add(labelId(matches[0]));
1012
+ }
1013
+ const ids = originalIds.filter((id) => !removed.has(id));
1014
+ if (addInputs.length > 0) {
1015
+ const team = issue["team"] ?? {};
1016
+ const teamId = str2(team["id"]);
1017
+ if (teamId === "") throw new CommandError(`issue ${identifier} carried no team id`);
1018
+ let catalog = (await ctx.client.get("/api/v1/labels")).data;
1019
+ for (const input of addInputs) {
1020
+ const idInput = UUID_FORMAT.test(input);
1021
+ let chosen = idInput ? catalog.find((label) => labelId(label).toLowerCase() === input.toLowerCase()) : void 0;
1022
+ if (chosen !== void 0) {
1023
+ const owner = chosen["team_id"];
1024
+ if (owner !== null && owner !== void 0 && owner !== teamId) {
1025
+ throw new CommandError(
1026
+ `label ${input} belongs to another team and cannot be added to ${identifier}`
1027
+ );
1028
+ }
1029
+ } else {
1030
+ if (idInput) throw new CommandError(`No label matches id "${input}".`);
1031
+ const resolved = await resolveOrCreateApplicableLabel(ctx, catalog, input, teamId);
1032
+ chosen = resolved.label;
1033
+ catalog = resolved.catalog;
1034
+ }
1035
+ const id = labelId(chosen);
1036
+ if (!ids.includes(id)) ids.push(id);
920
1037
  }
921
1038
  }
922
- const body = await ctx.client.put(`/api/v1/issues/${str2(issue["identifier"])}/labels`, {
923
- label_ids: ids
924
- });
925
- return body.data;
1039
+ return {
1040
+ issue,
1041
+ ids,
1042
+ changed: ids.length !== originalIds.length || ids.some((id, index) => id !== originalIds[index])
1043
+ };
926
1044
  }
927
1045
  async function fetchIssues(ctx, params, opts) {
928
1046
  if (opts.all === true && opts.page !== void 0) {
package/dist/cli.js CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  version,
71
71
  workflowStates,
72
72
  writeCredentials
73
- } from "./chunk-CIW4OU5G.js";
73
+ } from "./chunk-DQDNAG5E.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,19 +1188,60 @@ 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 atomically after resolving requested labels").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
- ).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
- );
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(
1215
+ "--label <name>",
1216
+ "resolve team-first, then workspace; create missing workspace labels before the issue (repeatable)",
1217
+ collect,
1218
+ []
1219
+ ).option("--json", "emit JSON").action(async (opts) => {
1220
+ await withConfig(opts.json === true, true, (ctx) => {
1221
+ if (opts.title === void 0 && opts.titleFile === void 0) {
1222
+ throw new UsageError(
1223
+ "usage: harness create --title <title> | --title-file <path> [--team <key>] \u2026"
1224
+ );
1225
+ }
1226
+ requireNonBlankInlineTitle(opts.title);
1227
+ const text = resolveTextSources(issueTextSources(opts));
1228
+ requireNonBlankTitle(text, opts.titleFile);
1229
+ return create(ctx, { ...opts, ...text });
1230
+ });
1093
1231
  });
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(
1232
+ 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
1233
  "--assignee <who>",
1096
1234
  'me, a user id, an email, or a full name; "" unassigns'
1235
+ ).option(
1236
+ "--add-label <name-or-id>",
1237
+ "add a label without replacing existing labels (repeatable)",
1238
+ collect,
1239
+ []
1240
+ ).option(
1241
+ "--remove-label <name-or-id>",
1242
+ "remove an attached label without replacing others (repeatable)",
1243
+ collect,
1244
+ []
1097
1245
  ).option("--json", "emit JSON").action(
1098
1246
  async (id, opts) => {
1099
1247
  await withConfig(opts.json === true, true, (ctx) => {
@@ -1103,20 +1251,34 @@ program.command("update").description("update an issue; --claim takes it atomica
1103
1251
  );
1104
1252
  }
1105
1253
  requireIdentifiers2([id]);
1106
- return update(ctx, id, opts);
1254
+ requireNonBlankInlineTitle(opts.title);
1255
+ const text = resolveTextSources([
1256
+ ...issueTextSources(opts),
1257
+ { name: "appendNotes", inline: opts.appendNotes, file: opts.appendNotesFile }
1258
+ ]);
1259
+ if (Object.hasOwn(text, "title")) requireNonBlankTitle(text, opts.titleFile);
1260
+ return update(ctx, id, { ...opts, ...text });
1107
1261
  });
1108
1262
  }
1109
1263
  );
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(
1264
+ 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
1265
  async (ids, opts) => {
1112
1266
  await withConfig(opts.json === true, true, (ctx) => {
1113
1267
  if (ids.length === 0) {
1114
1268
  throw new UsageError(
1115
- "usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel]"
1269
+ "usage: harness close <id> [<id>\u2026] [--reason <text> | --reason-file <path>] [--cancel]"
1116
1270
  );
1117
1271
  }
1118
1272
  requireIdentifiers2(ids);
1119
- return close(ctx, ids, opts);
1273
+ const text = resolveTextSources([
1274
+ { name: "reason", inline: opts.reason, file: opts.reasonFile }
1275
+ ]);
1276
+ if (Object.hasOwn(text, "reason") && (text["reason"] ?? "").trim() === "") {
1277
+ throw new UsageError(
1278
+ opts.reasonFile === void 0 ? "--reason is empty \u2014 omit it, or give a reason" : `--reason-file ${opts.reasonFile} produced an empty reason`
1279
+ );
1280
+ }
1281
+ return close(ctx, ids, { ...opts, ...text });
1120
1282
  });
1121
1283
  }
1122
1284
  );
@@ -1151,17 +1313,31 @@ program.command("dep").description("blocking dependencies: dep add|rm <issue> <b
1151
1313
  });
1152
1314
  }
1153
1315
  );
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(
1316
+ 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
1317
  async (id, text, opts) => {
1156
1318
  await withConfig(opts.json === true, true, (ctx) => {
1157
- if (id === void 0 || text.length === 0) {
1319
+ if (id === void 0 || opts.bodyFile === void 0 && text.length === 0) {
1158
1320
  throw new UsageError(
1159
- "usage: harness comment <id> <text> [--mention NAME]\u2026"
1321
+ "usage: harness comment <id> <text> [--mention NAME]\u2026 | harness comment <id> --body-file <path>"
1160
1322
  );
1161
1323
  }
1162
1324
  requireIdentifiers2([id]);
1325
+ if (opts.bodyFile !== void 0 && text.length > 0) {
1326
+ throw new UsageError(
1327
+ "comment text and --body-file both given \u2014 pass one; there is no precedence between them"
1328
+ );
1329
+ }
1330
+ const fromFile = resolveTextSources([
1331
+ { name: "body", inline: void 0, file: opts.bodyFile }
1332
+ ]).body;
1333
+ const body = fromFile === void 0 ? text : [fromFile];
1334
+ if (body.join(" ").trim() === "") {
1335
+ throw new UsageError(
1336
+ 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`
1337
+ );
1338
+ }
1163
1339
  const mentions = opts.mention.filter((name) => name.trim() !== "");
1164
- return comment(ctx, id, text, mentions);
1340
+ return comment(ctx, id, body, mentions);
1165
1341
  });
1166
1342
  }
1167
1343
  );
@@ -1391,7 +1567,7 @@ program.command("upload").description("upload a file; prints markdown to referen
1391
1567
  });
1392
1568
  });
1393
1569
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1394
- const { startMcpServer } = await import("./server-J2V5JXFY.js");
1570
+ const { startMcpServer } = await import("./server-WBZ5OPHK.js");
1395
1571
  await startMcpServer();
1396
1572
  });
1397
1573
  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-CIW4OU5G.js";
53
+ } from "./chunk-DQDNAG5E.js";
54
54
 
55
55
  // src/mcp/server.ts
56
56
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -189,13 +189,17 @@ var TOOLS = [
189
189
  due_date: dateField,
190
190
  state: z.string().optional().describe("workflow state by name or type"),
191
191
  parent: z.string().optional(),
192
- assignee: z.string().optional().describe('"" unassigns')
192
+ assignee: z.string().optional().describe('"" unassigns'),
193
+ add_labels: z.array(z.string()).optional().describe("label names or ids to add without replacing existing labels"),
194
+ remove_labels: z.array(z.string()).optional().describe("attached label names or ids to remove without replacing others")
193
195
  },
194
196
  handler: (ctx, args) => update(ctx, args["id"], {
195
197
  ...args,
196
198
  acceptance: args["acceptance_criteria"],
197
199
  appendNotes: args["append_notes"],
198
- dueDate: args["due_date"]
200
+ dueDate: args["due_date"],
201
+ addLabel: args["add_labels"],
202
+ removeLabel: args["remove_labels"]
199
203
  })
200
204
  },
201
205
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.5.1",
3
+ "version": "1.6.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
@@ -1038,6 +1038,14 @@
1038
1038
  "format": "date",
1039
1039
  "type": "string"
1040
1040
  },
1041
+ "label_ids": {
1042
+ "description": "Optional label IDs from the workspace or the same team; attached atomically with the issue",
1043
+ "items": {
1044
+ "format": "uuid",
1045
+ "type": "string"
1046
+ },
1047
+ "type": "array"
1048
+ },
1041
1049
  "notes": {
1042
1050
  "description": "Working notes (markdown)",
1043
1051
  "type": "string"