@allocator-one/harness-cli 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,204 +1,370 @@
1
1
  # @allocator-one/harness-cli
2
2
 
3
- CLI and MCP server for [Harness](https://harness.infra.one), allocator.one's
4
- issue tracker. A standalone Node client — no backend checkout, no Erlang
5
- toolchain, and no local issue state: issues live server-side, so there is
6
- nothing to commit or sync.
3
+ The `harness` CLI and MCP server for [Harness](https://harness.infra.one),
4
+ allocator.one's issue tracker. It is a standalone Node client that keeps no
5
+ local issue state. Issues live on the server, so there is nothing to commit or
6
+ sync.
7
7
 
8
8
  ## Install
9
9
 
10
- Zero-install, always current:
11
-
12
- ```sh
13
- npx -y @allocator-one/harness-cli list --team ALL
14
- ```
15
-
16
- Or globally:
17
-
18
10
  ```sh
19
11
  npm i -g @allocator-one/harness-cli
20
12
  harness ready
21
13
  ```
22
14
 
23
- Requires Node >= 20.
24
-
25
- ## Auth
26
-
27
- No setup needed: commands target `https://harness.infra.one` and start the
28
- browser login the first time. One login covers every shell, worktree, and
29
- agent slot on a machine — credentials are stored at
30
- `~/.config/harness/credentials.json` (0600) as a single `{url, token}` pair,
31
- and a token is only ever sent to the host that minted it.
32
-
33
- - `harness login` — browser flow (PKCE loopback); `--no-browser` prints the
34
- URL and prompts for a pasted token instead.
35
- - `harness logout` — revokes the stored login server-side and deletes the file.
36
- - `harness whoami` — shows the resolved host and credential source.
37
-
38
- Headless contexts (agents, CI): mint an `aone_…` API token (admin → API
39
- tokens) and set `HARNESS_API_TOKEN`. Do not set it in interactive sessions —
40
- the browser login attributes writes to the real user.
41
-
42
- ### Provider keys
43
-
44
- Admins store the org's provider API keys (Anthropic, OpenAI, Google,
45
- OpenRouter, Brave Search, Perplexity) in Harness under `Settings → Model
46
- keys`. The CLI hands them to tooling without ever printing them:
47
-
48
- - `harness keys list` — provider slugs the org has keys for and the env var
49
- each maps to (`--json` for `{providers: [{provider, env}]}`); values are
50
- never shown.
51
- - `harness keys exec [--only <slugs>] [--override] -- <command> [args…]` —
52
- runs the command with the keys in its environment and exits with the
53
- command's exit code (128 + signal number when it was signalled). `--only
54
- brave,perplexity` passes just those providers and withholds your shell's
55
- own copies of the other provider variables, so the tool sees no more than
56
- it needs; a requested provider the org has no key for keeps your shell's
57
- variable (and says so on stderr). Slugs are trimmed and case-insensitive,
58
- and an allowlist that names nothing (`--only ,`) is a usage error, not
59
- "everything". Otherwise a variable already set in your shell wins unless
60
- `--override` is given; a blank variable counts as unset. `--json` is
61
- accepted for uniformity but changes nothing: stdout belongs to the command. A SIGTERM sent to
62
- `harness` is forwarded to the command; Ctrl+C reaches the command from the
63
- terminal directly and `harness` waits to report its exit. macOS and Linux
64
- only.
65
-
66
- Slug → variable, the same table rcl uses: anthropic → `ANTHROPIC_API_KEY`,
67
- openai → `OPENAI_API_KEY`, google → `GEMINI_API_KEY`, openrouter →
68
- `OPENROUTER_API_KEY`, brave → `BRAVE_API_KEY`, perplexity →
69
- `PERPLEXITY_API_KEY`. Reading keys needs the `model-keys:read` scope: login
70
- tokens carry it, `aone_` tokens must be minted with it. There is deliberately
71
- no MCP tool for this — a tool result is model-visible text.
72
-
73
- ## Environment variables
74
-
75
- | Variable | Effect |
76
- | --- | --- |
77
- | `HARNESS_API_URL` | Target host (default `https://harness.infra.one`). https only, plain http allowed against localhost. |
78
- | `HARNESS_API_TOKEN` | Bearer token (`aone_…`); wins over the stored login. Never inherits the stored login's host. |
79
- | `HARNESS_TEAM` | Default team key for commands that take `--team`. |
80
- | `HARNESS_ACTOR` | Write attribution, sent only when explicitly set. |
81
- | `HARNESS_CACHE_DIR` | Where `show` downloads attached images (default `$XDG_CACHE_HOME/harness/attachments` or `~/.cache/harness/attachments`). Absolute paths only. |
82
-
83
- ## For agents
84
-
85
- `harness mcp` runs a stdio MCP server exposing every operation as a typed
86
- tool — issue_list, issue_show, activity_list, issue_create, issue_update, issue_claim,
87
- issue_close, issue_comment, issue_comment_edit, issue_comment_delete,
88
- relation_list/create/update/delete, relation_add/rm (blocking shorthand),
89
- link_list/add/update/rm,
90
- project_list/show/create/update, milestone_list/create,
91
- document_list/show/create/update, label_list/create, team_list,
92
- team_create, workflow_states, user_list, stats, upload. Read-only tools carry
93
- `readOnlyHint`. Results are compact JSON of the data envelope.
94
-
95
- Register it with Claude Code:
15
+ Or run it without installing:
96
16
 
97
17
  ```sh
98
- claude mcp add --scope user harness -- npx -y @allocator-one/harness-cli mcp
18
+ npx -y @allocator-one/harness-cli ready
99
19
  ```
100
20
 
101
- Example tool call: `issue_list` with `{"ready": true, "team": "ALL"}`
102
- returns the ready queue as JSON; `issue_claim` with `{"id": "IO-123"}`
103
- claims atomically (a conflict means someone else holds it).
104
-
105
- Read the same mixed timeline shown in an issue's Activity section with
106
- `harness activity IO-123`. It returns the newest 20 entries and points out
107
- when older entries exist; pass `--all` to follow the opaque cursors through
108
- the full history. `activity_list` exposes the same `id`, `limit`, and `all`
109
- inputs to MCP clients.
21
+ Requires Node >= 20.
110
22
 
111
- Typed issue relations are available without losing the concise blocking
112
- workflow. `harness dep add|rm` and the `relation_add`/`relation_rm` MCP tools
113
- remain the blocker-first shorthand; the full surface is:
23
+ ## Sign in
24
+
25
+ You don't need to configure anything. Commands target
26
+ `https://harness.infra.one`, and the first command you run in a terminal
27
+ opens a browser login and then carries on. One login covers every shell,
28
+ worktree, and agent slot on the machine.
29
+
30
+ - `harness login`: browser sign-in (PKCE, loopback callback on 127.0.0.1).
31
+ `--no-browser` prints the URL and asks you to paste the token instead.
32
+ `--url <host>` signs in to another host, and `--timeout <ms>` changes how
33
+ long it waits for the callback (default 3 minutes). Login tokens last
34
+ about 30 days.
35
+ - `harness logout`: revokes the stored login on the server and deletes it
36
+ locally.
37
+ - `harness whoami`: shows the resolved host and where the credential came
38
+ from.
39
+
40
+ The automatic login only runs when stdout is a terminal and `CI` is unset
41
+ or empty. Elsewhere (agents, CI, pipes), a command that has no credential
42
+ prints how to authenticate and exits with code `2`. For those headless
43
+ contexts, mint an `aone_…` API token (admin → API tokens) with the
44
+ `harness:read` scope, adding `harness:write` if the workflow changes
45
+ anything (claim, create, update, comment, close), and export it as
46
+ `HARNESS_API_TOKEN`.
47
+
48
+ > **Never set `HARNESS_API_TOKEN` or `HARNESS_ACTOR` in an interactive
49
+ > session.** The browser login attributes writes to the real user, and
50
+ > overriding it falsifies the audit trail. Both variables are only for
51
+ > headless CI and agent slots.
52
+
53
+ Every identifier includes its team key, as in `CLI-12` or `ALL-4901`. Older
54
+ issues keep legacy prefixes: `IO-` issues were created before team-prefixed
55
+ numbering, `ALL-` numbers up to the import ceiling came from Linear, and
56
+ `BD-` from beads.
57
+
58
+ ## Daily workflow
114
59
 
115
60
  ```sh
116
- harness relation list IO-123
117
- harness relation add IO-123 IO-456 --type related
118
- harness relation update IO-123 IO-456 --type blocks
119
- harness relation rm IO-123 IO-456
61
+ harness ready --claim --all # pick and claim the top uncontested issue
62
+ harness show CLI-12 # read it, including attached screenshots
63
+ # … do the work …
64
+ harness close CLI-12 --reason "Implemented in 1a2b3c4"
120
65
  ```
121
66
 
122
- Relation update and removal accept either the relation id from `relation list`
123
- or a unique related issue identifier. If the same pair has more than one
124
- relation, the CLI refuses to guess and requires the relation id. Link updates
125
- use the same safe lookup rule by id or exact URL; the existing `link rm`
126
- command keeps its first-match behavior for backward compatibility:
67
+ **Queue.** `harness ready` lists open, unblocked issues, highest priority
68
+ first. Blocked issues, `hold`-labeled issues, and parents with open children
69
+ are excluded. `--claim` claims the first issue in that ranking that nobody
70
+ else holds. It tries up to 10 issues and exits `1` with "No claimable
71
+ ready issues" when none can be claimed. Pass `--all` so that the ranking
72
+ covers the whole ready set rather than just the first page. `harness
73
+ blocked` lists issues that are waiting on a blocker. Both commands take
74
+ `--team`.
75
+
76
+ **Read.** `harness show <id>` prints the fields, relations, children, and
77
+ comments. It also downloads the images attached to the issue's description,
78
+ design, acceptance criteria, notes, and comments (up to 20, cached locally)
79
+ and prints their paths. Screenshots are often the reproduction, so read
80
+ them. `--no-images` skips the downloads. If a download fails, a stderr
81
+ notice says so and the command still succeeds. `harness comments <id>`
82
+ prints the full, untruncated comment thread with comment ids.
83
+ `harness activity <id>` prints the issue's activity timeline: comments,
84
+ creation, and state and assignee changes. It shows the newest 20 entries
85
+ (`--limit` accepts 1–100), and `--all` reads further back.
86
+
87
+ **Create.** Creating an issue before you write code keeps the work tracked:
127
88
 
128
89
  ```sh
129
- harness link update IO-123 https://example.com/old --url https://example.com/new --title "Runbook"
90
+ harness create --title "Fix login redirect" --type bug --priority high \
91
+ --description "…" --parent CLI-3 --assignee me --due-date 2026-11-01 \
92
+ --label regression --idempotency-key fix-login-redirect-1
130
93
  ```
131
94
 
132
- Issue creation resolves `--label` names within the target team first, then at
133
- workspace scope. Missing names are created at workspace scope before the issue
134
- is filed, and the final label ids travel in the single issue-create request, so
135
- a preparation or permission failure cannot leave an unlabeled issue behind.
136
- Creating a missing label requires Harness administrator standing.
137
-
138
- Label repair is additive and repeatable; it never implicitly replaces the
139
- whole set:
95
+ - `--type task|bug|feature|epic` is applied as a `type/<type>` label.
96
+ - `--priority urgent|high|medium|low|none` takes names only. Bare numbers
97
+ are refused because bd and Harness number priorities differently.
98
+ - `--team <key>` falls back to `HARNESS_TEAM` and then to the repo config.
99
+ With none of these set, the command is a usage error.
100
+ - `--label` (repeatable) looks for a matching label in the target team first
101
+ and then at workspace scope. Missing labels are created at workspace scope
102
+ (which requires Harness administrator standing) before the issue is filed,
103
+ and the issue is created in a single request with all its labels.
104
+ - `--idempotency-key <key>` (1–200 printable ASCII characters, no leading or
105
+ trailing space) makes a retry safe. If a create times out, run it again
106
+ with the same key to get the original issue back instead of a duplicate.
107
+ The CLI never retries a write on its own.
108
+ - `--design`, `--notes`, and `--acceptance` set the remaining text fields.
109
+
110
+ **Update.** `harness update <id>` with:
111
+
112
+ - `--claim`: atomic. It assigns you and moves the issue to the team's first
113
+ started state. A conflict means someone else holds the issue. It cannot be
114
+ combined with `--assignee`.
115
+ - `--state <name-or-type>`: moves the issue to the workflow state whose name
116
+ or type matches.
117
+ - `--title`, `--description`, `--design`, `--acceptance`, `--priority`,
118
+ `--parent`, `--assignee` (`""` unassigns), `--due-date` (`""` clears).
119
+ - `--notes` replaces the notes and `--append-notes` appends to them. Pass
120
+ only one of the two.
121
+ - `--add-label` / `--remove-label` (repeatable, name or id) add or remove
122
+ individual labels without replacing the rest. You can only add labels that
123
+ belong to the issue's team or the workspace, and a missing name is created
124
+ at workspace scope, as with `create`. If two attached labels match a name,
125
+ remove by id.
126
+
127
+ **Dependencies.** `harness dep add <issue> <blocker>`: **the second argument
128
+ blocks the first.** `harness dep rm <issue> <blocker>` removes the
129
+ dependency. For other relation types, see `relation` under
130
+ [Other commands](#other-commands).
131
+
132
+ **Close.** `harness close <id> [<id>…]` closes one or more issues as
133
+ completed. Use `--cancel` to close them as canceled, or `--duplicate-of <id>`
134
+ to close a single issue as a duplicate of the canonical one. The
135
+ duplicate-of form records a duplicate relation and cannot be combined with
136
+ `--cancel`. `--reason` records why the issue is done. The output lists the
137
+ issues each closure unblocked
138
+ (`Closed CLI-12 — unblocked: CLI-13, CLI-14`); act on them instead of
139
+ leaving them in the queue. With several ids, each one is closed on its own.
140
+ A failure prints `FAILED <id>: …`, and the command exits `1` after trying
141
+ the rest. `harness archive <id>…` archives issues, `--restore` brings them
142
+ back, and `harness list --scope archived` lists them.
143
+
144
+ **Comment.** `harness comment <id> "text" [--mention <name>]…` posts a
145
+ comment. The server resolves each mention and notifies that person.
146
+ `harness comment edit <id> <comment-id> "new text"` and
147
+ `harness comment delete <id> <comment-id>` (alias `rm`) work on your own
148
+ comments. Comment ids are UUIDs from `harness comments`.
149
+
150
+ **Find.** There are two search commands:
151
+
152
+ - `harness search <question…>` searches by meaning. It returns a ranked list
153
+ with an exact identifier first, followed by results ordered by relevance
154
+ across a lexical and a semantic signal, so it can find issues that share
155
+ none of your words. Write the question in plain language instead of
156
+ keywords. Results default to 10 and are capped at 20, with no pages
157
+ (`--page`/`--all` are accepted but ignored). Archived issues are not
158
+ searched. If the semantic side is unavailable, stderr says
159
+ `semantic search unavailable — showing lexical matches only`.
160
+ - `harness list --search <text>` is the paginated lexical filter, which
161
+ matches title or issue number.
162
+
163
+ `harness list` filters: `--status open|in_progress|closed`,
164
+ `--assignee <me|id|email|name|"">` (`""` means unassigned),
165
+ `--created-by <me|id>`, `--involved me`, `--priority <name>`,
166
+ `--label <name>` or `--type <type>` (only one of the two), `--team <key>`,
167
+ `--parent <id>` (children of an epic), and `--scope all|archived`.
168
+
169
+ **Overview.** `harness stats` prints per-team and total counts.
170
+ `harness prime` prints those counts, the top of the ready queue, and a
171
+ short workflow reminder. It is meant for the start of an agent session.
172
+
173
+ ### Text from a file
174
+
175
+ When text comes from somewhere other than your own typing, such as issue
176
+ content, a commit message, or model output, pass a file instead of a shell
177
+ argument. Quoting can be defeated: a `"` ends the string, `$(…)`
178
+ substitutes, and a heredoc ends at any flush-left delimiter line the text
179
+ happens to contain.
140
180
 
141
181
  ```sh
142
- harness update IO-123 --add-label security --add-label needs-review
143
- harness update IO-123 --remove-label security
182
+ harness create --title "…" --description-file ./body.md
183
+ harness update CLI-12 --notes-file ./notes.md --acceptance-file ./ac.md
184
+ harness comment CLI-12 --body-file ./comment.md
185
+ git log -1 --format=%B | harness close CLI-12 --reason-file -
144
186
  ```
145
187
 
146
- Both update flags accept a label name or id. Additions allow only labels from
147
- the issue's team or workspace. Removal can target an attached legacy foreign
148
- label; when attached normalized names are ambiguous, use the label id.
149
-
150
- Auth resolution is identical to the CLI. Without a credential, tools return
151
- an error telling the agent to run `harness login` in a terminal — the
152
- server never opens a browser itself.
188
+ | Command | File options |
189
+ | --- | --- |
190
+ | `create` | `--title-file`, `--description-file`, `--design-file`, `--notes-file`, `--acceptance-file` |
191
+ | `update` | the same five, plus `--append-notes-file` |
192
+ | `close` | `--reason-file` |
193
+ | `comment`, `comment edit` | `--body-file` |
194
+
195
+ Other commands (`project`, `milestone`, `doc`, `link`, and so on) take inline
196
+ values only. `-` reads from stdin, and only one option per invocation can do
197
+ that. stdin must not be a terminal. Passing both the inline and the file form
198
+ of a field is a usage error, and so is a file that produces an empty title,
199
+ reason, or comment. Conflicting options are caught before anything is read,
200
+ so a rejected command never consumes your pipe. A file must be valid UTF-8
201
+ and at most 1 MB, and one trailing newline is dropped, as with
202
+ `$(cat file)`.
203
+
204
+ ## Uploads
205
+
206
+ `harness upload <path>` uploads a file and prints a markdown reference to
207
+ paste into a description or comment. `--json` returns the URL, filename,
208
+ content type, byte size, and markdown instead.
209
+
210
+ - Images (PNG, JPG, WebP, GIF) can be up to 25 MB.
211
+ - Videos must be `.mp4` files of up to 100 MB, with H.264 8-bit 4:2:0 video,
212
+ optional AAC audio, and optional mov_text subtitles. The server validates
213
+ the media and does not transcode other formats. Harness shows linked
214
+ videos as an inline player.
215
+
216
+ A new video is private to its uploader until that user saves its link in an
217
+ issue description or comment. After that it follows the access policy of
218
+ the issue that holds it, and reusing the link elsewhere doesn't change who
219
+ owns it. `harness show` downloads images only, never videos. The older
220
+ `attach-image <path>` command accepts images only (25 MB).
221
+
222
+ ## Other commands
223
+
224
+ | Command | Notes |
225
+ | --- | --- |
226
+ | `relation list <issue>` | Every typed relation, with relation ids. |
227
+ | `relation add <issue> <other> --type <t>` | `blocks`, `blocked_by`, `related`, `duplicate`, `duplicate_of`, `duplicated_by`. |
228
+ | `relation update <issue> <relation-id-or-issue> --type <t>` | `blocks`, `blocked_by`, `related`, `duplicate_of`. |
229
+ | `relation rm <issue> <relation-id-or-issue>` | If more than one relation matches the other issue, pass the relation id. |
230
+ | `link list\|add\|update\|rm <issue> …` | URLs attached to an issue. `add <issue> <url> [--title]`; `update <issue> <link-id-or-url> --url … \| --title …` refuses an ambiguous URL; `rm <issue> <link-id-or-url>` removes the first match. |
231
+ | `project list\|show\|create\|update` | `list [--search] [--state]` (paginated). `create --name … [--description] [--summary] [--state] [--lead <user-id>] [--start-date] [--target-date]`. |
232
+ | `milestone list\|create --project <id>` | `create … --name … [--description] [--target-date]`. |
233
+ | `doc list\|show\|create\|update` | `list [--search]` (paginated). `create --title … [--content] [--project <id>]`. |
234
+ | `label list\|create\|update\|delete` | `create <name> [--color]`, `update <id> [--name] [--color]`, `delete <id> --yes` (refused while issues carry the label). |
235
+ | `team list\|create\|rename\|set-default` | `create --key <KEY> --name …` sets up the default workflow states. The key is permanent; `rename <key> --name …` changes only the display name. `set-default <key>` makes the team the org's default filing target. |
236
+ | `state list\|add\|update\|order\|delete` | A team's workflow states (`--team`, else `HARNESS_TEAM` or the repo config). `add --name … --type backlog\|unstarted\|started\|completed\|canceled [--color]`, `order <id,id,…>` (every state id, in order), `delete <id> --yes`. `states` is an alias of `state list`. |
237
+ | `member list\|add\|set-role` | The org's tracker roster. `add --email … --role admin\|staff\|collaborator`, `set-role <user-id> --role …\|none` (`none` revokes). |
238
+ | `user list` | Users, for resolving assignees. |
239
+ | `version` | CLI and server API versions. Works before you log in, and so does `harness --version`. |
240
+
241
+ `team rename`, `team set-default`, `label update`, `label delete`, and the
242
+ write commands of `state` and `member` require Harness administrator
243
+ standing, and so does `member list`.
244
+
245
+ ## Provider keys
153
246
 
154
- ### Text from a file, never from a shell argument
247
+ Admins store the org's provider API keys (Anthropic, OpenAI, Google,
248
+ OpenRouter, Brave Search, Perplexity) in Harness under `Settings → Model
249
+ keys`. `harness keys` passes them to tools without ever printing them:
155
250
 
156
- Every free-text option has a `--<field>-file` companion, and `-` means stdin:
251
+ - `harness keys list` shows the provider slugs the org has keys for and the
252
+ environment variable each one maps to (`--json` returns
253
+ `{providers: [{provider, env}]}`). It never prints the values.
254
+ - `harness keys exec [--only <slugs>] [--override] -- <command> [args…]`
255
+ runs the command with the keys in its environment and exits with the
256
+ command's exit code (128 + the signal number if the command was killed by a
257
+ signal). `--only brave,perplexity` passes just those providers and also
258
+ removes your shell's own copies of the other provider variables, so the
259
+ command sees only what it needs. If the org has no key for a provider you
260
+ requested, your shell's variable is kept and stderr says so. Slugs are
261
+ trimmed and case-insensitive. An `--only` list that names nothing
262
+ (`--only ,`) is a usage error, not a request for everything. Without
263
+ `--override`, a variable that is already set in your shell wins (a blank
264
+ variable counts as unset). `--json` is accepted but has no effect, because
265
+ stdout belongs to the command. A SIGTERM sent to `harness` is forwarded to
266
+ the command. Ctrl+C reaches the command directly from the terminal, and
267
+ `harness` waits to report its exit. Works on macOS and Linux only.
268
+
269
+ Each slug maps to the same variable as in rcl: anthropic → `ANTHROPIC_API_KEY`,
270
+ openai → `OPENAI_API_KEY`, google → `GEMINI_API_KEY`, openrouter →
271
+ `OPENROUTER_API_KEY`, brave → `BRAVE_API_KEY`, perplexity →
272
+ `PERPLEXITY_API_KEY`. Reading keys requires the `model-keys:read` scope.
273
+ Login tokens have it; `aone_` tokens need to be minted with it. There is
274
+ deliberately no MCP tool for keys, because a tool result is text the model
275
+ can read.
276
+
277
+ ## Scripting contract
278
+
279
+ - **`--json` on every command** except `mcp`. It replaces the human output
280
+ with the response data as JSON. `keys exec` accepts it but changes
281
+ nothing: its stdout is the child command's output.
282
+ - **stdout carries data only**, so it is safe to pipe. Diagnostics go to
283
+ stderr:
284
+ - `→ https://harness.infra.one` on every command that writes, naming the
285
+ host it sent the write to.
286
+ - `harness: showing 50 of 132 (page 1 of 3) — pass --all for the rest, or --page to step through.`
287
+ when a paginated read did not return everything, and
288
+ `harness: --limit N was capped at M by the server.` when the server
289
+ reduced the page size you asked for.
290
+ - The `activity`, `search`, and `show` notices described above.
291
+ - The update notice (see [Staying current](#staying-current)).
292
+ - **Pagination.** `ready`, `blocked`, `list`, `project list`, and `doc list`
293
+ return 50 records per page by default. `--limit` changes the page size,
294
+ `--page` selects a page, and `--all` follows every page (pass `--all` or
295
+ `--page`, not both). The truncation notice goes to stderr only, so a
296
+ script that reads just the `--json` output cannot tell a full page from a
297
+ complete result. Pass `--all` when you need everything.
298
+ - **Exit codes.** `keys exec` returns the command's exit code instead.
299
+
300
+ | Code | Meaning |
301
+ | --- | --- |
302
+ | `0` | Success. |
303
+ | `1` | The command failed. Causes include an API error (the message gives the HTTP status), a failure such as nothing being claimable, a partial failure across several ids, or a local refusal such as an unreadable or oversized upload. |
304
+ | `2` | Usage or configuration error, including "not signed in" outside a terminal. Nothing was sent. |
305
+ | `3` | The API was unreachable (DNS, TLS, connection refused, or timeout). Retry with backoff, and if the API stays down, say so instead of dropping tracked work. |
306
+
307
+ ## MCP server
308
+
309
+ `harness mcp` runs an MCP server over stdio. To register it with Claude Code:
157
310
 
158
311
  ```sh
159
- harness create --title "…" --description-file ./body.md
160
- harness update IO-123 --notes-file ./notes.md --acceptance-file ./ac.md
161
- harness comment IO-123 --body-file ./comment.md
162
- git log -1 --format=%B | harness close IO-123 --reason-file -
312
+ claude mcp add --scope user harness -- npx -y @allocator-one/harness-cli mcp
163
313
  ```
164
314
 
165
- `create` and `update` cover `--title`, `--description`, `--design`, `--notes`
166
- and `--acceptance` (plus `--append-notes` on `update`); `close` takes
167
- `--reason-file`, `comment` takes `--body-file`.
168
-
169
- Use these whenever the text is not something you authored inline. Prose that
170
- reaches a shell argument has to survive quoting, and quoting can be defeated —
171
- a `"` closes the string, `$(…)` substitutes, and a heredoc ends at whatever
172
- flush-left delimiter line the text happens to contain. Handing over a path
173
- removes the question, the way `git commit -F` does. This matters most for
174
- agents relaying issue text they did not write.
175
-
176
- Passing both an inline value and its `-file` for the same field is a usage
177
- error rather than a silent precedence, and only one field per invocation may
178
- read stdin. Both are checked before anything is read, so a rejected command
179
- never consumes your pipe. One trailing newline is dropped, like `$(cat file)`.
315
+ With a global install, `harness mcp` works as the command instead of the
316
+ `npx` form. Any other MCP client can use the same command:
180
317
 
181
- ## Image and video uploads
182
-
183
- `harness upload ./demo.mp4` prints a reusable markdown reference. Paste it
184
- into an issue description or comment to show an inline video player. Use
185
- `--json` for the server's URL, filename, content type, byte size, and markdown.
186
- The MCP `upload` tool accepts the same local path and returns the same metadata.
187
-
188
- Images (PNG, JPG, WebP, GIF) can be up to 25 MB. Videos must be `.mp4`, up to
189
- 100 MB, with H.264 8-bit 4:2:0 video, optional AAC audio, and optional mov_text
190
- subtitles. The server validates the media; it does not transcode other formats.
191
-
192
- A newly uploaded video is private to its uploader until that user saves its
193
- link in an issue description or comment. It then follows the owning issue's
194
- access policy. Reusing the link elsewhere does not change that ownership.
195
- `harness show` continues downloading images only, never linked videos.
196
- The older `attach-image` command retains its 25 MB limit.
318
+ ```json
319
+ { "mcpServers": { "harness": { "command": "npx", "args": ["-y", "@allocator-one/harness-cli", "mcp"] } } }
320
+ ```
197
321
 
198
- ## Repo config
322
+ The server exposes 52 tools:
199
323
 
200
- A committed `.harness-cli/config.json`, discovered git-style by walking up
201
- from the working directory, gives every clone of a repo its defaults:
324
+ | Area | Tools |
325
+ | --- | --- |
326
+ | Issues | `issue_list`, `issue_search`, `issue_show`, `activity_list`, `issue_create`, `issue_update`, `issue_claim`, `issue_close`, `issue_archive` |
327
+ | Comments | `issue_comment`, `issue_comment_edit`, `issue_comment_delete`, `comment_list` |
328
+ | Relations | `relation_add`, `relation_rm` (blocking shorthand: `blocker` blocks `issue`), `relation_list`, `relation_create`, `relation_update`, `relation_delete` |
329
+ | Links | `link_list`, `link_add`, `link_update`, `link_rm` |
330
+ | Projects and docs | `project_list`, `project_show`, `project_create`, `project_update`, `milestone_list`, `milestone_create`, `document_list`, `document_show`, `document_create`, `document_update` |
331
+ | Labels | `label_list`, `label_create`, `label_update`, `label_delete` |
332
+ | Teams and states | `team_list`, `team_create`, `team_rename`, `team_set_default`, `workflow_states`, `state_add`, `state_update`, `state_order`, `state_delete` |
333
+ | People | `member_list`, `member_add`, `member_set_role`, `user_list` |
334
+ | Other | `stats`, `upload` |
335
+
336
+ The tools cover the issue workflow and the admin surface. Some commands are
337
+ CLI-only: `keys` (deliberately), `login`/`logout`/`whoami`, `version`, and
338
+ `prime`. Read-only tools carry `readOnlyHint`. Each result is compact JSON
339
+ of the response data. Any stderr notice the CLI would print, such as
340
+ truncation, is added as a second text item. A partial failure across several
341
+ ids is returned as an error, with the details for each id in the JSON.
342
+ Some tools behave differently from their commands:
343
+
344
+ - `issue_list` with `ready: true` or `blocked: true` accepts every list
345
+ filter. The CLI's `ready` and `blocked` take only `--team`.
346
+ - `issue_show` downloads images only when you pass `images: true`.
347
+ - `state_delete` and `label_delete` don't need the CLI's `--yes`.
348
+
349
+ For example, `issue_list` with `{"ready": true, "team": "CLI"}` returns the
350
+ ready queue, and `issue_claim` with `{"id": "CLI-12"}` claims an issue
351
+ atomically. `issue_close` takes `ids`, `reason`, `cancel`, and
352
+ `duplicate_of`, and `issue_create` takes an `idempotency_key` for safe
353
+ retries.
354
+
355
+ The MCP server resolves auth exactly as the CLI does, on every call, so a
356
+ login completed while the server is running takes effect without a restart.
357
+ It never opens a browser. Without a credential, a tool returns an error that
358
+ tells the agent to run `harness login` in a terminal or set
359
+ `HARNESS_API_TOKEN`.
360
+
361
+ ## Configuration
362
+
363
+ ### Repo config
364
+
365
+ A committed `.harness-cli/config.json`, found by walking up from the working
366
+ directory the way git finds `.git`, gives every clone of a repo the same
367
+ defaults:
202
368
 
203
369
  ```json
204
370
  { "team": "CLI" }
@@ -206,47 +372,90 @@ from the working directory, gives every clone of a repo its defaults:
206
372
 
207
373
  | Field | Effect |
208
374
  | --- | --- |
209
- | `team` | Default team key. Precedence: `--team` > `HARNESS_TEAM` > repo config. |
210
- | `url` | Target host. `HARNESS_API_URL` wins. Tokens stay host-bound: a stored login only serves the host that minted it, an env `HARNESS_API_TOKEN` refuses to pair with a repo-named host (set `HARNESS_API_URL` explicitly), and before the interactive auto-login opens a browser at a host named only by the repo, the CLI asks once per host (remembered in `~/.config/harness/trusted-hosts.json`). |
211
- | `org` | Reserved for Harness org-scoping; parsed today, `url` wins meanwhile. |
212
-
213
- This is config, not state — issues stay server-side.
214
-
215
- ## Development
375
+ | `team` | Default team key. Precedence: `--team`, then `HARNESS_TEAM`, then the repo config. |
376
+ | `url` | Target host. `HARNESS_API_URL` takes precedence. Before an automatic login opens a browser at a host that only the repo names, the CLI asks for confirmation, once per host. |
377
+ | `org` | Reserved. It is parsed but has no effect yet. |
216
378
 
217
- ```sh
218
- npm ci
219
- npm run typecheck
220
- npm test
221
- npm run build # bundles to dist/cli.js (tsup)
222
- node dist/cli.js version
223
- ```
379
+ A malformed file is ignored with a warning on stderr.
224
380
 
225
- The OpenAPI spec is vendored at `spec/openapi.json` and TypeScript types are
226
- generated into `src/generated/` (committed, so npx consumers never run
227
- codegen):
381
+ ### Hosts and tokens
228
382
 
229
- ```sh
230
- npm run spec:refresh # fetch the spec from a live host (uses your login)
231
- npm run codegen # regenerate src/generated/api.ts from the vendored spec
232
- ```
383
+ The host comes from `HARNESS_API_URL`, then the repo config's `url`, then
384
+ the host of the stored login, then `https://harness.infra.one`. Tokens are
385
+ bound to the host that issued them. A stored login is only sent to the host
386
+ that issued it. `HARNESS_API_TOKEN` is only sent to `HARNESS_API_URL`, or to
387
+ the default host when that variable is unset. It is never sent to the stored
388
+ login's host, and when a repo config names another host, the command
389
+ refuses with exit code 2 instead of sending the token there. Set
390
+ `HARNESS_API_URL` explicitly if you do mean that host. Without
391
+ `HARNESS_API_TOKEN`, a repo-named host needs a login of its own, and the
392
+ automatic login asks for confirmation first, as described above. Plain
393
+ `http://` is accepted only for localhost.
233
394
 
234
- CI regenerates the types and fails on drift.
395
+ ### Environment variables
235
396
 
236
- ## Release
397
+ | Variable | Effect |
398
+ | --- | --- |
399
+ | `HARNESS_API_URL` | Target host (https, or http against localhost only). |
400
+ | `HARNESS_API_TOKEN` | An `aone_…` API token. It takes precedence over the stored login. Headless use only. |
401
+ | `HARNESS_ACTOR` | Write attribution, sent only when set. Without it the server attributes writes to the signed-in user, or for `aone_` tokens to the token's name. Headless use only. |
402
+ | `HARNESS_TEAM` | Default team key for commands that take `--team`. |
403
+ | `HARNESS_CACHE_DIR` | Where `show` stores downloaded images. Must be an absolute path. |
404
+ | `HARNESS_NO_UPDATE_CHECK` | Set to a value other than `0`, `false`, or `no` to turn off the update notice and the automatic upgrade. |
405
+ | `HARNESS_AUTO_UPGRADE=0` | Keeps the notice but never installs anything (`false` and `no` also work). |
406
+ | `CI` | Any value other than empty, `0`, `false`, or `no` turns off the update notice and automatic upgrade. Separately, any non-empty value, including `0`, stops the automatic browser login. |
407
+ | `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` | Used instead of `~/.config` and `~/.cache` when set to absolute paths. |
237
408
 
238
- First bump `package.json` and `package-lock.json` through a pull request. After
239
- that PR is squash-merged, fetch `main` and tag the merge commit — never the
240
- pre-merge branch tip. Pushing the tag starts the Release workflow, which builds,
241
- tests, and publishes to npm:
409
+ ### Files
242
410
 
243
- ```sh
244
- git fetch origin main
245
- git tag -a vX.Y.Z MERGE_COMMIT_SHA -m "Release vX.Y.Z"
246
- git push origin vX.Y.Z
247
- ```
248
-
249
- Publishing requires the `NPM_TOKEN` actions secret (an automation token for
250
- the `allocator-one` npm org). Provenance attestation is disabled: npm only
251
- accepts it from public source repositories, and this repo is private — turn
252
- `--provenance` back on in `release.yml` if the repo ever goes public.
411
+ | Path | Contents |
412
+ | --- | --- |
413
+ | `~/.config/harness/credentials.json` | The stored login: one `{url, token}` pair, mode 0600. |
414
+ | `~/.config/harness/trusted-hosts.json` | Repo-config hosts you agreed to log in to. |
415
+ | `~/.cache/harness/attachments/` | Images downloaded by `show` (or `HARNESS_CACHE_DIR`). |
416
+ | `~/.cache/harness/update-check.json` | The daily update-check stamp. |
417
+ | `~/.cache/harness/upgrade.lock` | Held while an automatic upgrade runs. |
418
+
419
+ None of these files is tracker state: issues stay on the server. The
420
+ attachment cache does hold copies of issue images, so clear it when you no
421
+ longer need them.
422
+
423
+ ## Staying current
424
+
425
+ **Notice.** Each run reads the cached stamp and prints at most one stderr
426
+ line when a newer release exists:
427
+ `harness: 1.13.0 is available (you have 1.12.1) — npm i -g @allocator-one/harness-cli@latest`.
428
+ When the stamp is more than a day old, a detached background process
429
+ refreshes it from the npm registry. A command never waits on that network
430
+ request. The notice applies only to global and project-local installs, not
431
+ to `npx` or a git checkout.
432
+
433
+ **Automatic upgrade on HTTP 426.** The CLI sends its version with every
434
+ request, and the server refuses versions below its minimum with a 426. In
435
+ that case the CLI runs
436
+ `npm install --global --ignore-scripts @allocator-one/harness-cli@latest`,
437
+ re-runs the original command, and exits with that command's exit code.
438
+ Re-running is safe only for this status. The server answers 426 before
439
+ authentication and before the request handler, so the refused request
440
+ changed nothing. The CLI never retries any other failure. The upgrade is
441
+ limited by these guards:
442
+
443
+ - It only upgrades a global install, located through `npm root -g`, and
444
+ only when that directory is writable.
445
+ - Only one upgrade runs at a time on a machine (a lock under
446
+ `~/.cache/harness`). A process that finds a newer version already
447
+ installed re-runs with it directly.
448
+ - `--ignore-scripts` means an unattended upgrade never runs package scripts.
449
+ - The re-run carries `HARNESS_UPGRADED=1`, so each command is upgraded at
450
+ most once.
451
+ - It is skipped for `npx`, project-local installs, git checkouts, inside
452
+ `harness mcp`, under `CI`, and when `HARNESS_NO_UPDATE_CHECK` or
453
+ `HARNESS_AUTO_UPGRADE=0` is set.
454
+
455
+ When the upgrade is skipped or fails, the command prints the server's
456
+ message, adds the reason if the upgrade was attempted, and exits `1`.
457
+
458
+ ---
459
+
460
+ Building, testing, and releasing this package are covered in `AGENTS.md`
461
+ in the (private) source repository.
@@ -83,8 +83,13 @@ var Client = class {
83
83
  async get(path2, params) {
84
84
  return this.json(await this.raw("GET", path2, { params }));
85
85
  }
86
- async post(path2, body = {}) {
87
- return this.json(await this.raw("POST", path2, { json: this.withActor(body) }));
86
+ async post(path2, body = {}, options = {}) {
87
+ return this.json(
88
+ await this.raw("POST", path2, {
89
+ json: this.withActor(body),
90
+ idempotencyKey: options.idempotencyKey
91
+ })
92
+ );
88
93
  }
89
94
  async patch(path2, body) {
90
95
  return this.json(await this.raw("PATCH", path2, { json: this.withActor(body) }));
@@ -138,6 +143,9 @@ var Client = class {
138
143
  authorization: `Bearer ${this.config.token}`,
139
144
  "x-harness-cli-version": CLI_VERSION
140
145
  };
146
+ if (options.idempotencyKey !== void 0) {
147
+ headers["idempotency-key"] = options.idempotencyKey;
148
+ }
141
149
  let body;
142
150
  if (options.json !== void 0) {
143
151
  headers["content-type"] = "application/json";
@@ -270,6 +278,8 @@ function activityEntry(entry) {
270
278
  return activityComment(entry);
271
279
  case "state_event":
272
280
  return activityStateEvent(entry);
281
+ case "assignee_event":
282
+ return activityAssigneeEvent(entry);
273
283
  default:
274
284
  throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
275
285
  }
@@ -311,6 +321,31 @@ function activityStateEvent(entry) {
311
321
  return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} \xB7 ${provenance}
312
322
  `;
313
323
  }
324
+ function activityAssigneeEvent(entry) {
325
+ const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
326
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
327
+ let description;
328
+ switch (entry["action"]) {
329
+ case "assigned":
330
+ description = `${actor} assigned the issue to ${assigneeName(entry["to_assignee"])}`;
331
+ break;
332
+ case "reassigned":
333
+ description = `${actor} reassigned the issue from ${assigneeName(entry["from_assignee"])} to ${assigneeName(entry["to_assignee"])}`;
334
+ break;
335
+ case "unassigned":
336
+ description = `${actor} unassigned the issue from ${assigneeName(entry["from_assignee"])}`;
337
+ break;
338
+ default:
339
+ throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
340
+ }
341
+ return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} - ${provenance}
342
+ `;
343
+ }
344
+ function assigneeName(value) {
345
+ const assignee = requiredRecord(value, "activity assignee");
346
+ if (assignee["kind"] === "unknown") return "Unknown assignee";
347
+ return activityActor(assignee);
348
+ }
314
349
  function activityActor(actor) {
315
350
  switch (actor["kind"]) {
316
351
  case "user":
@@ -637,6 +672,8 @@ var STATUS_STATE_TYPES = {
637
672
  closed: "completed,canceled"
638
673
  };
639
674
  var READY_CLAIM_ATTEMPTS = 10;
675
+ var MAX_IDEMPOTENCY_KEY_BYTES = 200;
676
+ var IDEMPOTENCY_KEY_FORMAT = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?(?![\s\S])/;
640
677
  var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
641
678
  var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
642
679
  function isUuid(value) {
@@ -783,6 +820,7 @@ async function create(ctx, opts) {
783
820
  }
784
821
  validateType(opts.type);
785
822
  const team = requiredTeam(ctx, opts.team);
823
+ validateIdempotencyKey(opts.idempotencyKey);
786
824
  const priority = optionalPriority(opts.priority);
787
825
  const dueDate = optionalDueDate(opts.dueDate);
788
826
  const labels = requestedLabelNames(wantedLabels(opts));
@@ -797,7 +835,9 @@ async function create(ctx, opts) {
797
835
  body = putDueDate(body, dueDate);
798
836
  body = putAssignee(body, opts.assignee);
799
837
  if (labelIds.length > 0) body["label_ids"] = labelIds;
800
- const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body);
838
+ const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body, {
839
+ idempotencyKey: opts.idempotencyKey
840
+ });
801
841
  const issue = created.data;
802
842
  return {
803
843
  json: issue,
@@ -805,6 +845,16 @@ async function create(ctx, opts) {
805
845
  `
806
846
  };
807
847
  }
848
+ function validateIdempotencyKey(key) {
849
+ if (key === void 0) return;
850
+ if (key.trim() === "") throw new UsageError("idempotency key must not be blank");
851
+ if (Buffer.byteLength(key, "utf8") > MAX_IDEMPOTENCY_KEY_BYTES) {
852
+ throw new UsageError(`idempotency key must not exceed ${MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes`);
853
+ }
854
+ if (!IDEMPOTENCY_KEY_FORMAT.test(key)) {
855
+ throw new UsageError("idempotency key must use printable ASCII and cannot start or end with a space");
856
+ }
857
+ }
808
858
  async function update(ctx, identifier, opts) {
809
859
  requireIdentifiers([identifier]);
810
860
  if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
@@ -869,14 +919,28 @@ ${opts.appendNotes}`;
869
919
  `;
870
920
  return { json: issue, human };
871
921
  }
872
- async function close(ctx, identifiers, opts) {
922
+ function requireCloseArgs(identifiers, opts) {
873
923
  if (identifiers.length === 0) {
874
- throw new UsageError("usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel]");
924
+ throw new UsageError(
925
+ "usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel | --duplicate-of <id>]"
926
+ );
875
927
  }
876
928
  requireIdentifiers(identifiers);
929
+ if (opts.duplicateOf === void 0) return;
930
+ requireIdentifiers([opts.duplicateOf]);
931
+ if (opts.cancel === true) {
932
+ throw new UsageError("--duplicate-of and --cancel are mutually exclusive");
933
+ }
934
+ if (identifiers.length !== 1) {
935
+ throw new UsageError("--duplicate-of accepts exactly one source issue");
936
+ }
937
+ }
938
+ async function close(ctx, identifiers, opts) {
939
+ requireCloseArgs(identifiers, opts);
877
940
  let body = {};
878
941
  body = putPresent(body, "reason", opts.reason);
879
942
  body = putPresent(body, "cancel", opts.cancel);
943
+ body = putPresent(body, "duplicate_of", opts.duplicateOf);
880
944
  const results = [];
881
945
  for (const identifier of identifiers) {
882
946
  try {
@@ -890,22 +954,28 @@ async function close(ctx, identifiers, opts) {
890
954
  }
891
955
  }
892
956
  }
893
- const human = results.map((result) => closeLine(result)).join("");
957
+ const human = results.map((result) => closeLine(result, opts.duplicateOf)).join("");
894
958
  const json2 = results.map(
895
- (result) => result.ok ? { identifier: result.identifier, closed: true, unblocked: result.response["unblocked"] ?? null } : { identifier: result.identifier, closed: false, error: errorText(result.error) }
959
+ (result) => result.ok ? {
960
+ identifier: result.identifier,
961
+ closed: true,
962
+ ...opts.duplicateOf === void 0 ? {} : { duplicate_of: opts.duplicateOf },
963
+ unblocked: result.response["unblocked"] ?? null
964
+ } : { identifier: result.identifier, closed: false, error: errorText(result.error) }
896
965
  );
897
966
  const partial = results.some((result) => !result.ok);
898
967
  return partial ? { json: json2, human, partial: true } : { json: json2, human };
899
968
  }
900
- function closeLine(result) {
969
+ function closeLine(result, duplicateOf) {
901
970
  if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
902
971
  `;
972
+ const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
903
973
  const unblocked = result.response["unblocked"];
904
974
  if (Array.isArray(unblocked) && unblocked.length > 0) {
905
- return `Closed ${result.identifier} \u2014 unblocked: ${unblocked.map(String).join(", ")}
975
+ return `${closure} \u2014 unblocked: ${unblocked.map(String).join(", ")}
906
976
  `;
907
977
  }
908
- return `Closed ${result.identifier}
978
+ return `${closure}
909
979
  `;
910
980
  }
911
981
  async function archive(ctx, identifiers, opts) {
@@ -2110,6 +2180,8 @@ export {
2110
2180
  Client,
2111
2181
  clean,
2112
2182
  json,
2183
+ MAX_IDEMPOTENCY_KEY_BYTES,
2184
+ IDEMPOTENCY_KEY_FORMAT,
2113
2185
  requireIdentifiers,
2114
2186
  ready,
2115
2187
  blocked,
@@ -2123,6 +2195,7 @@ export {
2123
2195
  version,
2124
2196
  create,
2125
2197
  update,
2198
+ requireCloseArgs,
2126
2199
  close,
2127
2200
  archive,
2128
2201
  comments,
package/dist/cli.js CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  relationList,
60
60
  relationRm,
61
61
  relationUpdate,
62
+ requireCloseArgs,
62
63
  requireCommentId,
63
64
  requireIdentifiers,
64
65
  resolveCacheHome,
@@ -82,7 +83,7 @@ import {
82
83
  version,
83
84
  workflowStates,
84
85
  writeCredentials
85
- } from "./chunk-WY6F3IJB.js";
86
+ } from "./chunk-XFMECLOU.js";
86
87
 
87
88
  // src/cli.ts
88
89
  import { createInterface as createInterface2 } from "readline";
@@ -1617,7 +1618,7 @@ program.command("show").description("full issue detail; downloads attached image
1617
1618
  });
1618
1619
  }
1619
1620
  );
1620
- program.command("activity").description("mixed issue activity: comments, creation, and state changes").argument("[id]").option(
1621
+ program.command("activity").description("mixed issue activity: comments, creation, state and assignee changes").argument("[id]").option(
1621
1622
  "--limit <n>",
1622
1623
  `entries per page (default 20, max ${ACTIVITY_MAX_LIMIT})`,
1623
1624
  activityLimit
@@ -1676,7 +1677,7 @@ function requireNonBlankTitle(text, titleFile) {
1676
1677
  program.command("create").description("create an issue atomically after resolving requested labels").option("--title <text>", "issue title (required)").option("--description <text>", "problem statement").option("--design <text>", "design notes").option("--notes <text>", "working notes").option("--acceptance <text>", "acceptance criteria").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'read the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option(
1677
1678
  "--type <type>",
1678
1679
  "task | bug | feature | epic (applied as a type/ label)"
1679
- ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option(
1680
+ ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option("--idempotency-key <key>", "reuse the same key when deliberately retrying this create").option(
1680
1681
  "--label <name>",
1681
1682
  "resolve team-first, then workspace; create missing workspace labels before the issue (repeatable)",
1682
1683
  collect,
@@ -1726,15 +1727,15 @@ program.command("update").description("update an issue; --claim takes it atomica
1726
1727
  });
1727
1728
  }
1728
1729
  );
1729
- program.command("close").description("close issues; the response lists newly unblocked issues").argument("[ids...]").option("--reason <text>", "why it is done (e.g. the commit hash)").option("--reason-file <path>", 'read the reason from a file ("-" for stdin)').option("--cancel", "close as canceled instead of completed").option("--json", "emit JSON").action(
1730
+ program.command("close").description("close issues; the response lists newly unblocked issues").argument("[ids...]").option("--reason <text>", "why it is done (e.g. the commit hash)").option("--reason-file <path>", 'read the reason from a file ("-" for stdin)').option("--cancel", "close as canceled instead of completed").option("--duplicate-of <id>", "close one issue as a duplicate of the canonical issue").option("--json", "emit JSON").action(
1730
1731
  async (ids, opts) => {
1732
+ if (ids.length === 0) {
1733
+ throw new UsageError(
1734
+ "usage: harness close <id> [<id>\u2026] [--reason <text> | --reason-file <path>] [--cancel | --duplicate-of <id>]"
1735
+ );
1736
+ }
1737
+ requireCloseArgs(ids, opts);
1731
1738
  await withConfig(opts.json === true, true, (ctx) => {
1732
- if (ids.length === 0) {
1733
- throw new UsageError(
1734
- "usage: harness close <id> [<id>\u2026] [--reason <text> | --reason-file <path>] [--cancel]"
1735
- );
1736
- }
1737
- requireIdentifiers2(ids);
1738
1739
  const text = resolveTextSources([
1739
1740
  { name: "reason", inline: opts.reason, file: opts.reasonFile }
1740
1741
  ]);
@@ -2093,7 +2094,7 @@ program.command("upload").description("upload an image (25 MB) or H.264 MP4 vide
2093
2094
  });
2094
2095
  });
2095
2096
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
2096
- const { startMcpServer } = await import("./server-5YNUXJO2.js");
2097
+ const { startMcpServer } = await import("./server-CYBKYG5E.js");
2097
2098
  await startMcpServer();
2098
2099
  });
2099
2100
  var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
@@ -4,6 +4,8 @@ import {
4
4
  CLI_VERSION,
5
5
  Client,
6
6
  HttpError,
7
+ IDEMPOTENCY_KEY_FORMAT,
8
+ MAX_IDEMPOTENCY_KEY_BYTES,
7
9
  RELATION_CREATE_TYPES,
8
10
  RELATION_UPDATE_TYPES,
9
11
  TransportError,
@@ -61,7 +63,7 @@ import {
61
63
  upload,
62
64
  userList,
63
65
  workflowStates
64
- } from "./chunk-WY6F3IJB.js";
66
+ } from "./chunk-XFMECLOU.js";
65
67
 
66
68
  // src/mcp/server.ts
67
69
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -165,7 +167,7 @@ var TOOLS = [
165
167
  },
166
168
  {
167
169
  name: "activity_list",
168
- description: "Mixed issue activity: comments, creation, and state changes. Returns the newest page unless all=true.",
170
+ description: "Mixed issue activity: comments, creation, state and assignee changes. Returns the newest page unless all=true.",
169
171
  readOnly: true,
170
172
  shape: {
171
173
  id: identifier,
@@ -190,13 +192,15 @@ var TOOLS = [
190
192
  due_date: dateField,
191
193
  parent: z.string().optional(),
192
194
  assignee: z.string().optional(),
193
- labels: z.array(z.string()).optional()
195
+ labels: z.array(z.string()).optional(),
196
+ idempotency_key: z.string().min(1).max(MAX_IDEMPOTENCY_KEY_BYTES).regex(IDEMPOTENCY_KEY_FORMAT, "must use printable ASCII without leading or trailing spaces").optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
194
197
  },
195
198
  handler: (ctx, args) => create(ctx, {
196
199
  ...args,
197
200
  acceptance: args["acceptance_criteria"],
198
201
  dueDate: args["due_date"],
199
- label: args["labels"]
202
+ label: args["labels"],
203
+ idempotencyKey: args["idempotency_key"]
200
204
  })
201
205
  },
202
206
  {
@@ -237,14 +241,19 @@ var TOOLS = [
237
241
  },
238
242
  {
239
243
  name: "issue_close",
240
- description: "Close issues. The result lists newly unblocked issues \u2014 surface them.",
244
+ description: "Close issues. Set duplicate_of for one source issue to close it as a duplicate; it cannot be combined with cancel. The result lists newly unblocked issues \u2014 surface them.",
241
245
  readOnly: false,
242
246
  shape: {
243
247
  ids: z.array(identifier).min(1),
244
248
  reason: z.string().optional().describe("why it is done, e.g. the commit hash"),
245
- cancel: z.boolean().optional().describe("close as canceled instead of completed")
249
+ cancel: z.boolean().optional().describe("close as canceled instead of completed; incompatible with duplicate_of"),
250
+ duplicate_of: identifier.optional().describe("canonical issue; requires exactly one source id and is incompatible with cancel")
246
251
  },
247
- handler: (ctx, args) => close(ctx, args["ids"], args)
252
+ handler: (ctx, args) => close(ctx, args["ids"], {
253
+ reason: args["reason"],
254
+ cancel: args["cancel"],
255
+ duplicateOf: args["duplicate_of"]
256
+ })
248
257
  },
249
258
  {
250
259
  name: "issue_comment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/spec/openapi.json CHANGED
@@ -1020,7 +1020,7 @@
1020
1020
  "post": {
1021
1021
  "callbacks": {},
1022
1022
  "deprecated": false,
1023
- "description": "Requires the `harness:write` scope.",
1023
+ "description": "Requires the `harness:write` scope. An optional Idempotency-Key makes deliberate retries safe for the authenticated credential. An identical replay returns the current issue with 200 and Idempotency-Replayed: true; reusing the key for a different request returns 409. Keys use 1-200 printable ASCII characters and cannot begin or end with a space.",
1024
1024
  "parameters": [
1025
1025
  {
1026
1026
  "description": "Team key, e.g. ALL",
@@ -1030,6 +1030,18 @@
1030
1030
  "schema": {
1031
1031
  "type": "string"
1032
1032
  }
1033
+ },
1034
+ {
1035
+ "description": "Credential-scoped printable-ASCII key for retrying this issue creation request",
1036
+ "in": "header",
1037
+ "name": "Idempotency-Key",
1038
+ "required": false,
1039
+ "schema": {
1040
+ "maxLength": 200,
1041
+ "minLength": 1,
1042
+ "pattern": "^[!-~](?:[ -~]*[!-~])?(?![\\s\\S])",
1043
+ "type": "string"
1044
+ }
1033
1045
  }
1034
1046
  ],
1035
1047
  "requestBody": {
@@ -1099,7 +1111,23 @@
1099
1111
  },
1100
1112
  "responses": {
1101
1113
  "200": {
1102
- "description": "Success"
1114
+ "description": "Idempotent replay",
1115
+ "headers": {
1116
+ "Idempotency-Replayed": {
1117
+ "description": "True when the response replays an earlier issue creation",
1118
+ "required": true,
1119
+ "schema": {
1120
+ "enum": [
1121
+ "true"
1122
+ ],
1123
+ "type": "string"
1124
+ },
1125
+ "style": "simple"
1126
+ }
1127
+ }
1128
+ },
1129
+ "201": {
1130
+ "description": "Issue created"
1103
1131
  },
1104
1132
  "400": {
1105
1133
  "description": "Bad request"
@@ -1113,6 +1141,9 @@
1113
1141
  "404": {
1114
1142
  "description": "Not found"
1115
1143
  },
1144
+ "409": {
1145
+ "description": "Idempotency key reused for a different request"
1146
+ },
1116
1147
  "422": {
1117
1148
  "description": "Validation error"
1118
1149
  }
@@ -1297,7 +1328,7 @@
1297
1328
  "get": {
1298
1329
  "callbacks": {},
1299
1330
  "deprecated": false,
1300
- "description": "Requires the `harness:read` scope. The newest mixed page of comments and issue-state events, returned oldest-first. `has_more` means older entries exist. Pass the opaque `next_cursor` back as `before`; cursors are bound to the issue and viewer and expire after 24 hours.",
1331
+ "description": "Requires the `harness:read` scope. The newest mixed page of comments, issue-state events, and assignee changes, returned oldest-first. `has_more` means older entries exist. Pass the opaque `next_cursor` back as `before`; cursors are bound to the issue and viewer and expire after 24 hours.",
1301
1332
  "parameters": [
1302
1333
  {
1303
1334
  "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
@@ -1594,6 +1625,131 @@
1594
1625
  "to_state"
1595
1626
  ],
1596
1627
  "type": "object"
1628
+ },
1629
+ {
1630
+ "additionalProperties": false,
1631
+ "properties": {
1632
+ "action": {
1633
+ "enum": [
1634
+ "assigned",
1635
+ "reassigned",
1636
+ "unassigned"
1637
+ ],
1638
+ "type": "string"
1639
+ },
1640
+ "actor": {
1641
+ "properties": {
1642
+ "kind": {
1643
+ "enum": [
1644
+ "system",
1645
+ "user",
1646
+ "deleted_user",
1647
+ "unknown"
1648
+ ],
1649
+ "type": "string"
1650
+ },
1651
+ "name": {
1652
+ "nullable": true,
1653
+ "type": "string"
1654
+ }
1655
+ },
1656
+ "required": [
1657
+ "kind",
1658
+ "name"
1659
+ ],
1660
+ "type": "object"
1661
+ },
1662
+ "from_assignee": {
1663
+ "nullable": true,
1664
+ "properties": {
1665
+ "kind": {
1666
+ "enum": [
1667
+ "system",
1668
+ "user",
1669
+ "deleted_user",
1670
+ "unknown"
1671
+ ],
1672
+ "type": "string"
1673
+ },
1674
+ "name": {
1675
+ "nullable": true,
1676
+ "type": "string"
1677
+ }
1678
+ },
1679
+ "required": [
1680
+ "kind",
1681
+ "name"
1682
+ ],
1683
+ "type": "object"
1684
+ },
1685
+ "id": {
1686
+ "type": "string"
1687
+ },
1688
+ "kind": {
1689
+ "enum": [
1690
+ "assignee_event"
1691
+ ],
1692
+ "type": "string"
1693
+ },
1694
+ "occurred_at": {
1695
+ "format": "date-time",
1696
+ "type": "string"
1697
+ },
1698
+ "provenance": {
1699
+ "properties": {
1700
+ "kind": {
1701
+ "enum": [
1702
+ "live",
1703
+ "import",
1704
+ "backfill"
1705
+ ],
1706
+ "type": "string"
1707
+ },
1708
+ "witnessed": {
1709
+ "type": "boolean"
1710
+ }
1711
+ },
1712
+ "required": [
1713
+ "kind",
1714
+ "witnessed"
1715
+ ],
1716
+ "type": "object"
1717
+ },
1718
+ "to_assignee": {
1719
+ "nullable": true,
1720
+ "properties": {
1721
+ "kind": {
1722
+ "enum": [
1723
+ "system",
1724
+ "user",
1725
+ "deleted_user",
1726
+ "unknown"
1727
+ ],
1728
+ "type": "string"
1729
+ },
1730
+ "name": {
1731
+ "nullable": true,
1732
+ "type": "string"
1733
+ }
1734
+ },
1735
+ "required": [
1736
+ "kind",
1737
+ "name"
1738
+ ],
1739
+ "type": "object"
1740
+ }
1741
+ },
1742
+ "required": [
1743
+ "id",
1744
+ "kind",
1745
+ "occurred_at",
1746
+ "action",
1747
+ "actor",
1748
+ "provenance",
1749
+ "from_assignee",
1750
+ "to_assignee"
1751
+ ],
1752
+ "type": "object"
1597
1753
  }
1598
1754
  ]
1599
1755
  },
@@ -2179,7 +2335,7 @@
2179
2335
  "post": {
2180
2336
  "callbacks": {},
2181
2337
  "deprecated": false,
2182
- "description": "Requires the `harness:write` scope. Moves the issue to the team's first completed-type state (canceled-type with `cancel`), posting `reason` as a comment first. The response's `unblocked` lists identifiers of issues that became ready because this one closed.",
2338
+ "description": "Requires the `harness:write` scope. Moves the issue to the team's first completed-type state (canceled-type with `cancel`). With `duplicate_of`, atomically records a duplicate relation, moves to the team's duplicate-purpose state, and posts `reason`; `cancel` and `duplicate_of` are mutually exclusive. The response's `unblocked` lists identifiers of issues that became ready because this one closed.",
2183
2339
  "parameters": [
2184
2340
  {
2185
2341
  "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
@@ -2201,11 +2357,18 @@
2201
2357
  "type": "string"
2202
2358
  },
2203
2359
  "cancel": {
2204
- "description": "Move to a canceled-type state instead of completed",
2360
+ "description": "Move to a canceled-type state instead of completed; cannot be used with duplicate_of",
2361
+ "nullable": true,
2205
2362
  "type": "boolean"
2206
2363
  },
2364
+ "duplicate_of": {
2365
+ "description": "Canonical issue identifier. Atomically records this issue as its duplicate and closes into the team's duplicate-purpose state; cannot be used with cancel.",
2366
+ "nullable": true,
2367
+ "type": "string"
2368
+ },
2207
2369
  "reason": {
2208
2370
  "description": "Optional close reason, posted as a comment",
2371
+ "nullable": true,
2209
2372
  "type": "string"
2210
2373
  }
2211
2374
  },