@allocator-one/harness-cli 1.12.1 → 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,206 +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. Assignment, reassignment, and unassignment entries show
110
- the actor and previous/new assignees when supplied by the server. Unavailable
111
- identities use safe labels rather than raw user ids.
21
+ Requires Node >= 20.
112
22
 
113
- Typed issue relations are available without losing the concise blocking
114
- workflow. `harness dep add|rm` and the `relation_add`/`relation_rm` MCP tools
115
- 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
116
59
 
117
60
  ```sh
118
- harness relation list IO-123
119
- harness relation add IO-123 IO-456 --type related
120
- harness relation update IO-123 IO-456 --type blocks
121
- 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"
122
65
  ```
123
66
 
124
- Relation update and removal accept either the relation id from `relation list`
125
- or a unique related issue identifier. If the same pair has more than one
126
- relation, the CLI refuses to guess and requires the relation id. Link updates
127
- use the same safe lookup rule by id or exact URL; the existing `link rm`
128
- 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:
129
88
 
130
89
  ```sh
131
- 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
132
93
  ```
133
94
 
134
- Issue creation resolves `--label` names within the target team first, then at
135
- workspace scope. Missing names are created at workspace scope before the issue
136
- is filed, and the final label ids travel in the single issue-create request, so
137
- a preparation or permission failure cannot leave an unlabeled issue behind.
138
- Creating a missing label requires Harness administrator standing.
139
-
140
- Label repair is additive and repeatable; it never implicitly replaces the
141
- 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.
142
180
 
143
181
  ```sh
144
- harness update IO-123 --add-label security --add-label needs-review
145
- 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 -
146
186
  ```
147
187
 
148
- Both update flags accept a label name or id. Additions allow only labels from
149
- the issue's team or workspace. Removal can target an attached legacy foreign
150
- label; when attached normalized names are ambiguous, use the label id.
151
-
152
- Auth resolution is identical to the CLI. Without a credential, tools return
153
- an error telling the agent to run `harness login` in a terminal — the
154
- 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
155
246
 
156
- ### 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:
157
250
 
158
- 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:
159
310
 
160
311
  ```sh
161
- harness create --title "…" --description-file ./body.md
162
- harness update IO-123 --notes-file ./notes.md --acceptance-file ./ac.md
163
- harness comment IO-123 --body-file ./comment.md
164
- 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
165
313
  ```
166
314
 
167
- `create` and `update` cover `--title`, `--description`, `--design`, `--notes`
168
- and `--acceptance` (plus `--append-notes` on `update`); `close` takes
169
- `--reason-file`, `comment` takes `--body-file`.
170
-
171
- Use these whenever the text is not something you authored inline. Prose that
172
- reaches a shell argument has to survive quoting, and quoting can be defeated —
173
- a `"` closes the string, `$(…)` substitutes, and a heredoc ends at whatever
174
- flush-left delimiter line the text happens to contain. Handing over a path
175
- removes the question, the way `git commit -F` does. This matters most for
176
- agents relaying issue text they did not write.
177
-
178
- Passing both an inline value and its `-file` for the same field is a usage
179
- error rather than a silent precedence, and only one field per invocation may
180
- read stdin. Both are checked before anything is read, so a rejected command
181
- 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:
182
317
 
183
- ## Image and video uploads
184
-
185
- `harness upload ./demo.mp4` prints a reusable markdown reference. Paste it
186
- into an issue description or comment to show an inline video player. Use
187
- `--json` for the server's URL, filename, content type, byte size, and markdown.
188
- The MCP `upload` tool accepts the same local path and returns the same metadata.
189
-
190
- Images (PNG, JPG, WebP, GIF) can be up to 25 MB. Videos must be `.mp4`, up to
191
- 100 MB, with H.264 8-bit 4:2:0 video, optional AAC audio, and optional mov_text
192
- subtitles. The server validates the media; it does not transcode other formats.
193
-
194
- A newly uploaded video is private to its uploader until that user saves its
195
- link in an issue description or comment. It then follows the owning issue's
196
- access policy. Reusing the link elsewhere does not change that ownership.
197
- `harness show` continues downloading images only, never linked videos.
198
- 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
+ ```
199
321
 
200
- ## Repo config
322
+ The server exposes 52 tools:
201
323
 
202
- A committed `.harness-cli/config.json`, discovered git-style by walking up
203
- 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:
204
368
 
205
369
  ```json
206
370
  { "team": "CLI" }
@@ -208,47 +372,90 @@ from the working directory, gives every clone of a repo its defaults:
208
372
 
209
373
  | Field | Effect |
210
374
  | --- | --- |
211
- | `team` | Default team key. Precedence: `--team` > `HARNESS_TEAM` > repo config. |
212
- | `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`). |
213
- | `org` | Reserved for Harness org-scoping; parsed today, `url` wins meanwhile. |
214
-
215
- This is config, not state — issues stay server-side.
216
-
217
- ## 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. |
218
378
 
219
- ```sh
220
- npm ci
221
- npm run typecheck
222
- npm test
223
- npm run build # bundles to dist/cli.js (tsup)
224
- node dist/cli.js version
225
- ```
379
+ A malformed file is ignored with a warning on stderr.
226
380
 
227
- The OpenAPI spec is vendored at `spec/openapi.json` and TypeScript types are
228
- generated into `src/generated/` (committed, so npx consumers never run
229
- codegen):
381
+ ### Hosts and tokens
230
382
 
231
- ```sh
232
- npm run spec:refresh # fetch the spec from a live host (uses your login)
233
- npm run codegen # regenerate src/generated/api.ts from the vendored spec
234
- ```
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.
235
394
 
236
- CI regenerates the types and fails on drift.
395
+ ### Environment variables
237
396
 
