@allocator-one/harness-cli 1.15.0 → 1.15.1

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.
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ CLI_PACKAGE,
4
+ CLI_VERSION,
5
+ invokedName,
6
+ named
7
+ } from "./chunk-FQWV5MIC.js";
2
8
 
3
9
  // src/core/errors.ts
4
10
  var HttpError = class extends Error {
@@ -52,14 +58,6 @@ var CommandError = class extends Error {
52
58
  }
53
59
  };
54
60
 
55
- // src/version.ts
56
- import { readFileSync } from "fs";
57
- var pkg = JSON.parse(
58
- readFileSync(new URL("../package.json", import.meta.url), "utf8")
59
- );
60
- var CLI_VERSION = pkg.version;
61
- var CLI_PACKAGE = pkg.name;
62
-
63
61
  // src/core/expert.ts
64
62
  var EXPERT_TOKEN_PREFIX = "pxc_";
65
63
  var EXPERT_ASSIGNMENTS_PATH = "/api/v1/expert/assignments";
@@ -93,9 +91,15 @@ var DEFAULT_MESSAGES = {
93
91
  404: "Not found",
94
92
  409: "Conflict",
95
93
  422: "Validation error",
96
- 426: "CLI too old for this API \u2014 upgrade: npm i -g @allocator-one/harness-cli@latest",
94
+ 426: `CLI too old for this API \u2014 upgrade: npm i -g ${CLI_PACKAGE}@latest`,
97
95
  429: "Rate limited \u2014 retry later"
98
96
  };
