@allocator-one/harness-cli 1.0.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.
@@ -0,0 +1,1459 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/core/errors.ts
4
+ var HttpError = class extends Error {
5
+ constructor(status, message, body = {}, retryAfter = null) {
6
+ super(message);
7
+ this.status = status;
8
+ this.body = body;
9
+ this.retryAfter = retryAfter;
10
+ this.name = "HttpError";
11
+ }
12
+ status;
13
+ body;
14
+ retryAfter;
15
+ };
16
+ var UpgradeRequiredError = class extends HttpError {
17
+ constructor(message, body = {}) {
18
+ super(426, message, body);
19
+ this.name = "UpgradeRequiredError";
20
+ }
21
+ };
22
+ var TransportError = class extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "TransportError";
26
+ }
27
+ };
28
+ var UsageError = class extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "UsageError";
32
+ }
33
+ };
34
+ var LocalError = class extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "LocalError";
38
+ }
39
+ };
40
+ var CommandError = class extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "CommandError";
44
+ }
45
+ };
46
+
47
+ // src/version.ts
48
+ import { readFileSync } from "fs";
49
+ var pkg = JSON.parse(
50
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
51
+ );
52
+ var CLI_VERSION = pkg.version;
53
+
54
+ // src/core/client.ts
55
+ var RECEIVE_TIMEOUT_MS = 3e4;
56
+ var UPLOAD_RECEIVE_TIMEOUT_MS = 6e4;
57
+ var UPLOAD_FLOOR_BYTES_PER_MS = 100;
58
+ function uploadTimeoutMs(bodyBytes) {
59
+ return UPLOAD_RECEIVE_TIMEOUT_MS + Math.ceil(bodyBytes / UPLOAD_FLOOR_BYTES_PER_MS);
60
+ }
61
+ var DEFAULT_MESSAGES = {
62
+ 401: "Missing or invalid token (check HARNESS_API_TOKEN)",
63
+ 403: "Token not permitted (scope or admin gate)",
64
+ 404: "Not found",
65
+ 409: "Conflict",
66
+ 422: "Validation error",
67
+ 426: "CLI too old for this API \u2014 upgrade: npm i -g @allocator-one/harness-cli@latest",
68
+ 429: "Rate limited \u2014 retry later"
69
+ };
70
+ var Client = class {
71
+ constructor(config) {
72
+ this.config = config;
73
+ this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
74
+ }
75
+ config;
76
+ fetchImpl;
77
+ get cliVersion() {
78
+ return CLI_VERSION;
79
+ }
80
+ get baseUrl() {
81
+ return this.config.baseUrl;
82
+ }
83
+ async get(path2, params) {
84
+ return this.json(await this.raw("GET", path2, { params }));
85
+ }
86
+ async post(path2, body = {}) {
87
+ return this.json(await this.raw("POST", path2, { json: this.withActor(body) }));
88
+ }
89
+ async patch(path2, body) {
90
+ return this.json(await this.raw("PATCH", path2, { json: this.withActor(body) }));
91
+ }
92
+ async put(path2, body) {
93
+ return this.json(await this.raw("PUT", path2, { json: this.withActor(body) }));
94
+ }
95
+ async delete(path2) {
96
+ return this.json(await this.raw("DELETE", path2, {}));
97
+ }
98
+ /**
99
+ * Posts multipart/form-data — the one endpoint that does not take JSON.
100
+ * Shares auth, the version header, and error mapping with the JSON verbs,
101
+ * but never the actor: there is no JSON body to carry an `actor` field,
102
+ * and the server's file contract is the form itself.
103
+ */
104
+ async postMultipart(path2, form, bodyBytes = 0) {
105
+ return this.json(await this.raw("POST", path2, { form, timeoutMs: uploadTimeoutMs(bodyBytes) }));
106
+ }
107
+ /** Binary download (attachments); non-2xx still maps through the JSON error envelope. */
108
+ async getBytes(path2) {
109
+ const response = await this.raw("GET", path2, {});
110
+ if (!response.ok) throw await this.toHttpError(response);
111
+ return new Uint8Array(await response.arrayBuffer());
112
+ }
113
+ /**
114
+ * The server's API version, for `version`'s server side. The header rides
115
+ * on every response — the versioning plug runs before auth — so even a
116
+ * 401 answers.
117
+ */
118
+ async serverVersion() {
119
+ const response = await this.raw("GET", "/api/v1/stats", {});
120
+ return response.headers.get("x-harness-api-version") ?? "unknown";
121
+ }
122
+ // Only an explicitly configured actor is sent, and never over one the
123
+ // caller already put in the body.
124
+ withActor(body) {
125
+ if (this.config.actor === null || "actor" in body) return body;
126
+ return { ...body, actor: this.config.actor };
127
+ }
128
+ async raw(method, path2, options) {
129
+ const url = new URL(this.config.baseUrl + path2);
130
+ for (const [key, value] of Object.entries(options.params ?? {})) {
131
+ if (value !== void 0) url.searchParams.set(key, String(value));
132
+ }
133
+ const headers = {
134
+ authorization: `Bearer ${this.config.token}`,
135
+ "x-harness-cli-version": CLI_VERSION
136
+ };
137
+ let body;
138
+ if (options.json !== void 0) {
139
+ headers["content-type"] = "application/json";
140
+ body = JSON.stringify(options.json);
141
+ } else if (options.form !== void 0) {
142
+ body = options.form;
143
+ }
144
+ try {
145
+ return await this.fetchImpl(url, {
146
+ method,
147
+ headers,
148
+ body,
149
+ signal: AbortSignal.timeout(options.timeoutMs ?? RECEIVE_TIMEOUT_MS),
150
+ redirect: "manual"
151
+ });
152
+ } catch (error) {
153
+ throw new TransportError(transportMessage(error));
154
+ }
155
+ }
156
+ async json(response) {
157
+ if (!response.ok) throw await this.toHttpError(response);
158
+ const text = await response.text();
159
+ if (text === "") return { data: void 0 };
160
+ try {
161
+ return JSON.parse(text);
162
+ } catch {
163
+ throw new TransportError(`the API answered with a non-JSON body (HTTP ${response.status})`);
164
+ }
165
+ }
166
+ async toHttpError(response) {
167
+ let parsed = {};
168
+ try {
169
+ const candidate = JSON.parse(await response.text());
170
+ if (typeof candidate === "object" && candidate !== null) {
171
+ parsed = candidate;
172
+ }
173
+ } catch {
174
+ }
175
+ const fromBody = typeof parsed["message"] === "string" ? parsed["message"] : null;
176
+ const message = fromBody ?? DEFAULT_MESSAGES[response.status] ?? `HTTP ${response.status}`;
177
+ if (response.status === 426) return new UpgradeRequiredError(message, parsed);
178
+ return new HttpError(response.status, message, parsed, response.headers.get("retry-after"));
179
+ }
180
+ };
181
+ function transportMessage(error) {
182
+ if (error instanceof Error) {
183
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "request timed out";
184
+ const cause = error.cause;
185
+ if (cause instanceof Error && cause.message !== "") return cause.message;
186
+ return error.message;
187
+ }
188
+ return String(error);
189
+ }
190
+
191
+ // src/core/output.ts
192
+ var TITLE_WIDTH = 70;
193
+ var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
194
+ function graphemes(text) {
195
+ return [...segmenter.segment(text)].map((piece) => piece.segment);
196
+ }
197
+ function graphemeLength(text) {
198
+ return graphemes(text).length;
199
+ }
200
+ function padGraphemes(text, width) {
201
+ const shortfall = width - graphemeLength(text);
202
+ return shortfall > 0 ? text + " ".repeat(shortfall) : text;
203
+ }
204
+ function clean(value) {
205
+ if (value === null || value === void 0) return "";
206
+ return String(value).replace(/[\x00-\x1F\x7F\u0080-\u009F]/gu, "");
207
+ }
208
+ function json(term) {
209
+ return JSON.stringify(term) + "\n";
210
+ }
211
+ function issuesTable(issues) {
212
+ if (issues.length === 0) return "No issues.\n";
213
+ const rows = issues.map((issue) => [
214
+ str(issue["identifier"]),
215
+ str(path(issue, "state", "name")) || "-",
216
+ str(issue["priority_label"]) || "-",
217
+ str(path(issue, "assignee", "name")) || "-",
218
+ truncate(issue["title"])
219
+ ]);
220
+ return table(["ID", "STATE", "PRIORITY", "ASSIGNEE", "TITLE"], rows);
221
+ }
222
+ function issueDetail(issue, images = []) {
223
+ const header = `${str(issue["identifier"])} \xB7 ${clean(issue["title"])}
224
+ `;
225
+ const fields = [
226
+ ["State", path(issue, "state", "name")],
227
+ ["Priority", issue["priority_label"]],
228
+ ["Assignee", path(issue, "assignee", "name")],
229
+ ["Team", path(issue, "team", "key")],
230
+ ["Labels", labelsLine(issue["labels"])],
231
+ ["Blocked", blockedLine(issue)],
232
+ ["Due", issue["due_date"]],
233
+ ["Updated", issue["updated_at"]]
234
+ ];
235
+ const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
236
+ `).join("");
237
+ const sections = [
238
+ ["Description", issue["description"]],
239
+ ["Design", issue["design"]],
240
+ ["Acceptance criteria", issue["acceptance_criteria"]],
241
+ ["Notes", issue["notes"]]
242
+ ];
243
+ const sectionText = sections.filter(([, text]) => text !== null && text !== void 0 && text !== "").map(([title, text]) => `
244
+ ${title}:
245
+ ${indent(String(text))}
246
+ `).join("");
247
+ return header + fieldLines + sectionText + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
248
+ }
249
+ function stats(data) {
250
+ const teams = data["teams"] ?? [];
251
+ const totals = data["totals"] ?? {};
252
+ const rows = teams.map((team) => [
253
+ str(path(team, "team", "key")),
254
+ str(team["open"]),
255
+ str(team["started"]),
256
+ str(team["blocked"]),
257
+ str(team["ready"]),
258
+ str(team["closed"])
259
+ ]);
260
+ const totalsRow = ["TOTAL", ...["open", "started", "blocked", "ready", "closed"].map((key) => str(totals[key]))];
261
+ return table(["TEAM", "OPEN", "STARTED", "BLOCKED", "READY", "CLOSED"], [...rows, totalsRow]);
262
+ }
263
+ function table(headers, rows) {
264
+ const all = [headers, ...rows.map((row) => row.map((cell) => clean(cell)))];
265
+ const widths = headers.map((_, column) => Math.max(...all.map((row) => graphemeLength(row[column] ?? ""))));
266
+ return all.map(
267
+ (row) => row.map((cell, column) => padGraphemes(cell, widths[column] ?? 0)).join(" ").replace(/\s+$/, "") + "\n"
268
+ ).join("");
269
+ }
270
+ function relationsSection(relations) {
271
+ if (!Array.isArray(relations) || relations.length === 0) return "";
272
+ const lines = relations.map((relation) => ` ${str(relation["direction"]).padEnd(14)}${str(relation["other_issue"])}
273
+ `).join("");
274
+ return "\nRelations:\n" + lines;
275
+ }
276
+ function childrenSection(children) {
277
+ if (!Array.isArray(children) || children.length === 0) return "";
278
+ const lines = children.map((child) => {
279
+ const state = str(path(child, "state", "name")) || "-";
280
+ return ` ${str(child["identifier"])} [${state}] ${truncate(child["title"])}
281
+ `;
282
+ }).join("");
283
+ return "\nSub-issues:\n" + lines;
284
+ }
285
+ function commentsSection(comments2) {
286
+ if (!Array.isArray(comments2) || comments2.length === 0) return "";
287
+ const lines = comments2.map((comment2) => {
288
+ const author = clean(path(comment2, "author", "name") ?? "unknown");
289
+ return ` [${str(comment2["inserted_at"])}] ${author}: ${truncate(comment2["body"])}
290
+ `;
291
+ }).join("");
292
+ return "\nComments:\n" + lines;
293
+ }
294
+ function imagesSection(images) {
295
+ if (images.length === 0) return "";
296
+ const lines = images.map((image) => ` [${image["source"]}] ${clean(image["path"])}
297
+ `).join("");
298
+ return "\nImages (attachments downloaded locally \u2014 read them, they are part of the issue):\n" + lines;
299
+ }
300
+ function labelsLine(labels) {
301
+ if (!Array.isArray(labels) || labels.length === 0) return null;
302
+ return labels.map((label) => clean(label["name"])).join(", ");
303
+ }
304
+ function blockedLine(issue) {
305
+ if (issue["blocked"] === true && Array.isArray(issue["blocked_by"])) {
306
+ return `yes, by ${issue["blocked_by"].map(String).join(", ")}`;
307
+ }
308
+ return null;
309
+ }
310
+ function indent(text) {
311
+ return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
312
+ }
313
+ function cleanMultiline(text) {
314
+ return text.split("\n").map((line) => clean(line)).join("\n");
315
+ }
316
+ function truncate(text) {
317
+ if (text === null || text === void 0) return "";
318
+ const cleaned = clean(text);
319
+ const pieces = graphemes(cleaned);
320
+ if (pieces.length > TITLE_WIDTH) {
321
+ return pieces.slice(0, TITLE_WIDTH - 1).join("") + "\u2026";
322
+ }
323
+ return cleaned;
324
+ }
325
+ function str(value) {
326
+ return value === null || value === void 0 ? "" : String(value);
327
+ }
328
+ function path(record, ...keys) {
329
+ let current = record;
330
+ for (const key of keys) {
331
+ if (typeof current !== "object" || current === null) return null;
332
+ current = current[key];
333
+ }
334
+ return current ?? null;
335
+ }
336
+
337
+ // src/commands/issues.ts
338
+ import { openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
339
+ import { basename } from "path";
340
+
341
+ // src/core/attachments.ts
342
+ import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
343
+ import { dirname, join } from "path";
344
+ var REF = /\/(?:uploads\/harness|api\/v1)\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/([A-Za-z0-9._-]*\.(?:png|jpe?g|webp|gif))(?![A-Za-z0-9._-])/gi;
345
+ var SOURCES = [
346
+ ["description", "description"],
347
+ ["design", "design"],
348
+ ["acceptance_criteria", "acceptance criteria"],
349
+ ["notes", "notes"]
350
+ ];
351
+ var MAX_IMAGES_PER_SHOW = 20;
352
+ var MAX_ATTACHMENT_BYTES = 25e6;
353
+ function refs(issue) {
354
+ const texts = SOURCES.map(([field2, source]) => [issue[field2], source]);
355
+ const comments2 = Array.isArray(issue["comments"]) ? issue["comments"] : [];
356
+ for (const comment2 of comments2) texts.push([comment2["body"], "comment"]);
357
+ const found = [];
358
+ const seen = /* @__PURE__ */ new Set();
359
+ for (const [text, source] of texts) {
360
+ if (typeof text !== "string" || text === "") continue;
361
+ for (const match of text.matchAll(REF)) {
362
+ const ref = {
363
+ attachmentId: match[1].toLowerCase(),
364
+ file: match[2],
365
+ source
366
+ };
367
+ const key = `${ref.attachmentId}/${ref.file}`;
368
+ if (!seen.has(key)) {
369
+ seen.add(key);
370
+ found.push(ref);
371
+ }
372
+ }
373
+ }
374
+ return found;
375
+ }
376
+ async function fetchImages(client, issue, cacheDir) {
377
+ const allRefs = refs(issue);
378
+ const wanted = allRefs.slice(0, MAX_IMAGES_PER_SHOW);
379
+ const dropped = allRefs.slice(MAX_IMAGES_PER_SHOW);
380
+ const images = [];
381
+ const failures = [];
382
+ for (const ref of wanted) {
383
+ const result = await download(client, ref, cacheDir);
384
+ if (result.ok) {
385
+ images.push({
386
+ attachment_id: ref.attachmentId,
387
+ file: ref.file,
388
+ path: result.path,
389
+ source: ref.source
390
+ });
391
+ } else {
392
+ failures.push(`${ref.file}: ${result.message}`);
393
+ }
394
+ }
395
+ if (dropped.length > 0) {
396
+ failures.push(
397
+ `${dropped.length} further reference(s) skipped \u2014 the cap is ${MAX_IMAGES_PER_SHOW} images per show`
398
+ );
399
+ }
400
+ return { images, failures };
401
+ }
402
+ async function download(client, ref, cacheDir) {
403
+ const path2 = join(cacheDir, hostSegment(client.baseUrl), ref.attachmentId, ref.file);
404
+ try {
405
+ if (statSync(path2).size > 0) return { ok: true, path: path2 };
406
+ } catch {
407
+ }
408
+ const bytes = await fetchBytes(client, ref);
409
+ if (!bytes.ok) return bytes;
410
+ return write(path2, bytes.bytes);
411
+ }
412
+ function hostSegment(baseUrl) {
413
+ let host = "unknown-host";
414
+ let port = null;
415
+ try {
416
+ const parsed = new URL(baseUrl);
417
+ host = parsed.hostname.toLowerCase().replace(/[^a-z0-9.-]/g, "_");
418
+ port = parsed.port === "" ? null : Number(parsed.port);
419
+ } catch {
420
+ }
421
+ return port === null || port === 80 || port === 443 ? host : `${host}_${port}`;
422
+ }
423
+ async function fetchBytes(client, ref) {
424
+ let bytes;
425
+ try {
426
+ bytes = await client.getBytes(`/api/v1/attachments/${ref.attachmentId}/${ref.file}`);
427
+ } catch (error) {
428
+ if (error instanceof HttpError) return { ok: false, message: `${error.message} (HTTP ${error.status})` };
429
+ if (error instanceof TransportError) return { ok: false, message: error.message };
430
+ throw error;
431
+ }
432
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
433
+ return { ok: false, message: `response is larger than the ${MAX_ATTACHMENT_BYTES}-byte attachment cap` };
434
+ }
435
+ if (!isImage(bytes)) return { ok: false, message: "response is not an image" };
436
+ return { ok: true, bytes };
437
+ }
438
+ function isImage(bytes) {
439
+ const startsWith = (prefix) => prefix.every((byte, index) => bytes[index] === byte);
440
+ if (startsWith([137, 80, 78, 71, 13, 10, 26, 10])) return true;
441
+ if (startsWith([255, 216, 255])) return true;
442
+ if (startsWith([71, 73, 70, 56, 55, 97])) return true;
443
+ if (startsWith([71, 73, 70, 56, 57, 97])) return true;
444
+ if (startsWith([82, 73, 70, 70]) && bytes.length >= 12) {
445
+ return bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80;
446
+ }
447
+ return false;
448
+ }
449
+ var partialCounter = 0;
450
+ function write(path2, bytes) {
451
+ const partial = `${path2}.partial.${process.pid}.${++partialCounter}`;
452
+ try {
453
+ mkdirSync(dirname(path2), { recursive: true });
454
+ chmodSync(dirname(path2), 448);
455
+ writeFileSync(partial, bytes);
456
+ chmodSync(partial, 384);
457
+ renameSync(partial, path2);
458
+ return { ok: true, path: path2 };
459
+ } catch (error) {
460
+ rmSync(partial, { force: true });
461
+ const detail = error instanceof Error ? error.message : String(error);
462
+ return { ok: false, message: `cannot write ${path2}: ${detail}` };
463
+ }
464
+ }
465
+
466
+ // src/core/pagination.ts
467
+ function notices(meta, requestedPageSize, records) {
468
+ return capNotice(meta, requestedPageSize) + truncationNotice(meta, records);
469
+ }
470
+ function capNotice(meta, requestedPageSize) {
471
+ const served = meta?.page_size;
472
+ if (typeof served === "number" && typeof requestedPageSize === "number" && requestedPageSize > served) {
473
+ return `harness: --limit ${requestedPageSize} was capped at ${served} by the server.
474
+ `;
475
+ }
476
+ return "";
477
+ }
478
+ function truncationNotice(meta, records) {
479
+ const total = meta?.total;
480
+ const page = meta?.page;
481
+ const pages = meta?.total_pages;
482
+ if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
483
+ const shown = records.length;
484
+ if (shown < total) {
485
+ return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
486
+ `;
487
+ }
488
+ return "";
489
+ }
490
+ async function fetchEveryPage(fetchPage) {
491
+ const first = await fetchPage(1);
492
+ const totalPages = pageCount(first.meta);
493
+ if (totalPages === null) return first.data;
494
+ let records = first.data;
495
+ for (let page = 2; page <= totalPages; page++) {
496
+ const body = await fetchPage(page);
497
+ records = dedupe([...records, ...body.data]);
498
+ }
499
+ return records;
500
+ }
501
+ function pageCount(meta) {
502
+ const total = meta?.total_pages;
503
+ return typeof total === "number" && total > 0 ? total : null;
504
+ }
505
+ function dedupe(records) {
506
+ const seen = /* @__PURE__ */ new Set();
507
+ return records.filter((record) => {
508
+ const key = record["id"] ?? record;
509
+ if (seen.has(key)) return false;
510
+ seen.add(key);
511
+ return true;
512
+ });
513
+ }
514
+
515
+ // src/commands/issues.ts
516
+ var PRIORITY_NAMES = { urgent: 1, high: 2, medium: 3, low: 4, none: 0 };
517
+ var PRIORITY_RANK = { 1: 0, 2: 1, 3: 2, 4: 3, 0: 4 };
518
+ var STATUS_STATE_TYPES = {
519
+ open: "backlog,unstarted",
520
+ in_progress: "started",
521
+ closed: "completed,canceled"
522
+ };
523
+ var READY_CLAIM_ATTEMPTS = 10;
524
+ var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
525
+ function requireIdentifiers(identifiers) {
526
+ const bad = identifiers.filter((identifier) => !IDENTIFIER_FORMAT.test(identifier));
527
+ if (bad.length > 0) {
528
+ throw new UsageError(`not an issue identifier: ${bad.join(", ")} (expected e.g. ALL-4223)`);
529
+ }
530
+ }
531
+ async function ready(ctx, opts) {
532
+ const params = { ...listParams(ctx, opts), ready: true };
533
+ const { records, notice } = await fetchIssues(ctx, params, opts);
534
+ const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
535
+ const sorted = [...records].sort((a, b) => rank(a) - rank(b));
536
+ if (opts.claim === true) return claimFirst(ctx, sorted);
537
+ return { json: sorted, human: issuesTable(sorted), notice };
538
+ }
539
+ async function blocked(ctx, opts) {
540
+ const params = { ...listParams(ctx, opts), blocked: true };
541
+ const { records, notice } = await fetchIssues(ctx, params, opts);
542
+ return { json: records, human: issuesTable(records), notice };
543
+ }
544
+ async function list(ctx, opts) {
545
+ const params = listParams(ctx, opts);
546
+ const { records, notice } = await fetchIssues(ctx, params, opts);
547
+ return { json: records, human: issuesTable(records), notice };
548
+ }
549
+ async function search(ctx, queryWords, opts) {
550
+ if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
551
+ const params = {
552
+ search: queryWords.join(" "),
553
+ page_size: opts.limit ?? 50,
554
+ page: opts.page ?? 1
555
+ };
556
+ const { records, notice } = await fetchIssues(ctx, params, opts);
557
+ return { json: records, human: issuesTable(records), notice };
558
+ }
559
+ async function show(ctx, identifier, opts) {
560
+ requireIdentifiers([identifier]);
561
+ const body = await ctx.client.get(`/api/v1/issues/${identifier}`, {
562
+ include: "relations,children,comments"
563
+ });
564
+ const issue = body.data;
565
+ let images = [];
566
+ let failures = [];
567
+ if (opts.images !== false) {
568
+ ({ images, failures } = await fetchImages(ctx.client, issue, ctx.config.cacheDir));
569
+ }
570
+ const json2 = images.length === 0 ? issue : { ...issue, local_images: images };
571
+ const human = issueDetail(issue, images);
572
+ const notice = failures.map((failure) => `harness: image not downloaded: ${clean(failure)}
573
+ `).join("");
574
+ return notice === "" ? { json: json2, human } : { json: json2, human, notice };
575
+ }
576
+ async function stats2(ctx) {
577
+ const body = await ctx.client.get("/api/v1/stats");
578
+ return { json: body.data, human: stats(body.data) };
579
+ }
580
+ async function prime(ctx) {
581
+ const statsBody = await ctx.client.get("/api/v1/stats");
582
+ const readyBody = await ctx.client.get("/api/v1/issues", { ready: true, page_size: 10 });
583
+ const json2 = { stats: statsBody.data, ready: readyBody.data };
584
+ const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(readyBody.data) + `
585
+ Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
586
+ harness close <id> --reason "Implemented in <commit-hash>" (response
587
+ lists newly unblocked issues \u2014 surface them).
588
+ Create tracked work BEFORE coding: harness create --title "\u2026" --type task.
589
+ Dependencies: harness dep add <issue> <blocker> (blocker blocks issue).
590
+ Before reporting done: close the issues you finished.
591
+ `;
592
+ return { json: json2, human };
593
+ }
594
+ async function version(config) {
595
+ let server;
596
+ if (config === null || config.baseUrl === "") {
597
+ server = "unconfigured";
598
+ } else {
599
+ server = await new Client(config).serverVersion().catch(() => "unreachable");
600
+ }
601
+ return { json: { cli: CLI_VERSION, server }, human: `harness ${CLI_VERSION} (server API ${server})
602
+ ` };
603
+ }
604
+ async function create(ctx, opts) {
605
+ const labels = wantedLabels(opts);
606
+ if (opts.title === void 0) {
607
+ throw new UsageError("usage: harness create --title <title> [--team <key>] \u2026");
608
+ }
609
+ validateType(opts.type);
610
+ const team = requiredTeam(ctx, opts.team);
611
+ const priority = optionalPriority(opts.priority);
612
+ const dueDate = optionalDueDate(opts.dueDate);
613
+ let body = { title: opts.title };
614
+ body = putOpt(body, "description", opts.description);
615
+ body = putOpt(body, "design", opts.design);
616
+ body = putOpt(body, "notes", opts.notes);
617
+ body = putOpt(body, "acceptance_criteria", opts.acceptance);
618
+ body = putOpt(body, "parent", opts.parent);
619
+ body = putPresent(body, "priority", priority);
620
+ body = putDueDate(body, dueDate);
621
+ body = putAssignee(body, opts.assignee);
622
+ const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body);
623
+ const issue = created.data;
624
+ try {
625
+ const labeled = await applyLabels(ctx, issue, labels);
626
+ return {
627
+ json: labeled,
628
+ human: `Created ${str2(labeled["identifier"])}: ${clean(labeled["title"])}
629
+ `
630
+ };
631
+ } catch (error) {
632
+ if (error instanceof HttpError || error instanceof TransportError) {
633
+ throw new CommandError(
634
+ `created ${str2(issue["identifier"])}, but applying labels failed: ${errorText(error)}`
635
+ );
636
+ }
637
+ throw error;
638
+ }
639
+ }
640
+ async function update(ctx, identifier, opts) {
641
+ requireIdentifiers([identifier]);
642
+ if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
643
+ throw new UsageError("--notes replaces and --append-notes appends \u2014 pass one, not both");
644
+ }
645
+ if (opts.claim === true && opts.assignee !== void 0) {
646
+ throw new UsageError(
647
+ "--claim takes the issue and starts it; --assignee only sets the field \u2014 pass one, not both (--claim to take it yourself, --assignee to hand it to someone)"
648
+ );
649
+ }
650
+ const priority = optionalPriority(opts.priority);
651
+ const dueDate = optionalDueDate(opts.dueDate);
652
+ let issue = null;
653
+ if (opts.claim === true) {
654
+ const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
655
+ issue = body.data;
656
+ }
657
+ let patch = {};
658
+ patch = putOpt(patch, "title", opts.title);
659
+ patch = putOpt(patch, "description", opts.description);
660
+ patch = putOpt(patch, "design", opts.design);
661
+ patch = putOpt(patch, "notes", opts.notes);
662
+ patch = putOpt(patch, "acceptance_criteria", opts.acceptance);
663
+ patch = putOpt(patch, "parent", opts.parent);
664
+ patch = putPresent(patch, "priority", priority);
665
+ patch = putDueDate(patch, dueDate);
666
+ patch = putAssignee(patch, opts.assignee);
667
+ if (opts.appendNotes !== void 0) {
668
+ const current = await ctx.client.get(`/api/v1/issues/${identifier}`);
669
+ const existing = current.data["notes"];
670
+ const combined = existing === null || existing === void 0 || existing === "" ? opts.appendNotes : `${str2(existing)}
671
+
672
+ ${opts.appendNotes}`;
673
+ patch["notes"] = combined;
674
+ }
675
+ if (Object.keys(patch).length > 0) {
676
+ const body = await ctx.client.patch(`/api/v1/issues/${identifier}`, patch);
677
+ issue = body.data;
678
+ }
679
+ if (opts.state !== void 0) {
680
+ issue = await setState(ctx, identifier, opts.state);
681
+ }
682
+ if (issue === null) {
683
+ throw new UsageError("usage: harness update <id> --claim | --title \u2026 | --state <name> | \u2026");
684
+ }
685
+ return { json: issue, human: `Updated ${str2(issue["identifier"])}
686
+ ` };
687
+ }
688
+ async function close(ctx, identifiers, opts) {
689
+ if (identifiers.length === 0) {
690
+ throw new UsageError("usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel]");
691
+ }
692
+ requireIdentifiers(identifiers);
693
+ let body = {};
694
+ body = putPresent(body, "reason", opts.reason);
695
+ body = putPresent(body, "cancel", opts.cancel);
696
+ const results = [];
697
+ for (const identifier of identifiers) {
698
+ try {
699
+ const response = await ctx.client.post(`/api/v1/issues/${identifier}/close`, body);
700
+ results.push({ identifier, ok: true, response });
701
+ } catch (error) {
702
+ if (error instanceof HttpError || error instanceof TransportError) {
703
+ results.push({ identifier, ok: false, error });
704
+ } else {
705
+ throw error;
706
+ }
707
+ }
708
+ }
709
+ const human = results.map((result) => closeLine(result)).join("");
710
+ const json2 = results.map(
711
+ (result) => result.ok ? { identifier: result.identifier, closed: true, unblocked: result.response["unblocked"] ?? null } : { identifier: result.identifier, closed: false, error: errorText(result.error) }
712
+ );
713
+ const partial = results.some((result) => !result.ok);
714
+ return partial ? { json: json2, human, partial: true } : { json: json2, human };
715
+ }
716
+ function closeLine(result) {
717
+ if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
718
+ `;
719
+ const unblocked = result.response["unblocked"];
720
+ if (Array.isArray(unblocked) && unblocked.length > 0) {
721
+ return `Closed ${result.identifier} \u2014 unblocked: ${unblocked.map(String).join(", ")}
722
+ `;
723
+ }
724
+ return `Closed ${result.identifier}
725
+ `;
726
+ }
727
+ async function archive(ctx, identifiers, opts) {
728
+ if (identifiers.length === 0) {
729
+ throw new UsageError("usage: harness archive <id> [<id>\u2026] [--restore]");
730
+ }
731
+ requireIdentifiers(identifiers);
732
+ const archived = opts.restore !== true;
733
+ const verb = archived ? "Archived" : "Restored";
734
+ const results = [];
735
+ for (const identifier of identifiers) {
736
+ try {
737
+ const body = await ctx.client.put(`/api/v1/issues/${identifier}/archive_state`, { archived });
738
+ results.push({ identifier, ok: true, issue: body.data });
739
+ } catch (error) {
740
+ if (error instanceof HttpError || error instanceof TransportError) {
741
+ results.push({ identifier, ok: false, error });
742
+ } else {
743
+ throw error;
744
+ }
745
+ }
746
+ }
747
+ const human = results.map(
748
+ (result) => result.ok ? `${verb} ${result.identifier}
749
+ ` : `FAILED ${result.identifier}: ${errorText(result.error)}
750
+ `
751
+ ).join("");
752
+ const json2 = results.map(
753
+ (result) => result.ok ? { identifier: result.identifier, archived } : { identifier: result.identifier, error: errorText(result.error) }
754
+ );
755
+ const partial = results.some((result) => !result.ok);
756
+ return partial ? { json: json2, human, partial: true } : { json: json2, human };
757
+ }
758
+ async function comments(ctx, identifier) {
759
+ requireIdentifiers([identifier]);
760
+ const body = await ctx.client.get(`/api/v1/issues/${identifier}/comments`);
761
+ if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
762
+ const human = body.data.map((comment2) => {
763
+ const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
764
+ const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
765
+ return `[${str2(comment2["inserted_at"])}] ${author}:
766
+ ${text}
767
+ `;
768
+ }).join("\n");
769
+ return { json: body.data, human };
770
+ }
771
+ async function dep(ctx, verb, issue, blocker) {
772
+ requireIdentifiers([issue, blocker]);
773
+ if (verb === "add") {
774
+ const response = await ctx.client.post(`/api/v1/issues/${issue}/relations`, {
775
+ type: "blocked_by",
776
+ related_issue: blocker
777
+ });
778
+ return { json: response.data, human: `${blocker} now blocks ${issue}
779
+ ` };
780
+ }
781
+ if (verb === "rm") {
782
+ const body = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
783
+ const relation = (body.data ?? []).find(
784
+ (candidate) => candidate["direction"] === "blocked_by" && candidate["other_issue"] === blocker
785
+ );
786
+ if (relation === void 0) throw new CommandError(`${blocker} does not block this issue.`);
787
+ await ctx.client.delete(`/api/v1/issues/${issue}/relations/${str2(relation["id"])}`);
788
+ return { json: { deleted: relation["id"] }, human: `${blocker} no longer blocks ${issue}
789
+ ` };
790
+ }
791
+ throw new UsageError("usage: harness dep add|rm <issue> <blocker> (the blocker blocks the issue)");
792
+ }
793
+ async function comment(ctx, identifier, textWords, mentions) {
794
+ requireIdentifiers([identifier]);
795
+ if (textWords.length === 0) {
796
+ throw new UsageError("usage: harness comment <id> <text> [--mention NAME]\u2026");
797
+ }
798
+ const body = { body: textWords.join(" ") };
799
+ if (mentions.length > 0) body["mentions"] = mentions;
800
+ const response = await ctx.client.post(`/api/v1/issues/${identifier}/comments`, body);
801
+ return { json: response.data, human: `Commented on ${identifier}
802
+ ` };
803
+ }
804
+ var MAX_ATTACHMENT_BYTES2 = 25e6;
805
+ async function attachImage(ctx, path2) {
806
+ attachableFile(path2);
807
+ const bytes = readFileSync2(path2);
808
+ const form = new FormData();
809
+ form.set("file", new File([bytes], basename(path2)));
810
+ const body = await ctx.client.postMultipart("/api/v1/uploads", form, bytes.byteLength);
811
+ const attachment = body.data;
812
+ const markdown = attachment["markdown"];
813
+ if (typeof markdown !== "string") throw new CommandError("the upload response carried no markdown reference");
814
+ return { json: attachment, human: clean(markdown) + "\n" };
815
+ }
816
+ function attachableFile(path2) {
817
+ let fileStats;
818
+ try {
819
+ fileStats = statSync2(path2);
820
+ } catch (error) {
821
+ throw new LocalError(`cannot read ${path2}: ${posixDetail(error)}`);
822
+ }
823
+ if (!fileStats.isFile()) {
824
+ const kind = fileStats.isDirectory() ? "directory" : "special file";
825
+ throw new LocalError(`${path2} is a ${kind}, not a file.`);
826
+ }
827
+ if (fileStats.size > MAX_ATTACHMENT_BYTES2) {
828
+ throw new LocalError(
829
+ `${path2} is too large: ${fileStats.size} bytes, and attachments are capped at ${MAX_ATTACHMENT_BYTES2}.`
830
+ );
831
+ }
832
+ try {
833
+ closeSync(openSync(path2, "r"));
834
+ } catch (error) {
835
+ throw new LocalError(`cannot read ${path2}: ${posixDetail(error)}`);
836
+ }
837
+ }
838
+ function posixDetail(error) {
839
+ const code = error.code;
840
+ if (code === "ENOENT") return "no such file or directory";
841
+ if (code === "EACCES") return "permission denied";
842
+ return error instanceof Error ? error.message : String(error);
843
+ }
844
+ async function claimFirst(ctx, issues) {
845
+ for (const issue of issues.slice(0, READY_CLAIM_ATTEMPTS)) {
846
+ try {
847
+ const body = await ctx.client.post(`/api/v1/issues/${str2(issue["identifier"])}/claim`);
848
+ const claimed = body.data;
849
+ return {
850
+ json: claimed,
851
+ human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
852
+ `
853
+ };
854
+ } catch (error) {
855
+ if (error instanceof HttpError && error.status === 409) continue;
856
+ throw error;
857
+ }
858
+ }
859
+ throw new CommandError("No claimable ready issues.");
860
+ }
861
+ async function setState(ctx, identifier, stateName) {
862
+ const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
863
+ const teamKey = str2((showBody.data["team"] ?? {})["key"]);
864
+ const statesBody = await ctx.client.get(
865
+ `/api/v1/teams/${teamKey}/workflow_states`
866
+ );
867
+ const wanted = stateName.toLowerCase();
868
+ const state = statesBody.data.find(
869
+ (candidate) => str2(candidate["name"]).toLowerCase() === wanted || str2(candidate["type"]) === wanted
870
+ );
871
+ if (state === void 0) {
872
+ const names = statesBody.data.map((candidate) => str2(candidate["name"])).join(", ");
873
+ throw new CommandError(`No workflow state matches "${stateName}". States: ${names}`);
874
+ }
875
+ const body = await ctx.client.put(`/api/v1/issues/${identifier}/state`, { state_id: state["id"] });
876
+ return body.data;
877
+ }
878
+ var ISSUE_TYPES = ["task", "bug", "feature", "epic"];
879
+ function wantedLabels(opts) {
880
+ const typeLabels = opts.type === void 0 ? [] : [`type/${opts.type}`];
881
+ return [...typeLabels, ...opts.label ?? []];
882
+ }
883
+ function validateType(type) {
884
+ if (type !== void 0 && !ISSUE_TYPES.includes(type)) {
885
+ throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
886
+ }
887
+ }
888
+ async function applyLabels(ctx, issue, names) {
889
+ if (names.length === 0) return issue;
890
+ const labelsBody = await ctx.client.get("/api/v1/labels");
891
+ const byName = new Map(labelsBody.data.map((label) => [str2(label["name"]), label["id"]]));
892
+ const ids = [];
893
+ for (const name of names) {
894
+ const existing = byName.get(name);
895
+ if (existing !== void 0) {
896
+ ids.push(existing);
897
+ } else {
898
+ const created = await ctx.client.post("/api/v1/labels", {
899
+ name,
900
+ color: "gray"
901
+ });
902
+ ids.push(created.data["id"]);
903
+ }
904
+ }
905
+ const body = await ctx.client.put(`/api/v1/issues/${str2(issue["identifier"])}/labels`, {
906
+ label_ids: ids
907
+ });
908
+ return body.data;
909
+ }
910
+ async function fetchIssues(ctx, params, opts) {
911
+ if (opts.all === true && opts.page !== void 0) {
912
+ throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
913
+ }
914
+ if (opts.all === true) {
915
+ const records2 = await fetchEveryPage(
916
+ (page) => ctx.client.get("/api/v1/issues", { ...params, page })
917
+ );
918
+ return { records: records2, notice: "" };
919
+ }
920
+ const body = await ctx.client.get("/api/v1/issues", params);
921
+ const records = body.data;
922
+ const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
923
+ return { records, notice: notices(body.meta, requested, records) };
924
+ }
925
+ function listParams(ctx, opts) {
926
+ if (opts.type !== void 0 && opts.label !== void 0) {
927
+ throw new UsageError("--type is a label filter itself \u2014 pass either --type or --label, not both");
928
+ }
929
+ validateType(opts.type);
930
+ let params = withTeam({ page_size: opts.limit ?? 50, page: opts.page ?? 1 }, ctx, opts);
931
+ params = putParam(params, "search", opts.search);
932
+ params = putParam(params, "parent", opts.parent);
933
+ params = putParam(params, "label", opts.label);
934
+ params = putParam(params, "scope", opts.scope);
935
+ params = putParam(params, "assignee", opts.assignee);
936
+ if (opts.type !== void 0) params = putParam(params, "label", `type/${opts.type}`);
937
+ if (opts.status !== void 0) {
938
+ const stateTypes = Object.hasOwn(STATUS_STATE_TYPES, opts.status) ? STATUS_STATE_TYPES[opts.status] : void 0;
939
+ if (stateTypes === void 0) {
940
+ throw new UsageError(`status must be one of: ${Object.keys(STATUS_STATE_TYPES).join(", ")}`);
941
+ }
942
+ params = putParam(params, "state_type", stateTypes);
943
+ }
944
+ const priority = optionalPriority(opts.priority);
945
+ if (priority !== null) params = putParam(params, "priority", priority);
946
+ return params;
947
+ }
948
+ function optionalPriority(value) {
949
+ if (value === void 0) return null;
950
+ const named = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
951
+ if (named !== void 0) return named;
952
+ if (/^\d+$/.test(value)) {
953
+ throw new UsageError(
954
+ `Bare numeric priorities are refused: bd and harness disagree on what
955
+ the numbers mean, so a habit-typed number would silently file the
956
+ wrong priority.
957
+
958
+ bd: 0=critical 1=high 2=medium 3=normal 4=backlog
959
+ harness: 0=none 1=urgent 2=high 3=medium 4=low
960
+
961
+ Use a name instead: --priority urgent|high|medium|low|none
962
+ `
963
+ );
964
+ }
965
+ throw new UsageError("priority must be one of: urgent, high, medium, low, none");
966
+ }
967
+ function optionalDueDate(value) {
968
+ if (value === void 0) return null;
969
+ const trimmed = value.trim();
970
+ if (trimmed === "") return "clear";
971
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
972
+ if (match === null) throw new UsageError(`not a date: ${trimmed} (expected YYYY-MM-DD)`);
973
+ const [, year, month, day] = match.map(Number);
974
+ const candidate = /* @__PURE__ */ new Date(0);
975
+ candidate.setUTCFullYear(year, month - 1, day);
976
+ const real = candidate.getUTCFullYear() === year && candidate.getUTCMonth() === month - 1 && candidate.getUTCDate() === day;
977
+ if (!real) throw new UsageError(`not a date: ${trimmed} (no such day on the calendar)`);
978
+ return trimmed;
979
+ }
980
+ var TEAM_KEY_FORMAT = /^[A-Za-z][A-Za-z0-9]*$/;
981
+ function requiredTeam(ctx, team) {
982
+ const resolved = team ?? ctx.config.team ?? void 0;
983
+ if (resolved === void 0) throw new UsageError("No team given. Pass --team <key> or set HARNESS_TEAM.");
984
+ if (!TEAM_KEY_FORMAT.test(resolved)) throw new UsageError(`not a team key: ${resolved} (expected e.g. ALL)`);
985
+ return resolved;
986
+ }
987
+ function withTeam(params, ctx, opts) {
988
+ const team = opts.team ?? ctx.config.team ?? void 0;
989
+ return team === void 0 ? params : { ...params, team };
990
+ }
991
+ function putParam(params, key, value) {
992
+ return value === void 0 ? params : { ...params, [key]: value };
993
+ }
994
+ function putOpt(body, field2, value) {
995
+ return value === void 0 ? body : { ...body, [field2]: value };
996
+ }
997
+ function putPresent(body, key, value) {
998
+ if (value === null || value === void 0 || value === false) return body;
999
+ return { ...body, [key]: value };
1000
+ }
1001
+ function putDueDate(body, dueDate) {
1002
+ if (dueDate === null) return body;
1003
+ if (dueDate === "clear") return { ...body, due_date: null };
1004
+ return { ...body, due_date: dueDate };
1005
+ }
1006
+ function putAssignee(body, assignee) {
1007
+ if (assignee === void 0) return body;
1008
+ return { ...body, assignee: assignee.trim() === "" ? null : assignee };
1009
+ }
1010
+ function errorText(error) {
1011
+ if (error instanceof HttpError) return `${clean(error.message)} (HTTP ${error.status})`;
1012
+ return `cannot reach the API: ${clean(error.message)}`;
1013
+ }
1014
+ function str2(value) {
1015
+ return value === null || value === void 0 ? "" : String(value);
1016
+ }
1017
+
1018
+ // src/commands/extended.ts
1019
+ var SAFE_ID = /^[A-Za-z0-9._-]+$/;
1020
+ function requireSafeId(kind, id) {
1021
+ if (!SAFE_ID.test(id)) throw new UsageError(`not a ${kind} id: ${id}`);
1022
+ }
1023
+ function str3(value) {
1024
+ return value === null || value === void 0 ? "" : String(value);
1025
+ }
1026
+ function field(row, ...keys) {
1027
+ let current = row;
1028
+ for (const key of keys) {
1029
+ if (typeof current !== "object" || current === null) return null;
1030
+ current = current[key];
1031
+ }
1032
+ return current ?? null;
1033
+ }
1034
+ function putOpt2(body, key, value) {
1035
+ return value === void 0 ? body : { ...body, [key]: value };
1036
+ }
1037
+ function putDate(body, key, raw) {
1038
+ const parsed = optionalDueDate(raw);
1039
+ if (parsed === null) return body;
1040
+ return { ...body, [key]: parsed === "clear" ? null : parsed };
1041
+ }
1042
+ async function paginatedRead(ctx, path2, params, opts) {
1043
+ if (opts.all === true && opts.page !== void 0) {
1044
+ throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1045
+ }
1046
+ if (opts.all === true) {
1047
+ const records = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
1048
+ return { records, notice: "" };
1049
+ }
1050
+ const body = await ctx.client.get(path2, params);
1051
+ const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
1052
+ return { records: body.data, notice: notices(body.meta, requested, body.data) };
1053
+ }
1054
+ async function projectList(ctx, opts) {
1055
+ let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1056
+ if (opts.search !== void 0) params = { ...params, search: opts.search };
1057
+ if (opts.state !== void 0) params = { ...params, state: opts.state };
1058
+ const { records, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
1059
+ const human = records.length === 0 ? "No projects.\n" : table(
1060
+ ["ID", "STATE", "LEAD", "TARGET", "NAME"],
1061
+ records.map((project) => [
1062
+ str3(project["id"]),
1063
+ str3(project["state"]) || "-",
1064
+ str3(field(project, "lead", "name")) || "-",
1065
+ str3(project["target_date"]) || "-",
1066
+ str3(project["name"])
1067
+ ])
1068
+ );
1069
+ return { json: records, human, notice };
1070
+ }
1071
+ async function projectShow(ctx, id) {
1072
+ requireSafeId("project", id);
1073
+ const body = await ctx.client.get(`/api/v1/projects/${id}`);
1074
+ const project = body.data;
1075
+ const fields = [
1076
+ ["State", project["state"]],
1077
+ ["Lead", field(project, "lead", "name")],
1078
+ ["Start", project["start_date"]],
1079
+ ["Target", project["target_date"]],
1080
+ ["Updated", project["updated_at"]]
1081
+ ];
1082
+ const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
1083
+ `).join("");
1084
+ const sections = [
1085
+ ["Summary", project["summary"]],
1086
+ ["Description", project["description"]]
1087
+ ];
1088
+ const sectionText = sections.filter(([, text]) => text !== null && text !== void 0 && text !== "").map(([title, text]) => `
1089
+ ${title}:
1090
+ ${indent2(String(text))}
1091
+ `).join("");
1092
+ const milestones = Array.isArray(project["milestones"]) ? project["milestones"] : [];
1093
+ const milestoneText = milestones.length === 0 ? "" : "\nMilestones:\n" + milestones.map((milestone) => ` ${(str3(milestone["target_date"]) || "-").padEnd(12)}${clean(milestone["name"])}
1094
+ `).join("");
1095
+ const human = `${str3(project["id"])} \xB7 ${clean(project["name"])}
1096
+ ` + fieldLines + sectionText + milestoneText;
1097
+ return { json: project, human };
1098
+ }
1099
+ function projectBody(opts) {
1100
+ let body = {};
1101
+ body = putOpt2(body, "name", opts.name);
1102
+ body = putOpt2(body, "description", opts.description);
1103
+ body = putOpt2(body, "summary", opts.summary);
1104
+ body = putOpt2(body, "state", opts.state);
1105
+ body = putOpt2(body, "lead_id", opts.lead);
1106
+ body = putDate(body, "start_date", opts.startDate);
1107
+ body = putDate(body, "target_date", opts.targetDate);
1108
+ return body;
1109
+ }
1110
+ async function projectCreate(ctx, opts) {
1111
+ if (opts.name === void 0) throw new UsageError("usage: harness project create --name <name> \u2026");
1112
+ const body = await ctx.client.post("/api/v1/projects", projectBody(opts));
1113
+ const project = body.data;
1114
+ return { json: project, human: `Created project ${clean(project["name"])} (${str3(project["id"])})
1115
+ ` };
1116
+ }
1117
+ async function projectUpdate(ctx, id, opts) {
1118
+ requireSafeId("project", id);
1119
+ const patch = projectBody(opts);
1120
+ if (Object.keys(patch).length === 0) {
1121
+ throw new UsageError("usage: harness project update <id> --name \u2026 | --state \u2026 | \u2026");
1122
+ }
1123
+ const body = await ctx.client.patch(`/api/v1/projects/${id}`, patch);
1124
+ return { json: body.data, human: `Updated project ${str3(body.data["id"])}
1125
+ ` };
1126
+ }
1127
+ async function milestoneList(ctx, project) {
1128
+ if (project === void 0) throw new UsageError("usage: harness milestone list --project <id>");
1129
+ requireSafeId("project", project);
1130
+ const body = await ctx.client.get(`/api/v1/projects/${project}/milestones`);
1131
+ const human = body.data.length === 0 ? "No milestones.\n" : table(
1132
+ ["ID", "TARGET", "NAME"],
1133
+ body.data.map((milestone) => [
1134
+ str3(milestone["id"]),
1135
+ str3(milestone["target_date"]) || "-",
1136
+ str3(milestone["name"])
1137
+ ])
1138
+ );
1139
+ return { json: body.data, human };
1140
+ }
1141
+ async function milestoneCreate(ctx, opts) {
1142
+ if (opts.project === void 0 || opts.name === void 0) {
1143
+ throw new UsageError("usage: harness milestone create --project <id> --name <name> [--description TEXT] [--target-date YYYY-MM-DD]");
1144
+ }
1145
+ requireSafeId("project", opts.project);
1146
+ let body = { name: opts.name };
1147
+ body = putOpt2(body, "description", opts.description);
1148
+ body = putDate(body, "target_date", opts.targetDate);
1149
+ const response = await ctx.client.post(`/api/v1/projects/${opts.project}/milestones`, body);
1150
+ const milestone = response.data;
1151
+ return {
1152
+ json: milestone,
1153
+ human: `Created milestone ${clean(milestone["name"])} (${str3(milestone["id"])})
1154
+ `
1155
+ };
1156
+ }
1157
+ async function docList(ctx, opts) {
1158
+ let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1159
+ if (opts.search !== void 0) params = { ...params, search: opts.search };
1160
+ const { records, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
1161
+ const human = records.length === 0 ? "No documents.\n" : table(
1162
+ ["ID", "UPDATED", "TITLE"],
1163
+ records.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
1164
+ );
1165
+ return { json: records, human, notice };
1166
+ }
1167
+ async function docShow(ctx, id) {
1168
+ requireSafeId("document", id);
1169
+ const body = await ctx.client.get(`/api/v1/documents/${id}`);
1170
+ const doc = body.data;
1171
+ const content = doc["content"];
1172
+ const human = `${str3(doc["id"])} \xB7 ${clean(doc["title"])}
1173
+ Updated: ${clean(doc["updated_at"])}
1174
+ ` + (content === null || content === void 0 || content === "" ? "" : "\n" + cleanMultiline(String(content)) + "\n");
1175
+ return { json: doc, human };
1176
+ }
1177
+ async function docCreate(ctx, opts) {
1178
+ if (opts.title === void 0) throw new UsageError("usage: harness doc create --title <title> [--content TEXT] [--project ID]");
1179
+ let body = { title: opts.title };
1180
+ body = putOpt2(body, "content", opts.content);
1181
+ body = putOpt2(body, "project_id", opts.project);
1182
+ const response = await ctx.client.post("/api/v1/documents", body);
1183
+ const doc = response.data;
1184
+ return { json: doc, human: `Created document ${clean(doc["title"])} (${str3(doc["id"])})
1185
+ ` };
1186
+ }
1187
+ async function docUpdate(ctx, id, opts) {
1188
+ requireSafeId("document", id);
1189
+ let patch = {};
1190
+ patch = putOpt2(patch, "title", opts.title);
1191
+ patch = putOpt2(patch, "content", opts.content);
1192
+ patch = putOpt2(patch, "project_id", opts.project);
1193
+ if (Object.keys(patch).length === 0) {
1194
+ throw new UsageError("usage: harness doc update <id> --title \u2026 | --content \u2026 | --project \u2026");
1195
+ }
1196
+ const response = await ctx.client.patch(`/api/v1/documents/${id}`, patch);
1197
+ return { json: response.data, human: `Updated document ${str3(response.data["id"])}
1198
+ ` };
1199
+ }
1200
+ async function linkList(ctx, issue) {
1201
+ requireIdentifiers([issue]);
1202
+ const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
1203
+ const human = body.data.length === 0 ? "No links.\n" : table(
1204
+ ["ID", "URL", "TITLE"],
1205
+ body.data.map((link) => [str3(link["id"]), str3(link["url"]), str3(link["title"]) || "-"])
1206
+ );
1207
+ return { json: body.data, human };
1208
+ }
1209
+ async function linkAdd(ctx, issue, url, title) {
1210
+ requireIdentifiers([issue]);
1211
+ let body = { url };
1212
+ body = putOpt2(body, "title", title);
1213
+ const response = await ctx.client.post(`/api/v1/issues/${issue}/links`, body);
1214
+ return { json: response.data, human: `Linked ${clean(url)} to ${issue}
1215
+ ` };
1216
+ }
1217
+ async function linkRm(ctx, issue, idOrUrl) {
1218
+ requireIdentifiers([issue]);
1219
+ const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
1220
+ const link = body.data.find((candidate) => candidate["id"] === idOrUrl || candidate["url"] === idOrUrl);
1221
+ if (link === void 0) {
1222
+ throw new UsageError(`no link on ${issue} matches ${idOrUrl} (by id or exact URL)`);
1223
+ }
1224
+ await ctx.client.delete(`/api/v1/issues/${issue}/links/${str3(link["id"])}`);
1225
+ return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
1226
+ ` };
1227
+ }
1228
+ async function labelList(ctx) {
1229
+ const body = await ctx.client.get("/api/v1/labels");
1230
+ const human = body.data.length === 0 ? "No labels.\n" : table(
1231
+ ["NAME", "COLOR", "ID"],
1232
+ body.data.map((label) => [str3(label["name"]), str3(label["color"]) || "-", str3(label["id"])])
1233
+ );
1234
+ return { json: body.data, human };
1235
+ }
1236
+ async function labelCreate(ctx, name, color) {
1237
+ if (name === void 0) throw new UsageError("usage: harness label create <name> [--color <color>]");
1238
+ let body = { name };
1239
+ body = putOpt2(body, "color", color);
1240
+ const response = await ctx.client.post("/api/v1/labels", body);
1241
+ return { json: response.data, human: `Created label ${clean(response.data["name"])}
1242
+ ` };
1243
+ }
1244
+ async function teamList(ctx) {
1245
+ const body = await ctx.client.get("/api/v1/teams");
1246
+ const human = body.data.length === 0 ? "No teams.\n" : table(
1247
+ ["KEY", "NAME"],
1248
+ body.data.map((team) => [str3(team["key"]), str3(team["name"])])
1249
+ );
1250
+ return { json: body.data, human };
1251
+ }
1252
+ async function workflowStates(ctx, team) {
1253
+ const key = requiredTeam({ client: ctx.client, config: ctx.config }, team);
1254
+ const body = await ctx.client.get(`/api/v1/teams/${key}/workflow_states`);
1255
+ const human = body.data.length === 0 ? "No workflow states.\n" : table(
1256
+ ["NAME", "TYPE"],
1257
+ body.data.map((state) => [str3(state["name"]), str3(state["type"])])
1258
+ );
1259
+ return { json: body.data, human };
1260
+ }
1261
+ async function userList(ctx) {
1262
+ const body = await ctx.client.get("/api/v1/users");
1263
+ const human = body.data.length === 0 ? "No users.\n" : table(
1264
+ ["NAME", "EMAIL", "ID"],
1265
+ body.data.map((user) => [str3(user["name"]), str3(user["email"]), str3(user["id"])])
1266
+ );
1267
+ return { json: body.data, human };
1268
+ }
1269
+ async function upload(ctx, path2) {
1270
+ return attachImage(ctx, path2);
1271
+ }
1272
+ function indent2(text) {
1273
+ return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
1274
+ }
1275
+
1276
+ // src/core/credentials.ts
1277
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1278
+ import { homedir } from "os";
1279
+ import { dirname as dirname2, isAbsolute, join as join2 } from "path";
1280
+ function defaultCredentialsPath(env = process.env) {
1281
+ const xdg = env["XDG_CONFIG_HOME"];
1282
+ const base = xdg !== void 0 && xdg !== "" && isAbsolute(xdg) ? xdg : join2(homedir(), ".config");
1283
+ return join2(base, "harness", "credentials.json");
1284
+ }
1285
+ function readCredentials(path2) {
1286
+ try {
1287
+ const parsed = JSON.parse(readFileSync3(path2, "utf8"));
1288
+ if (typeof parsed === "object" && parsed !== null) {
1289
+ const { url, token } = parsed;
1290
+ if (typeof url === "string" && typeof token === "string") return { url, token };
1291
+ }
1292
+ } catch {
1293
+ }
1294
+ return null;
1295
+ }
1296
+ var uniqueCounter = 0;
1297
+ function writeCredentials(path2, url, token) {
1298
+ mkdirSync2(dirname2(path2), { recursive: true });
1299
+ const tempPath = `${path2}.${process.pid}.${++uniqueCounter}.tmp`;
1300
+ try {
1301
+ writeFileSync2(tempPath, "", { mode: 384 });
1302
+ chmodSync2(tempPath, 384);
1303
+ writeFileSync2(tempPath, JSON.stringify({ url, token }));
1304
+ renameSync2(tempPath, path2);
1305
+ } finally {
1306
+ rmSync2(tempPath, { force: true });
1307
+ }
1308
+ }
1309
+ function deleteCredentials(path2) {
1310
+ rmSync2(path2, { force: true });
1311
+ }
1312
+
1313
+ // src/core/config.ts
1314
+ import { homedir as homedir2, tmpdir } from "os";
1315
+ import { isAbsolute as isAbsolute2, join as join3 } from "path";
1316
+ var DEFAULT_BASE_URL = "https://harness.infra.one";
1317
+ function resolveConfig(env = process.env, credentialsPath) {
1318
+ const path2 = credentialsPath ?? defaultCredentialsPath(env);
1319
+ const stored = readCredentials(path2);
1320
+ const envUrl = blankToNull(env["HARNESS_API_URL"]);
1321
+ const envToken = blankToNull(env["HARNESS_API_TOKEN"]);
1322
+ const rawUrl = envUrl ?? (envToken === null && stored !== null ? stored.url : DEFAULT_BASE_URL);
1323
+ const validated = validateBaseUrl(rawUrl);
1324
+ if (!validated.ok) return { kind: "error", message: validated.message };
1325
+ const baseUrl = rawUrl.replace(/\/+$/, "");
1326
+ let token;
1327
+ if (envToken !== null) {
1328
+ token = envToken;
1329
+ } else if (stored !== null && normalize(stored.url) !== null && normalize(stored.url) === normalize(baseUrl)) {
1330
+ token = stored.token;
1331
+ } else {
1332
+ return { kind: "needs-login", baseUrl };
1333
+ }
1334
+ return {
1335
+ kind: "ok",
1336
+ config: {
1337
+ baseUrl,
1338
+ token,
1339
+ actor: blankToNull(env["HARNESS_ACTOR"]),
1340
+ team: blankToNull(env["HARNESS_TEAM"]),
1341
+ cacheDir: resolveCacheDir(env)
1342
+ }
1343
+ };
1344
+ }
1345
+ function resolveCacheDir(env = process.env) {
1346
+ return absolute(env["HARNESS_CACHE_DIR"]) ?? join3(cacheHome(env), "harness", "attachments");
1347
+ }
1348
+ function absolute(value) {
1349
+ return value !== void 0 && value !== "" && isAbsolute2(value) ? value : null;
1350
+ }
1351
+ function cacheHome(env) {
1352
+ return absolute(env["XDG_CACHE_HOME"]) ?? join3(homedir2() || tmpdir(), ".cache");
1353
+ }
1354
+ function normalize(url) {
1355
+ let parsed;
1356
+ try {
1357
+ parsed = new URL(url);
1358
+ } catch {
1359
+ return null;
1360
+ }
1361
+ const port = parsed.port !== "" ? parsed.port : parsed.protocol === "https:" ? "443" : parsed.protocol === "http:" ? "80" : "";
1362
+ return JSON.stringify([parsed.protocol, parsed.hostname, port, parsed.pathname.replace(/\/+$/, "")]);
1363
+ }
1364
+ function validateBaseUrl(baseUrl) {
1365
+ let parsed;
1366
+ try {
1367
+ parsed = new URL(baseUrl);
1368
+ } catch {
1369
+ return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
1370
+ }
1371
+ if (parsed.username !== "" || parsed.password !== "") {
1372
+ return { ok: false, message: "HARNESS_API_URL must not embed credentials (user:pass@) \u2014 use HARNESS_API_TOKEN" };
1373
+ }
1374
+ if (parsed.protocol === "https:" && parsed.hostname !== "") return { ok: true };
1375
+ if (parsed.protocol === "http:") {
1376
+ if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) return { ok: true };
1377
+ return {
1378
+ ok: false,
1379
+ message: `refusing plain-http HARNESS_API_URL (${baseUrl}) \u2014 the API token would travel in cleartext. Use https, or http against localhost only.`
1380
+ };
1381
+ }
1382
+ return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
1383
+ }
1384
+ function loginHelp(baseUrl) {
1385
+ return `Not signed in to ${baseUrl}.
1386
+
1387
+ Interactively, in a terminal (not from inside an agent \u2014 the browser
1388
+ tab needs someone to see it):
1389
+
1390
+ harness login
1391
+
1392
+ For agents and CI, mint an API token (admin \u2192 API tokens) with the
1393
+ harness:read scope \u2014 plus harness:write only where the workflow
1394
+ creates or closes \u2014 set an expiry, and export it:
1395
+
1396
+ export HARNESS_API_TOKEN=aone_...
1397
+ `;
1398
+ }
1399
+ function blankToNull(value) {
1400
+ if (value === void 0 || value.trim() === "") return null;
1401
+ return value;
1402
+ }
1403
+
1404
+ export {
1405
+ HttpError,
1406
+ UpgradeRequiredError,
1407
+ TransportError,
1408
+ UsageError,
1409
+ LocalError,
1410
+ CommandError,
1411
+ CLI_VERSION,
1412
+ Client,
1413
+ clean,
1414
+ json,
1415
+ requireIdentifiers,
1416
+ ready,
1417
+ blocked,
1418
+ list,
1419
+ search,
1420
+ show,
1421
+ stats2 as stats,
1422
+ prime,
1423
+ version,
1424
+ create,
1425
+ update,
1426
+ close,
1427
+ archive,
1428
+ comments,
1429
+ dep,
1430
+ comment,
1431
+ attachImage,
1432
+ projectList,
1433
+ projectShow,
1434
+ projectCreate,
1435
+ projectUpdate,
1436
+ milestoneList,
1437
+ milestoneCreate,
1438
+ docList,
1439
+ docShow,
1440
+ docCreate,
1441
+ docUpdate,
1442
+ linkList,
1443
+ linkAdd,
1444
+ linkRm,
1445
+ labelList,
1446
+ labelCreate,
1447
+ teamList,
1448
+ workflowStates,
1449
+ userList,
1450
+ upload,
1451
+ defaultCredentialsPath,
1452
+ readCredentials,
1453
+ writeCredentials,
1454
+ deleteCredentials,
1455
+ DEFAULT_BASE_URL,
1456
+ resolveConfig,
1457
+ validateBaseUrl,
1458
+ loginHelp
1459
+ };