238
- ## 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. |
239
408
 
240
- First bump `package.json` and `package-lock.json` through a pull request. After
241
- that PR is squash-merged, fetch `main` and tag the merge commit — never the
242
- pre-merge branch tip. Pushing the tag starts the Release workflow, which builds,
243
- tests, and publishes to npm:
409
+ ### Files
244
410
 
245
- ```sh
246
- git fetch origin main
247
- git tag -a vX.Y.Z MERGE_COMMIT_SHA -m "Release vX.Y.Z"
248
- git push origin vX.Y.Z
249
- ```
250
-
251
- Publishing requires the `NPM_TOKEN` actions secret (an automation token for
252
- the `allocator-one` npm org). Provenance attestation is disabled: npm only
253
- accepts it from public source repositories, and this repo is private — turn
254
- `--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";
@@ -664,6 +672,8 @@ var STATUS_STATE_TYPES = {
664
672
  closed: "completed,canceled"
665
673
  };
666
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])/;
667
677
  var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
668
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;
669
679
  function isUuid(value) {
@@ -810,6 +820,7 @@ async function create(ctx, opts) {
810
820
  }
811
821
  validateType(opts.type);
812
822
  const team = requiredTeam(ctx, opts.team);
823
+ validateIdempotencyKey(opts.idempotencyKey);
813
824
  const priority = optionalPriority(opts.priority);
814
825
  const dueDate = optionalDueDate(opts.dueDate);
815
826
  const labels = requestedLabelNames(wantedLabels(opts));
@@ -824,7 +835,9 @@ async function create(ctx, opts) {
824
835
  body = putDueDate(body, dueDate);
825
836
  body = putAssignee(body, opts.assignee);
826
837
  if (labelIds.length > 0) body["label_ids"] = labelIds;
827
- 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
+ });
828
841
  const issue = created.data;
829
842
  return {
830
843
  json: issue,
@@ -832,6 +845,16 @@ async function create(ctx, opts) {
832
845
  `
833
846
  };
834
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
+ }
835
858
  async function update(ctx, identifier, opts) {
836
859
  requireIdentifiers([identifier]);
837
860
  if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
@@ -896,14 +919,28 @@ ${opts.appendNotes}`;
896
919
  `;
897
920
  return { json: issue, human };
898
921
  }
899
- async function close(ctx, identifiers, opts) {
922
+ function requireCloseArgs(identifiers, opts) {
900
923
  if (identifiers.length === 0) {
901
- 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
+ );
902
927
  }
903
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);
904
940
  let body = {};
905
941
  body = putPresent(body, "reason", opts.reason);
906
942
  body = putPresent(body, "cancel", opts.cancel);
943
+ body = putPresent(body, "duplicate_of", opts.duplicateOf);
907
944
  const results = [];
908
945
  for (const identifier of identifiers) {
909
946
  try {
@@ -917,22 +954,28 @@ async function close(ctx, identifiers, opts) {
917
954
  }
918
955
  }
919
956
  }
920
- const human = results.map((result) => closeLine(result)).join("");
957
+ const human = results.map((result) => closeLine(result, opts.duplicateOf)).join("");
921
958
  const json2 = results.map(
922
- (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) }
923
965
  );
924
966
  const partial = results.some((result) => !result.ok);
925
967
  return partial ? { json: json2, human, partial: true } : { json: json2, human };
926
968
  }
927
- function closeLine(result) {
969
+ function closeLine(result, duplicateOf) {
928
970
  if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
929
971
  `;
972
+ const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
930
973
  const unblocked = result.response["unblocked"];
931
974
  if (Array.isArray(unblocked) && unblocked.length > 0) {
932
- return `Closed ${result.identifier} \u2014 unblocked: ${unblocked.map(String).join(", ")}
975
+ return `${closure} \u2014 unblocked: ${unblocked.map(String).join(", ")}
933
976
  `;
934
977
  }
935
- return `Closed ${result.identifier}
978
+ return `${closure}
936
979
  `;
937
980
  }
938
981
  async function archive(ctx, identifiers, opts) {
@@ -2137,6 +2180,8 @@ export {
2137
2180
  Client,
2138
2181
  clean,
2139
2182
  json,
2183
+ MAX_IDEMPOTENCY_KEY_BYTES,
2184
+ IDEMPOTENCY_KEY_FORMAT,
2140
2185
  requireIdentifiers,
2141
2186
  ready,
2142
2187
  blocked,
@@ -2150,6 +2195,7 @@ export {
2150
2195
  version,
2151
2196
  create,
2152
2197
  update,
2198
+ requireCloseArgs,
2153
2199
  close,
2154
2200
  archive,
2155
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-VOH6BBJD.js";
86
+ } from "./chunk-XFMECLOU.js";
86
87
 
87
88
  // src/cli.ts
88
89
  import { createInterface as createInterface2 } from "readline";
@@ -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-FNNMISOZ.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-VOH6BBJD.js";
66
+ } from "./chunk-XFMECLOU.js";
65
67
 
66
68
  // src/mcp/server.ts
67
69
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -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.1",
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
  }
@@ -2304,7 +2335,7 @@
2304
2335
  "post": {
2305
2336
  "callbacks": {},
2306
2337
  "deprecated": false,
2307
- "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.",
2308
2339
  "parameters": [
2309
2340
  {
2310
2341
  "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
@@ -2326,11 +2357,18 @@
2326
2357
  "type": "string"
2327
2358
  },
2328
2359
  "cancel": {
2329
- "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,
2330
2362
  "type": "boolean"
2331
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
+ },
2332
2369
  "reason": {
2333
2370
  "description": "Optional close reason, posted as a comment",
2371
+ "nullable": true,
2334
2372
  "type": "string"
2335
2373
  }
2336
2374
  },