97
+ var API_VERSION_HEADER = "x-harness-api-version";
98
+ var STATS_PATH = "/api/v1/stats";
99
+ var SESSION_PATH = "/api/v1/session";
100
+ function isLoginCredential(token) {
101
+ return !token.startsWith("aone_") && !isExpertToken(token);
102
+ }
99
103
  var Client = class {
100
104
  constructor(config) {
101
105
  this.config = config;
@@ -153,16 +157,32 @@ var Client = class {
153
157
  throw new TransportError(transportMessage(error), transportCode(error));
154
158
  }
155
159
  }
160
+ /**
161
+ * A GET that also hands back the server's API version header, so a probe
162
+ * that verifies a credential reports the version without a second
163
+ * request. Errors map as for `get`.
164
+ */
165
+ async getVersioned(path2) {
166
+ const response = await this.raw("GET", path2, {});
167
+ const apiVersion = response.headers.get(API_VERSION_HEADER);
168
+ return { envelope: await this.json(response), apiVersion };
169
+ }
156
170
  /**
157
171
  * The server's API version, for `version`'s server side. The header rides
158
- * on every response — the versioning plug runs before auth — so even a
159
- * 401 answers. A pxc_ credential probes the one route it reaches, so the
160
- * probe is never a refusal in the server's log.
172
+ * on every response of a route the server has — the versioning plug runs
173
+ * before auth — so even a 401 answers. Each credential probes a route it
174
+ * reaches, so the probe is never a refusal in the server's log: a pxc_
175
+ * credential its assignments, a login credential of either kind (the
176
+ * Operator-only one reaches no tracker read) GET /api/v1/session, and an
177
+ * aone_ token the stats. A server older than GET /api/v1/session answers
178
+ * that route's 404 without the header, so the stats answer then.
161
179
  */
162
180
  async serverVersion() {
163
- const path2 = isExpertToken(this.config.token) ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats";
164
- const response = await this.raw("GET", path2, {});
165
- return response.headers.get("x-harness-api-version") ?? "unknown";
181
+ const token = this.config.token;
182
+ const path2 = isExpertToken(token) ? EXPERT_ASSIGNMENTS_PATH : isLoginCredential(token) ? SESSION_PATH : STATS_PATH;
183
+ const version2 = (await this.raw("GET", path2, {})).headers.get(API_VERSION_HEADER);
184
+ if (version2 !== null || path2 !== SESSION_PATH) return version2 ?? "unknown";
185
+ return (await this.raw("GET", STATS_PATH, {})).headers.get(API_VERSION_HEADER) ?? "unknown";
166
186
  }
167
187
  // Only an explicitly configured actor is sent, and never over one the
168
188
  // caller already put in the body.
@@ -966,7 +986,7 @@ function path(record, ...keys) {
966
986
  }
967
987
 
968
988
  // src/commands/issues.ts
969
- import { openAsBlob, openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
989
+ import { openAsBlob, openSync, closeSync, readFileSync, statSync as statSync2 } from "fs";
970
990
  import { basename, extname } from "path";
971
991
 
972
992
  // src/core/attachments.ts
@@ -1198,7 +1218,7 @@ function requireReadyClaimArgs(opts) {
1198
1218
  async function claimNext(ctx, opts) {
1199
1219
  requireReadyClaimArgs(opts);
1200
1220
  const team = requiredTeam(ctx, opts.team);
1201
- const check = `check \`harness list --team ${team} --assignee me --status in_progress\``;
1221
+ const check = `check \`${invokedName()} list --team ${team} --assignee me --status in_progress\``;
1202
1222
  const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
1203
1223
  let body;
1204
1224
  try {
@@ -1356,14 +1376,14 @@ async function prime(ctx) {
1356
1376
  const statsBody = await ctx.client.get("/api/v1/stats");
1357
1377
  const ready2 = markAgentSteps(await mostImportantReady(ctx));
1358
1378
  const json2 = { stats: statsBody.data, ready: ready2 };
1359
- const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + `
1379
+ const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + named(`
1360
1380
  Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
1361
1381
  harness close <id> --reason "Implemented in <commit-hash>" (response
1362
1382
  lists newly unblocked issues \u2014 surface them).
1363
1383
  Create tracked work BEFORE coding: harness create --title "\u2026" --type task.
1364
1384
  Dependencies: harness dep add <issue> <blocker> (blocker blocks issue).
1365
1385
  Before reporting done: close the issues you finished.
1366
- `;
1386
+ `);
1367
1387
  return { json: json2, human };
1368
1388
  }
1369
1389
  async function mostImportantReady(ctx) {
@@ -1381,7 +1401,7 @@ async function version(config) {
1381
1401
  } else {
1382
1402
  server = await new Client(config).serverVersion().catch(() => "unreachable");
1383
1403
  }
1384
- return { json: { cli: CLI_VERSION, server }, human: `harness ${CLI_VERSION} (server API ${server})
1404
+ return { json: { cli: CLI_VERSION, server }, human: `${invokedName()} ${CLI_VERSION} (server API ${server})
1385
1405
  ` };
1386
1406
  }
1387
1407
  async function create(ctx, opts) {
@@ -1496,8 +1516,8 @@ ${opts.appendNotes}`;
1496
1516
  var CLAIM_REFUSAL_HINTS = {
1497
1517
  // The edit holds a released step, so the plan needs releasing again before
1498
1518
  // the step is claimable.
1499
- agent_step: () => "Operator runs it for its plan; to do it yourself, set its executor to none (harness plan edit <plan-id>) and release the plan again",
1500
- waiting_on_condition: (identifier) => `harness show ${identifier} lists what it waits on; tick a gate with harness gate satisfy ${identifier} <gate>`
1519
+ agent_step: () => `Operator runs it for its plan; to do it yourself, set its executor to none (${invokedName()} plan edit <plan-id>) and release the plan again`,
1520
+ waiting_on_condition: (identifier) => `${invokedName()} show ${identifier} lists what it waits on; tick a gate with ${invokedName()} gate satisfy ${identifier} <gate>`
1501
1521
  };
1502
1522
  function explainClaimRefusal(error, identifier) {
1503
1523
  if (!(error instanceof HttpError) || error.status !== 409) return error;
@@ -1604,9 +1624,9 @@ async function archive(ctx, identifiers, opts) {
1604
1624
  }
1605
1625
  async function comments(ctx, identifier) {
1606
1626
  requireIdentifiers([identifier]);
1607
- const body = await ctx.client.get(`/api/v1/issues/${identifier}/comments`);
1608
- if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
1609
- const human = body.data.map((comment2) => {
1627
+ const records5 = await completeCommentThread(ctx, identifier);
1628
+ if (records5.length === 0) return { json: records5, human: "No comments.\n" };
1629
+ const human = records5.map((comment2) => {
1610
1630
  const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
1611
1631
  const id = clean(comment2["id"] ?? "unknown id");
1612
1632
  const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
@@ -1614,7 +1634,35 @@ async function comments(ctx, identifier) {
1614
1634
  ${text}
1615
1635
  `;
1616
1636
  }).join("\n");
1617
- return { json: body.data, human };
1637
+ return { json: records5, human };
1638
+ }
1639
+ async function completeCommentThread(ctx, identifier) {
1640
+ const pages = [];
1641
+ const seen = /* @__PURE__ */ new Set();
1642
+ let before;
1643
+ for (; ; ) {
1644
+ const page = await ctx.client.get(`/api/v1/issues/${identifier}/comments`, {
1645
+ limit: 200,
1646
+ before
1647
+ });
1648
+ if (!Array.isArray(page.data) || typeof page.meta?.has_more !== "boolean") {
1649
+ throw new CommandError("Invalid comment pagination: expected a comment array and boolean meta.has_more; completeness unknown.");
1650
+ }
1651
+ const records5 = [];
1652
+ for (const record of page.data) {
1653
+ if (record === null || typeof record !== "object" || Array.isArray(record) || typeof record.id !== "string" || record.id.trim() === "" || seen.has(record.id)) {
1654
+ throw new CommandError("Invalid comment pagination: missing or repeated comment id; completeness unknown.");
1655
+ }
1656
+ seen.add(record.id);
1657
+ records5.push(record);
1658
+ }
1659
+ pages.push(records5);
1660
+ if (!page.meta.has_more) return pages.reverse().flat();
1661
+ if (records5.length === 0) {
1662
+ throw new CommandError("Invalid comment pagination: has_more on an empty page; completeness unknown.");
1663
+ }
1664
+ before = records5[0]["id"];
1665
+ }
1618
1666
  }
1619
1667
  async function dep(ctx, verb, issue, blocker) {
1620
1668
  requireIdentifiers([issue, blocker]);
@@ -1764,7 +1812,7 @@ async function attachFile(ctx, path2) {
1764
1812
  }
1765
1813
  async function uploadAttachment(ctx, path2, maxBytes) {
1766
1814
  attachableFile(path2, maxBytes);
1767
- const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync2(path2)], basename(path2));
1815
+ const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync(path2)], basename(path2));
1768
1816
  requireAttachmentSize(path2, file.size, maxBytes);
1769
1817
  const form = new FormData();
1770
1818
  form.set("file", file);
@@ -1871,13 +1919,13 @@ async function prepareApplicableLabelIds(ctx, teamId, names) {
1871
1919
  }
1872
1920
  function applicableLabel(labels, input, teamId) {
1873
1921
  const normalized = normalizeLabelName(input);
1874
- const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
1875
- const teamMatches = named.filter((label) => label["team_id"] === teamId);
1922
+ const named2 = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
1923
+ const teamMatches = named2.filter((label) => label["team_id"] === teamId);
1876
1924
  if (teamMatches.length > 1) {
1877
1925
  throw new CommandError(`more than one team label matches "${input}"; use a label id`);
1878
1926
  }
1879
1927
  if (teamMatches.length === 1) return teamMatches[0];
1880
- const workspaceMatches = named.filter(
1928
+ const workspaceMatches = named2.filter(
1881
1929
  (label) => label["team_id"] === null || label["team_id"] === void 0
1882
1930
  );
1883
1931
  if (workspaceMatches.length > 1) {
@@ -2003,8 +2051,8 @@ function listParams(ctx, opts) {
2003
2051
  }
2004
2052
  function optionalPriority(value) {
2005
2053
  if (value === void 0) return null;
2006
- const named = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
2007
- if (named !== void 0) return named;
2054
+ const named2 = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
2055
+ if (named2 !== void 0) return named2;
2008
2056
  if (/^\d+$/.test(value)) {
2009
2057
  throw new UsageError(
2010
2058
  `Bare numeric priorities are refused: bd and harness disagree on what
@@ -2518,7 +2566,7 @@ var APPROVAL_NOTE = "Write and send capabilities, and any proposal, need a perso
2518
2566
  var REFUSALS = {
2519
2567
  operator_disabled: "nothing here works until it is switched back on",
2520
2568
  operator_not_permitted: "this account has no admin or member role in the organization the credential is bound to",
2521
- actor_required: "this credential acts as no person: sign in with `harness login`, or use an API token minted for a user",
2569
+ actor_required: `this credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token minted for a user`,
2522
2570
  run_bound_credential: "a run-bound credential can't read Operator's catalog",
2523
2571
  forbidden: "Operator needs the operator:read scope (aone_ API tokens must be minted with it) and an admin or member role"
2524
2572
  };
@@ -2638,7 +2686,7 @@ function requireCapabilityId(id) {
2638
2686
  throw new UsageError(`not a capability id: ${clean(id)} (e.g. crm.party.search)`);
2639
2687
  }
2640
2688
  if (id === "domains") {
2641
- throw new UsageError("domains is not a capability id \u2014 `harness tools domains` lists the domains");
2689
+ throw new UsageError(`domains is not a capability id \u2014 \`${invokedName()} tools domains\` lists the domains`);
2642
2690
  }
2643
2691
  }
2644
2692
  function requireSearchQuery(query) {
@@ -2995,9 +3043,9 @@ function jobPeople(job) {
2995
3043
  const participants = records3(field2(job, "people", "participants"));
2996
3044
  const owner = isRecord2(job["owner"]) ? job["owner"] : null;
2997
3045
  const names = /* @__PURE__ */ new Map();
2998
- for (const named of owner === null ? participants : [...participants, owner]) {
2999
- if (typeof named["user_id"] === "string" && typeof named["name"] === "string" && named["name"] !== "") {
3000
- if (!names.has(named["user_id"])) names.set(named["user_id"], named["name"]);
3046
+ for (const named2 of owner === null ? participants : [...participants, owner]) {
3047
+ if (typeof named2["user_id"] === "string" && typeof named2["name"] === "string" && named2["name"] !== "") {
3048
+ if (!names.has(named2["user_id"])) names.set(named2["user_id"], named2["name"]);
3001
3049
  }
3002
3050
  }
3003
3051
  const person = (userId) => typeof userId === "string" && userId !== "" ? { user_id: userId, name: names.get(userId) ?? null } : null;
@@ -3090,7 +3138,8 @@ async function jobWatch(ctx, id, opts) {
3090
3138
  }
3091
3139
  }
3092
3140
  function resumeLine(id, after) {
3093
- return `harness: stopped watching job ${id}; resume with: harness watch ${id} --after ${after}
3141
+ const name = invokedName();
3142
+ return `${name}: stopped watching job ${id}; resume with: ${name} watch ${id} --after ${after}
3094
3143
  `;
3095
3144
  }
3096
3145
  function watchStopSignal(stdout = process.stdout, signals = process) {
@@ -3179,9 +3228,9 @@ async function eventsPage(ctx, id, after) {
3179
3228
  var REFUSAL_HINTS = {
3180
3229
  operator_disabled: "nothing can be read until Operator is switched back on for this organization",
3181
3230
  operator_not_permitted: "only the organization's admins and members use Operator; guests and people outside it cannot",
3182
- actor_required: "the credential acts as no person: sign in with `harness login`, or use an API token a person created",
3183
- forbidden: "the credential lacks the operator:read scope (`harness login` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
3184
- run_bound_credential: "a run-bound credential cannot read Operator jobs; sign in with `harness login`"
3231
+ actor_required: `the credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token a person created`,
3232
+ forbidden: `the credential lacks the operator:read scope (\`${invokedName()} login\` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization`,
3233
+ run_bound_credential: `a run-bound credential cannot read Operator jobs; sign in with \`${invokedName()} login\``
3185
3234
  };
3186
3235
  function missingJob(id) {
3187
3236
  return `no Operator job ${id} you can see: it does not exist, is in another organization, or is not shared with you (or this Harness server has no Operator API yet)`;
@@ -3567,10 +3616,10 @@ async function sendOnce(ctx, path2, body, write2) {
3567
3616
  throw error;
3568
3617
  }
3569
3618
  }
3570
- function explainRefusal(error, notFound2, hints) {
3619
+ function explainRefusal(error, notFound2, hints2) {
3571
3620
  if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
3572
3621
  const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
3573
- const own = Object.hasOwn(hints, code) ? hints[code] : void 0;
3622
+ const own = Object.hasOwn(hints2, code) ? hints2[code] : void 0;
3574
3623
  let hint;
3575
3624
  if (error.status >= 500) hint = own;
3576
3625
  else if (error.status === 404) hint = notFound2;
@@ -3578,10 +3627,132 @@ function explainRefusal(error, notFound2, hints) {
3578
3627
  else if (error.status === 403 && Object.hasOwn(ACCESS_HINTS, code)) hint = ACCESS_HINTS[code];
3579
3628
  else if (error.status === 400 && code === "bad_request") hint = BAD_REQUEST_HINT;
3580
3629
  if (hint === void 0) return error;
3581
- const message = error.status >= 500 && typeof error.body["message"] === "string" ? error.body["message"] : error.message;
3630
+ const message = error.status < 500 ? error.message : typeof error.body["message"] === "string" ? error.body["message"] : own !== void 0 ? `The server answered ${error.status}` : error.message;
3582
3631
  return new HttpError(error.status, `${message} \u2014 ${hint}`, error.body, error.retryAfter);
3583
3632
  }
3584
3633
 
3634
+ // src/commands/operator-run.ts
3635
+ var OUTCOME_MAX_CHARACTERS = 1e4;
3636
+ var BUDGET_MAX_DOLLARS = 1e6;
3637
+ var SKILL = /^\/?[a-z0-9]+(-[a-z0-9]+)*$/;
3638
+ var SKILL_MAX_CHARACTERS = 65;
3639
+ var BUDGET = /^\d+(\.\d{1,2})?$/;
3640
+ function prepareRun(outcome, opts = {}, surface = CLI_SURFACE) {
3641
+ const trimmed = outcome.trim();
3642
+ if (trimmed === "") throw new UsageError(`the outcome is empty \u2014 say what the job should achieve${surface.outcomeHint}`);
3643
+ const length = graphemeLength(trimmed);
3644
+ if (length > OUTCOME_MAX_CHARACTERS) {
3645
+ throw new UsageError(
3646
+ `the outcome is ${length} characters once trimmed; a job takes at most ${OUTCOME_MAX_CHARACTERS}`
3647
+ );
3648
+ }
3649
+ const body = { outcome: trimmed };
3650
+ if (opts.budget !== void 0) {
3651
+ const budget = opts.budget.trim();
3652
+ const amount = BUDGET.test(budget) ? Number(budget) : NaN;
3653
+ if (!(amount > 0) || amount > BUDGET_MAX_DOLLARS) {
3654
+ throw new UsageError(
3655
+ `${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`
3656
+ );
3657
+ }
3658
+ body["budget"] = amount;
3659
+ }
3660
+ if (opts.skill !== void 0) {
3661
+ const skill = opts.skill.trim();
3662
+ if (skill.length > SKILL_MAX_CHARACTERS || !SKILL.test(skill)) {
3663
+ throw new UsageError(
3664
+ `${surface.skill} takes a skill's invocation name, like /summarize-lps (${surface.skills} lists them)`
3665
+ );
3666
+ }
3667
+ body["skill"] = skill;
3668
+ }
3669
+ return { body };
3670
+ }
3671
+ var CLI_SURFACE = {
3672
+ budget: "--budget",
3673
+ skill: "--skill",
3674
+ outcomeHint: ': run "<outcome>"',
3675
+ skills: "`skills`",
3676
+ look: "`jobs --mine` for the new job",
3677
+ again: "run again"
3678
+ };
3679
+ var MCP_SURFACE = {
3680
+ budget: "budget",
3681
+ skill: "skill",
3682
+ outcomeHint: "",
3683
+ skills: "`skills_list`",
3684
+ look: "`jobs_list` with mine for the new job",
3685
+ again: "call again"
3686
+ };
3687
+ async function startRun(ctx, request, surface = CLI_SURFACE) {
3688
+ const write2 = { mayHave: "a job may have been started", check: surface.look };
3689
+ let data;
3690
+ try {
3691
+ data = (await sendOnce(ctx, "/api/v1/operator/jobs", request.body, write2)).data;
3692
+ } catch (error) {
3693
+ throw explainRefusal(error, "this Harness server has no Operator API for starting jobs yet", hints(error, surface));
3694
+ }
3695
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
3696
+ throw outcomeUnknown("the answer named no job", write2);
3697
+ }
3698
+ const record = data;
3699
+ const jobId = record["job_id"];
3700
+ if (typeof jobId !== "string" || jobId === "") throw outcomeUnknown("the answer named no job", write2);
3701
+ const text = (value) => typeof value === "string" ? value : "";
3702
+ return {
3703
+ job_id: jobId,
3704
+ state: text(record["state"]),
3705
+ workbench_url: text(record["workbench_url"]),
3706
+ approval_url: text(record["approval_url"]) === "" ? null : text(record["approval_url"])
3707
+ };
3708
+ }
3709
+ function hints(error, surface) {
3710
+ const retryAfter = error instanceof HttpError ? error.retryAfter : null;
3711
+ return {
3712
+ budget_not_permitted: `nothing was started: only the organization's admins, who manage its Operator budget, can pass ${surface.budget} \u2014 ${surface.again} without it`,
3713
+ budget_exceeded: `nothing was started: the organization's monthly Operator budget has less than ${surface.budget} left this month \u2014 pass a smaller budget, or ask an admin`,
3714
+ concurrent_change: `nothing was started: Operator or your access changed while the job started \u2014 check that you can still use Operator, then ${surface.again}`,
3715
+ invalid_outcome: `nothing was started: pass the outcome as text of 1 to ${OUTCOME_MAX_CHARACTERS} characters once trimmed, without control characters`,
3716
+ invalid_budget: `nothing was started: ${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`,
3717
+ unknown_skill: `nothing was started: no Operator skill of that name that you may run in the organization (${surface.skills} lists them)`,
3718
+ rate_limited: "nothing was started: you can start 30 jobs an hour this way" + (retryAfter === null ? "" : `; try again in ${clean(retryAfter)} s`),
3719
+ service_unavailable: "nothing was started; try again later"
3720
+ };
3721
+ }
3722
+ function startedText(job) {
3723
+ return `Started Operator job ${clean(job.job_id)}.
3724
+ ` + labelled([
3725
+ ["State", clean(job.state) || null],
3726
+ ["Workbench", clean(job.workbench_url) || null],
3727
+ ["Approve at", job.approval_url === null ? null : clean(job.approval_url)]
3728
+ ]) + (job.approval_url === null ? "" : "\nApproving happens in the app; the CLI cannot approve.\n");
3729
+ }
3730
+ async function run(ctx, request, opts = {}) {
3731
+ const job = await startRun(ctx, request);
3732
+ if (opts.watch !== true) return { json: job, human: startedText(job) };
3733
+ const json2 = opts.json === true;
3734
+ if (!json2) opts.onLine?.(startedText(job) + "\n");
3735
+ let watched;
3736
+ try {
3737
+ watched = await jobWatch(ctx, job.job_id, {
3738
+ throughWaits: true,
3739
+ collectEvents: json2,
3740
+ onLine: json2 ? void 0 : opts.onLine,
3741
+ signal: opts.signal,
3742
+ sleep: opts.sleep
3743
+ });
3744
+ } catch (error) {
3745
+ const follow = ` \u2014 the job was started: follow it with \`watch ${job.job_id}\``;
3746
+ if (error instanceof HttpError) {
3747
+ throw new HttpError(error.status, `${error.message}${follow}`, error.body, error.retryAfter);
3748
+ }
3749
+ if (error instanceof TransportError) throw new TransportError(`${error.message}${follow}`, error.code);
3750
+ if (error instanceof CommandError) throw new CommandError(`${error.message}${follow}`);
3751
+ throw error;
3752
+ }
3753
+ return { ...watched, json: { ...job, watch: watched.json } };
3754
+ }
3755
+
3585
3756
  // src/commands/operator-continue.ts
3586
3757
  async function jobContinue(ctx, id) {
3587
3758
  requireJobId(id);
@@ -3614,7 +3785,7 @@ function refusals(id) {
3614
3785
  }
3615
3786
 
3616
3787
  // src/core/textinput.ts
3617
- import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
3788
+ import { readFileSync as readFileSync2, statSync as statSync3 } from "fs";
3618
3789
  import { isatty } from "tty";
3619
3790
  var STDIN_PATH = "-";
3620
3791
  var MAX_TEXT_BYTES = 1e6;
@@ -3672,7 +3843,7 @@ function readTextFile(path2, option, limit = TEXT_LIMIT) {
3672
3843
  if (path2 === STDIN_PATH) {
3673
3844
  let piped;
3674
3845
  try {
3675
- piped = readFileSync3(0);
3846
+ piped = readFileSync2(0);
3676
3847
  } catch (error) {
3677
3848
  throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
3678
3849
  }
@@ -3684,7 +3855,7 @@ function readTextFile(path2, option, limit = TEXT_LIMIT) {
3684
3855
  const flag = `${option} ${path2}`;
3685
3856
  let bytes;
3686
3857
  try {
3687
- bytes = readFileSync3(path2);
3858
+ bytes = readFileSync2(path2);
3688
3859
  } catch (error) {
3689
3860
  throw new UsageError(`${flag}: ${describe(error)}`);
3690
3861
  }
@@ -3882,14 +4053,14 @@ function requireProcedureName(name) {
3882
4053
  }
3883
4054
  async function procedureSave(ctx, jobId, name) {
3884
4055
  requireJobId(jobId);
3885
- const named = requireProcedureName(name);
4056
+ const named2 = requireProcedureName(name);
3886
4057
  const write2 = {
3887
4058
  mayHave: "a procedure may have been saved",
3888
4059
  check: `the job's procedures with \`job ${jobId}\``
3889
4060
  };
3890
4061
  let data;
3891
4062
  try {
3892
- data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named }, write2)).data;
4063
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named2 }, write2)).data;
3893
4064
  } catch (error) {
3894
4065
  throw explainRefusal(error, missingJob(jobId), {
3895
4066
  owner_required: "only the job's owner can save it as a procedure; ask them to save it",
@@ -4160,7 +4331,7 @@ async function planCreate(ctx, plan, opts) {
4160
4331
  human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
4161
4332
 
4162
4333
  ` + planDetail(created) + `
4163
- Nothing runs until it is released: harness plan release ${clean(id)}
4334
+ Nothing runs until it is released: ${invokedName()} plan release ${clean(id)}
4164
4335
  `
4165
4336
  };
4166
4337
  }
@@ -4179,7 +4350,7 @@ async function planEdit(ctx, id, edits) {
4179
4350
  human: `Updated plan ${clean(id)}
4180
4351
 
4181
4352
  ` + planDetail(plan) + (held ? `
4182
- Release it again to run the held steps: harness plan release ${clean(id)}
4353
+ Release it again to run the held steps: ${invokedName()} plan release ${clean(id)}
4183
4354
  ` : "")
4184
4355
  };
4185
4356
  }
@@ -4295,8 +4466,8 @@ async function explainNotGateOwner(ctx, error, gate, issue, satisfied) {
4295
4466
  }
4296
4467
  const owner = isRecord5(currentOwner) ? ownerText(currentOwner) : "";
4297
4468
  const subject = `its owner${owner === "" ? "" : `, ${owner},`}`;
4298
- const named = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
4299
- const message = satisfied ? `Cannot satisfy ${named}: only ${subject} can tick it` : `Cannot un-satisfy ${named}: only ${subject} or Harness staff can untick it`;
4469
+ const named2 = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
4470
+ const message = satisfied ? `Cannot satisfy ${named2}: only ${subject} can tick it` : `Cannot un-satisfy ${named2}: only ${subject} or Harness staff can untick it`;
4300
4471
  return new HttpError(error.status, message, error.body, error.retryAfter);
4301
4472
  }
4302
4473
  var gateLabel = (condition) => str7(condition["label"]).trim();
@@ -4373,7 +4544,7 @@ function isRecord6(value) {
4373
4544
  }
4374
4545
 
4375
4546
  // src/core/credentials.ts
4376
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4547
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4377
4548
  import { homedir } from "os";
4378
4549
  import { dirname as dirname2, isAbsolute, join as join2 } from "path";
4379
4550
  function defaultCredentialsPath(env = process.env) {
@@ -4383,7 +4554,7 @@ function defaultCredentialsPath(env = process.env) {
4383
4554
  }
4384
4555
  function readCredentials(path2) {
4385
4556
  try {
4386
- const parsed = JSON.parse(readFileSync4(path2, "utf8"));
4557
+ const parsed = JSON.parse(readFileSync3(path2, "utf8"));
4387
4558
  if (typeof parsed === "object" && parsed !== null) {
4388
4559
  const { url, token } = parsed;
4389
4560
  if (typeof url === "string" && typeof token === "string") return { url, token };
@@ -4410,7 +4581,7 @@ function deleteCredentials(path2) {
4410
4581
  }
4411
4582
 
4412
4583
  // src/core/repo-config.ts
4413
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
4584
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
4414
4585
  import { homedir as homedir2 } from "os";
4415
4586
  import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
4416
4587
  var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
@@ -4429,7 +4600,7 @@ function findRepoConfig(startDir = process.cwd()) {
4429
4600
  function readRepoConfig(path2) {
4430
4601
  let parsed;
4431
4602
  try {
4432
- parsed = JSON.parse(readFileSync5(path2, "utf8"));
4603
+ parsed = JSON.parse(readFileSync4(path2, "utf8"));
4433
4604
  } catch (error) {
4434
4605
  const detail = error instanceof Error ? error.message : String(error);
4435
4606
  return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
@@ -4458,7 +4629,7 @@ function trustedHostsPath(env = process.env) {
4458
4629
  }
4459
4630
  function readTrustedHosts(env) {
4460
4631
  try {
4461
- const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
4632
+ const parsed = JSON.parse(readFileSync4(trustedHostsPath(env), "utf8"));
4462
4633
  if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
4463
4634
  return parsed["hosts"].filter(
4464
4635
  (host) => typeof host === "string"
@@ -4590,13 +4761,13 @@ function validateBaseUrl(baseUrl) {
4590
4761
  }
4591
4762
  return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
4592
4763
  }
4593
- function loginHelp(baseUrl) {
4764
+ function loginHelp(baseUrl, name = invokedName()) {
4594
4765
  return `Not signed in to ${baseUrl}.
4595
4766
 
4596
4767
  Interactively, in a terminal (not from inside an agent \u2014 the browser
4597
4768
  tab needs someone to see it):
4598
4769
 
4599
- harness login
4770
+ ${name} login
4600
4771
 
4601
4772
  For agents and CI, mint an API token (admin \u2192 API tokens) with the
4602
4773
  harness:read scope \u2014 plus harness:write only where the workflow
@@ -4605,7 +4776,7 @@ creates or closes \u2014 set an expiry, and export it:
4605
4776
  export HARNESS_API_TOKEN=aone_...
4606
4777
 
4607
4778
  An outside expert uses the pxc_ credential minted in their expert view:
4608
- export it the same way, or paste it into \`harness login --no-browser\`.
4779
+ export it the same way, or paste it into \`${name} login --no-browser\`.
4609
4780
  `;
4610
4781
  }
4611
4782
  function blankToNull(value) {
@@ -4620,12 +4791,12 @@ export {
4620
4791
  UsageError,
4621
4792
  LocalError,
4622
4793
  CommandError,
4623
- CLI_VERSION,
4624
- CLI_PACKAGE,
4625
4794
  EXPERT_ASSIGNMENTS_PATH,
4626
4795
  isExpertToken,
4627
4796
  EXPERT_LAPSED,
4628
4797
  explainExpertRefusal,
4798
+ SESSION_PATH,
4799
+ isLoginCredential,
4629
4800
  Client,
4630
4801
  clean,
4631
4802
  json,
@@ -4719,6 +4890,12 @@ export {
4719
4890
  jobShow,
4720
4891
  jobWatch,
4721
4892
  watchStopSignal,
4893
+ OUTCOME_MAX_CHARACTERS,
4894
+ BUDGET_MAX_DOLLARS,
4895
+ prepareRun,
4896
+ MCP_SURFACE,
4897
+ startRun,
4898
+ run,
4722
4899
  jobContinue,
4723
4900
  resolveTextSources,
4724
4901
  STEER_MAX_CHARACTERS,