@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 +45 -0
- package/dist/{chunk-CIW4OU5G.js → chunk-DQDNAG5E.js} +153 -35
- package/dist/cli.js +201 -25
- package/dist/{server-J2V5JXFY.js → server-WBZ5OPHK.js} +7 -3
- package/package.json +1 -1
- package/spec/openapi.json +8 -0
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
const
|
|
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
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
833
|
-
const pid = Number.parseInt(
|
|
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
|
-
|
|
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(
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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-
|
|
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-
|
|
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
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"
|