pingcode-cli-unofficial 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,943 @@
1
+ # pingcode-cli
2
+
3
+ [![CI](https://github.com/anine09/pingcode-cli-unofficial/actions/workflows/ci.yml/badge.svg)](https://github.com/anine09/pingcode-cli-unofficial/actions/workflows/ci.yml)
4
+
5
+ A command-line client for the [PingCode Open API](https://open.pingcode.com/), plus a single
6
+ `pingcode` skill that teaches AI agents how to drive it.
7
+
8
+ Scope, in two tiers: **every one of the 459 documented `/v1` endpoints is reachable** through the
9
+ generic executor `pingcode api`, and **158 of them also have named commands** with flag validation,
10
+ name→id resolution and tables — projects and work items, ship products/ideas/tickets, testhub
11
+ libraries/cases/plans/runs, the SCM and CI/CD write-back surface, and the cross-object
12
+ relations/comments/attachments/activities that link them. Wiki has **no** named command (0 of 19
13
+ endpoints), and neither do the org chart beyond `settings users`, worklogs, permission views or
14
+ Nexus; they are reachable through `pingcode api` only. Flow and Insight have no REST API at all.
15
+ See [Coverage](#coverage-reach-vs-ergonomics) for the per-module split.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ Requires **Node.js >= 20**. The package is not published to npm; release zips
22
+ ship their own `node_modules/` (`commander`, `picocolors`), so an installed
23
+ binary needs only Node — no `npm` on the client. Build from a checkout for development.
24
+
25
+ ### One-click (recommended)
26
+
27
+ From the repo checkout, one command installs deps, builds, and links `pingcode` onto your
28
+ `PATH` — compatible with Linux, macOS, and Windows. Re-run it after `git pull` to rebuild +
29
+ relink the latest code:
30
+
31
+ ```bash
32
+ ./install.sh # Linux / macOS (or: npm run install:cli)
33
+ ```
34
+
35
+ ```powershell
36
+ .\install.ps1 # Windows PowerShell (or: npm run install:cli)
37
+ ```
38
+
39
+ The Windows variant needs PowerShell (`pwsh` or Windows PowerShell); the `install:cli` npm
40
+ script is the cross-platform fallback (`node scripts/install.mjs`).
41
+
42
+ ### Manual
43
+
44
+ ```bash
45
+ npm install
46
+ npm run build # → dist/bin/pingcode.js
47
+ npm link # then just: pingcode --help
48
+ ```
49
+
50
+ Development commands:
51
+
52
+ ```bash
53
+ npm run typecheck # tsc --noEmit
54
+ npm test # vitest run — no network, ever
55
+ npm run dev # tsup --watch
56
+ npm run skill:install # copy skills/pingcode/SKILL.md to the agent skill dirs
57
+ npm run scan:secrets # credential / tenant-identifier scan
58
+ npm run check:commits # commit-message gate
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Get credentials
64
+
65
+ The CLI authenticates as an **application**, not as a user, using the OAuth
66
+ `client_credentials` grant.
67
+
68
+ 1. In the PingCode enterprise console open **后台管理 (企业后台) → 凭据管理** ("Credential
69
+ Management") and create an application.
70
+ 2. Set 鉴权方式 (grant type) to **Client Credentials**.
71
+ 3. Grant the scopes the commands you intend to use need. This table is the same list
72
+ `skills/pingcode/SKILL.md` §1 gives an agent; the first four cover the smallest useful surface:
73
+
74
+ | Scope | Needed for |
75
+ |---|---|
76
+ | `pcp:read:pjm:project` | `project list` / `get` / `progress`, `project member …`, and every project-name lookup |
77
+ | `pcp:write:pjm:project` | `project create` / `update`, `project member add`. Grant deliberately: **a project can never be deleted or archived** through this API |
78
+ | `pcp:read:pjm:workitem` | `project work-item list` / `get` / `history …`, `project meta types` / `states` / `priorities` / `relation-types` / `tags` |
79
+ | `pcp:write:pjm:workitem` | `project work-item create` / `update` / `transition` / `bulk-update` / `delete`, plus `link …` and `tag …` |
80
+ | `pcp:read:global:team` | `settings users`, and every `--assignee` / `--executor` that resolves against the org directory |
81
+ | `pcp:read:pjm:sprint` | `project meta sprints` (which is the sprint *list*) and `project sprint get` |
82
+ | `pcp:write:pjm:sprint` | `project sprint create` / `update` / `bulk-create`. **A sprint can never be deleted** |
83
+ | `pcp:read:pjm:release` | `project version list` / `get`. Note the mismatch: the scope says *release*, the command says *version* |
84
+ | `pcp:write:pjm:release` | `project version create` / `update` / `delete` / `bulk-create` |
85
+ | `pcp:read:ship:product` | `product list` / `get`, `product plan …`, `product meta members`, and every product-name lookup |
86
+ | `pcp:read:ship:idea` | `product idea list` / `get` / `history …`, `product meta idea-*` |
87
+ | `pcp:write:ship:idea` | `product idea create` / `update` |
88
+ | `pcp:read:ship:ticket` | `product ticket list` / `get`, `product meta ticket-*` |
89
+ | `pcp:write:ship:ticket` | `product ticket create` / `update` / `transition` |
90
+ | `pcp:read:ship:configuration` | optional — only the state-plan *explanation* in `product ticket transition`; without it the CLI warns and lets the server judge |
91
+ | `pcp:read:testhub:library` | `testhub libraries list` / `get`, `testhub meta suites`, and the case-module (模块) tree behind `--suite` |
92
+ | `pcp:write:testhub:library` | `testhub libraries create` — grant it only if you mean to create libraries; they cannot be deleted |
93
+ | `pcp:read:testhub:testcase` | `testhub cases list` / `get` / `history list`, `testhub meta case-types` / `case-properties` |
94
+ | `pcp:write:testhub:testcase` | `testhub cases create` / `update` / `bulk-create` / `bulk-update` / `delete` |
95
+ | `pcp:read:testhub:testplan` | `testhub plans list` / `get`, `testhub runs list` / `history …`, `testhub meta plan-types` / `plan-states` |
96
+ | `pcp:write:testhub:testplan` | `testhub plans create` / `update`, `testhub runs create` / `patch` / `bulk*` |
97
+ | `pcp:read:testhub:configuration` | **not optional** — `testhub meta case-states` / `run-statuses` / `important-levels`, i.e. every `state_id`, `status_id` and `important_level_id` |
98
+ | `pcp:read:devops:code` | `scm platform` / `platform-user` / `repo` / `branch` / `commit` / `ref` / `pr` / `review` reads, and every platform/repo name lookup |
99
+ | `pcp:write:devops:code` | every `scm … create` / `update`, and `scm branch delete` |
100
+ | `pcp:read:devops:build` | `build list` / `get` |
101
+ | `pcp:write:devops:build` | `build create` / `update` / `delete`. **Separate from `devops:code`** — a token that can write commits cannot write builds, and the only symptom is exit 4 |
102
+ | `pcp:read:devops:deploy` | `release env list` / `get` **and** `release deploy list` / `get` — one pair covers both subgroups |
103
+ | `pcp:write:devops:deploy` | `release env create` / `update`, `release deploy create` / `update` |
104
+
105
+ Every ship command begins by resolving a product name, so `pcp:read:ship:product` is required
106
+ even for a pure `product idea list`. The product-scoped metadata endpoints (`/v1/ship/idea/*`,
107
+ `/v1/ship/ticket/*`) sit under the ordinary read scopes above, **not** under `configuration`.
108
+
109
+ Testhub is the same story with a sharper edge: every testhub command begins by resolving a test
110
+ library, so `pcp:read:testhub:library` is required even for a pure `testhub cases list` — and
111
+ `pcp:read:testhub:configuration` is *not* optional despite the name, because `case/states` and
112
+ `run/statuses` live behind it while their sibling `case/types` does not. A token without it can
113
+ list cases, plans and runs but cannot resolve a `status_id`, and `PATCH /runs/{id}` requires one,
114
+ so it cannot write a run at all. `--executor` on a run and `--assignee` on a plan both resolve
115
+ through the organisation directory, so they also need `pcp:read:global:team`.
116
+
117
+ The whole DevOps area (`scm`, `build`, `release`) is **企业令牌 only**, which is exactly what
118
+ `client_credentials` yields — no extra grant type is needed, only the six `devops:*` scopes.
119
+
120
+ The 15 cross-object endpoints behind `relation` / `comment` / `attachment` / `activity` declare
121
+ **no scope at all** in the vendor docs, and they work with the scopes above; a 403 from one of
122
+ them would be a documentation bug, not a missing grant.
123
+
124
+ `pingcode api describe <id>` prints the scope the docs declare for any endpoint, so a 403 through
125
+ the generic layer names the scope it wants instead of leaving you guessing.
126
+
127
+ 4. Copy the `client_id` and `client_secret`.
128
+
129
+ > **A `client_credentials` token carries organisation-wide system-administrator authority** and is
130
+ > not tied to any user. Treat it, and `~/.pingcode/config.json`, as a secret.
131
+
132
+ ## Log in
133
+
134
+ ```bash
135
+ # public cloud (default host: https://open.pingcode.com)
136
+ pingcode auth login --client-id <id> --client-secret <secret> --save
137
+
138
+ # self-hosted: pass your instance host, the API is served from <host>/open
139
+ pingcode auth login --host https://pingcode.example.com \
140
+ --client-id <id> --client-secret <secret> --save
141
+
142
+ pingcode auth status --check # adds one live call: GET /v1/pjm/projects?page_size=1
143
+ pingcode auth logout # drops the token, the credentials and the metadata cache
144
+ ```
145
+
146
+ Credentials resolve in this precedence order: **CLI flags → environment
147
+ (`PINGCODE_CLIENT_ID`, `PINGCODE_CLIENT_SECRET`, `PINGCODE_HOST`) → `~/.pingcode/config.json`**.
148
+ With a TTY attached, `auth login` prompts for anything missing.
149
+
150
+ - `--save` is what persists the client id/secret. Without it only the token is stored, so you must
151
+ log in again when it expires.
152
+ - Storage is `~/.pingcode/config.json`, mode `0600` inside a `0700` directory (a no-op on Windows).
153
+ `PINGCODE_CONFIG_DIR` relocates it.
154
+ - Tokens are valid ~30 days and are re-acquired **transparently**: proactively 120 s before expiry,
155
+ and reactively once on a `401`, replaying the original request. You never have to re-run `login`
156
+ while the credentials are stored.
157
+ - Repeated logins do **not** invalidate previously issued tokens, so parallel invocations are safe.
158
+
159
+ ---
160
+
161
+ ## Coverage: reach vs ergonomics
162
+
163
+ The API has **459** documented `/v1` endpoints and no OpenAPI spec. This CLI answers that with two
164
+ layers whose costs are completely different, and it is worth knowing which one you are standing on.
165
+
166
+ | Layer | What you get | Coverage | Cost of adding an endpoint |
167
+ |---|---|---|---|
168
+ | **Reach** — `pingcode api` | one generic executor over a vendored endpoint catalog: real auth, paging, `--dry-run`, redaction, exit codes, pre-flight validation | **459 / 459** | zero — it is already there |
169
+ | **Ergonomics** — the refined layer | `--flags` instead of raw JSON, name→id resolution, width-aware tables, per-endpoint traps recorded in `--help` | **158 / 459** | one live-verified slice each |
170
+
171
+ "Complete" (完全体) refers to **Reach**, and Reach is finished: every documented endpoint is
172
+ invocable today. Seven of the 459 are refused *before any request* because they need a user token
173
+ this CLI cannot obtain (`/v1/myself`, `/v1/permission/my/*`, `/v1/permission/check/*` — the
174
+ authorization-code flow is not implemented), which leaves 452 actually callable. Ergonomics is a
175
+ **curation backlog, not a finish line**: an endpoint earns a named command by being run against a
176
+ live tenant, having its error codes either mapped with evidence or explicitly left alone, and
177
+ keeping `--json` pure and `--dry-run` silent. Endpoints that nobody drives interactively are better
178
+ served by the generic layer than by a hand-written command nobody has exercised.
179
+
180
+ ### Per module
181
+
182
+ Counted as `(method, path)` pairs: how many of a module's endpoints the refined layer calls, out of
183
+ how many the catalog documents. The module names are the ones `pingcode api list --module <m>` takes.
184
+
185
+ | Module | Refined | Total | Notes |
186
+ |---|---|---|---|
187
+ | `pjm` 项目管理 | 40 | 145 | projects, work items, sprints, releases, members. The 105 remaining are mostly configuration — 工作项配置 schemes (42) and 项目配置 (7) — plus 看板 boards (15) |
188
+ | `ship` 产品管理 | 27 | 101 | products, ideas, tickets, requirement schedules. Customers, external users and product configuration writes are generic-layer only |
189
+ | `testhub` 测试管理 | 32 | 65 | libraries, cases, plans, runs and their config lookups |
190
+ | `scm` 源码管理 | 31 | 36 | complete except the 5 `PUT`s — see below |
191
+ | `directory` 组织架构 | 1 | 23 | `settings users` only; departments, groups, roles and jobs are org master data |
192
+ | `wiki` | 0 | 19 | **no named command at all**, by decision: page content is `PUT`-shaped and destructive, and a CLI is a poor editor |
193
+ | `release` 部署 | 8 | 12 | environments + deploys; 2 `DELETE`s and 2 `PUT`s are generic-layer only |
194
+ | `build` 构建 | 5 | 6 | complete except its 1 `PUT` |
195
+ | 跨对象 `relations` `comments` `attachments` `activities` | 14 | 15 | the 15th is the `multipart/form-data` file upload — see the follow-ups |
196
+ | `reviews` `participants` | 0 | 12 | 评审 and 关注人; note `scm review` is a *different* resource |
197
+ | `permission` | 0 | 7 | 6 of the 7 need a user token; `GET /v1/permission/points` is reachable |
198
+ | `workloads` `workload_types` | 0 | 7 | 工时 |
199
+ | `nexus` | 0 | 5 | Nexus/CES app storage |
200
+ | `auth` | 0 | 3 | not user commands: `auth login` calls the `client_credentials` grant internally, and the two user-token grants are not implemented |
201
+ | `security` `myself` | 0 | 3 | login/audit logs, and the user-token `/v1/myself` |
202
+ | **Total** | **158** | **459** | 301 endpoints are reachable through `pingcode api` only |
203
+
204
+ **Two counting traps worth stating, because they make the arithmetic look wrong otherwise.**
205
+
206
+ - The table counts **endpoints**, while `--help` counts **commands**, and the two do not correspond
207
+ one-to-one in either direction. There are **254** command leaves across **10** groups. The four
208
+ cross-object families are implemented once (14 endpoints) and *mounted on five entities* — work
209
+ items, ideas, tickets, test cases and test runs — so they contribute **70** leaves from those 14
210
+ endpoints. And `pingcode resolve` contributes **32** leaves (one per resolvable metadata kind,
211
+ plus `resolve list`) while calling only lookup endpoints already counted in their own module.
212
+ - Conversely one command often covers several endpoints (`project work-item list` is both the simple
213
+ `GET` and `POST …/search`), and **two endpoints have no command at all** yet are counted: `GET
214
+ /v1/ship/ticket_state_plans` and its `…/ticket_state_flows` child are called by the resolver cache,
215
+ to tell `product ticket transition` which states are reachable when the server refuses one. They
216
+ are wired and exercised, just never as a leaf you can type — which is why the layer is labelled
217
+ *the refined layer* rather than *named commands*. So compare the two columns of *this* table, never
218
+ a leaf count against an endpoint count.
219
+
220
+ ### How this task's plan compares
221
+
222
+ The task that produced this surface planned three mutually exclusive sets over the 459: **53 already
223
+ covered + 107 to add + 299 left to the generic layer**. Measured after the fact, at
224
+ `(method, path)` granularity:
225
+
226
+ - the **53** baseline is exact (pjm 10 + directory 1 + ship 22 + testhub 20), confirmed by running
227
+ the same count against the pre-task tree;
228
+ - **105 of the 107** landed, so refined coverage is 158 business endpoints and **301** are
229
+ generic-layer only;
230
+ - the two that did not: `POST /v1/attachments` in its `multipart/form-data` form (a file upload
231
+ needs a change to the frozen transport layer, so it was reported rather than forced), and
232
+ `GET /v1/testhub/plan_states/{state_id}` (the get-one; the *list* is wired and is the only thing
233
+ the plan write needs).
234
+
235
+ ### Why there is no `scm platform replace`
236
+
237
+ All **10** `PUT` endpoints — 5 in `scm`, 2 in `release`, 1 each in `build`, `wiki` and `testhub` —
238
+ are reachable **only** through `pingcode api`, deliberately. `PUT` is full replacement on this API,
239
+ and the docs never say what an omitted field does; one module was measured *clearing* a field its
240
+ `PATCH` sibling preserves. A named `replace` command would make that trivially easy to do by
241
+ accident, so every refined write is a `PATCH`. If you really mean "replace the whole object":
242
+
243
+ ```bash
244
+ pingcode api list --method PUT # all 10, with the resource each one replaces
245
+ pingcode api describe scm.products.replace # prints the full-replacement warning
246
+ pingcode api PUT /v1/scm/products/<id> --set name="…" --set type=other
247
+ ```
248
+
249
+ ### The escape hatches
250
+
251
+ ```bash
252
+ pingcode api list --module scm # what exists, offline, from the vendored catalog
253
+ pingcode api list --method DELETE # the whole auditable danger surface, 49 rows
254
+ pingcode api describe scm.commits.get # fields, scope, token type, paging, warnings
255
+ pingcode api GET /v1/directory/departments --all
256
+ pingcode resolve list --json # every name→id kind and the parent it needs
257
+ pingcode resolve ship-product SLC --json # ids for the generic layer, since it takes no names
258
+ ```
259
+
260
+ `api list` / `api describe` read a catalog vendored into the binary and never touch the network. A
261
+ weekly CI job diffs that catalog against the live docs — see [CI/CD](#cicd).
262
+
263
+ ---
264
+
265
+ ## Command surface
266
+
267
+ The top level mirrors PingCode's own GUI modules: each business module owns its resources *and* its
268
+ id lookups, so one `--help` shows a module's whole surface. **10 groups, 254 leaves**; `--help`
269
+ works at every level, and is the authority — this listing is a map, not a contract.
270
+
271
+ ```
272
+ pingcode auth login | status | logout
273
+ pingcode api GET|POST|PATCH|PUT|DELETE <path> · list | describe
274
+ pingcode resolve list | <kind> <name> # 31 id-resolvable kinds
275
+
276
+ # ship (产品管理)
277
+ pingcode product list | get <product>
278
+ pingcode product idea list | get <ref> | create | update <ref> · history list|get
279
+ pingcode product ticket list | get <ref> | create | update <ref> | transition <ref>
280
+ pingcode product plan list | get <ref> # 需求排期, read-only upstream
281
+ pingcode product meta idea-states | idea-priorities | idea-suites | idea-properties | idea-plans
282
+ members | ticket-states | ticket-priorities | ticket-types
283
+ ticket-channels | ticket-properties
284
+
285
+ # pjm (敏捷项目管理)
286
+ pingcode project list | get <project> | create | update <project> | progress <project>
287
+ pingcode project work-item list | get <ref> | create | update <ref> | transition <ref>
288
+ bulk-update | delete <ref>
289
+ link list|get|add|delete · tag add|get|delete · history list|get
290
+ pingcode project sprint get | create | update | bulk-create # list is `project meta sprints`
291
+ pingcode project version list | get | create | update | delete | bulk-create
292
+ pingcode project member list | get | add
293
+ pingcode project meta types | states | priorities | sprints | relation-types | tags
294
+
295
+ # testhub (测试管理)
296
+ pingcode testhub libraries list | get <library> | create
297
+ pingcode testhub cases list | get <ref> | create | update <ref> | delete <ref>
298
+ bulk-create | bulk-update · history list
299
+ pingcode testhub plans list | get <ref> | create | update <ref>
300
+ pingcode testhub runs list | create | patch <run> | bulk | bulk-create | bulk-update
301
+ history list|get
302
+ pingcode testhub meta case-states | case-types | case-properties | important-levels
303
+ run-statuses | plan-types | plan-states | suites
304
+
305
+ # scm (源码管理) — DevOps write-back, 企业令牌 only
306
+ pingcode scm platform | platform-user | repo list | get | create | update
307
+ pingcode scm branch list | get | create | update | delete
308
+ pingcode scm commit | ref list | get | create
309
+ pingcode scm pr | review list | get | create | update
310
+
311
+ # 构建与部署
312
+ pingcode build list | get | create | update | delete
313
+ pingcode release env list | get | create | update
314
+ pingcode release deploy list | get | create | update
315
+
316
+ # 后台设置
317
+ pingcode settings users
318
+
319
+ # cross-object, mounted on five entities:
320
+ # product idea · product ticket · project work-item · testhub cases · testhub runs
321
+ pingcode <entity> relation list | get | add | delete
322
+ pingcode <entity> comment list | get | add | delete
323
+ pingcode <entity> attachment list | get | add-snippet | delete
324
+ pingcode <entity> activity list | get
325
+ ```
326
+
327
+ Global flags — valid **before or after** the subcommand: `--host <url>`, `--json`, `--dry-run`,
328
+ `--no-cache`, `--verbose`, `--version`, `--help`. `--help` works at every level
329
+ (`pingcode project work-item update --help`).
330
+
331
+ ```bash
332
+ pingcode project list --json
333
+ pingcode project get "Mobile App" --json
334
+
335
+ # metadata first — ids are project-scoped
336
+ pingcode project meta types --project "Mobile App" --json
337
+ pingcode project meta states --project "Mobile App" --type task --json
338
+ pingcode project meta priorities --project "Mobile App" --json
339
+ pingcode settings users --keywords wang --json
340
+
341
+ pingcode project work-item list --project "Mobile App" --type task --state "In Progress" --json
342
+ pingcode project work-item list --project "Mobile App" --all --limit 200 --json
343
+ pingcode project work-item get SCR-5 --json # also: id, short_id, or a pasted work-item URL
344
+
345
+ pingcode project work-item create --project "Mobile App" --type task --title "Fix login retry" --dry-run --json
346
+ pingcode project work-item create --project "Mobile App" --type task --title "Fix login retry" \
347
+ --assignee wangxiao --priority High --end-at 2026-02-15 --json
348
+
349
+ pingcode project work-item update SCR-5 --title "Fix login retry (v2)" --json
350
+ pingcode project work-item transition SCR-5 --type task --state Done --json
351
+ pingcode project work-item transition SCR-5 --state-id 5eb623f6a70571487ea47000 --json
352
+ ```
353
+
354
+ ```bash
355
+ # ship: resolve the product first — every other ship id hangs off it
356
+ pingcode product list --json
357
+ pingcode product get SLC --json
358
+
359
+ pingcode product meta idea-states --product SLC --json
360
+ pingcode product meta members --product SLC --json # the only valid --assignee values
361
+ pingcode product meta ticket-types --product SLC --json # required to create a ticket
362
+
363
+ pingcode product idea list --product SLC --state 待评审 --assignee zhangsan --json
364
+ pingcode product idea get SLC-1 --json
365
+ pingcode product idea create --product SLC --title "Single sign-on" --dry-run --json
366
+ pingcode product idea update SLC-1 --state 开发中 --json
367
+
368
+ pingcode product ticket list --product SLC --type 故障 --json
369
+ pingcode product ticket create --product SLC --type 故障 --title "Cannot log in" --json
370
+ pingcode product ticket transition SLC-7 --state 处理中 --json
371
+ ```
372
+
373
+ ```bash
374
+ # testhub: resolve the test library first — states, types, statuses, modules and plans hang off it
375
+ pingcode testhub libraries list --json
376
+ pingcode testhub libraries get LIB --json
377
+ pingcode testhub libraries create --name "Payments" --identifier PAY --json # permanent: no DELETE
378
+
379
+ pingcode testhub meta case-states --library LIB --json # --state / state_id
380
+ pingcode testhub meta case-types --library LIB --json # --type / type_id
381
+ pingcode testhub meta run-statuses --library LIB --json # --status / status_id
382
+ pingcode testhub meta plan-types --library LIB --json # --type on `plans create`
383
+ pingcode testhub meta suites --library LIB --json # --suite; the PATH column is the key
384
+ pingcode testhub meta suites --library LIB --parent-id root --json # top level only
385
+ pingcode testhub meta important-levels --json # org-wide: takes no --library
386
+
387
+ pingcode testhub cases list --library LIB --state 已评审 --json
388
+ pingcode testhub cases get aB3dEf9h --json # an id or a short_id
389
+ pingcode testhub cases create --library LIB --title "SSO login" --dry-run --json
390
+ pingcode testhub cases update aB3dEf9h --state 已评审 --json
391
+
392
+ pingcode testhub plans list --library LIB --json
393
+ pingcode testhub plans get "2026 S1 回归" --library LIB --json
394
+ pingcode testhub plans create --library LIB --name "2026 S2 回归" \
395
+ --type 普通 --start 2026-08-10 --end 2026-08-31 --assignee 张三 --dry-run --json
396
+
397
+ pingcode testhub runs list --library LIB --plan "2026 S1 回归" --json
398
+ pingcode testhub runs update 7hK2mQ9x --status 通过 --remark "retested on iOS" --json
399
+ pingcode testhub runs bulk --library LIB --plan "2026 S1 回归" --remove-run 7hK2mQ9x --json
400
+ ```
401
+
402
+ ```bash
403
+ # DevOps write-back: a CI job telling PingCode what happened. Nothing here reads your
404
+ # git server or your pipeline — every command writes a record PingCode links to work items.
405
+ pingcode scm platform list --json
406
+ pingcode scm repo list --platform "GitHub" --json
407
+ pingcode scm commit create --sha 9f3c1ab0000000000000000000000000000000ab \
408
+ --message "fix login retry" --committer ci-bot --work-item PLM-1 --dry-run --json
409
+ pingcode scm pr create --platform "GitHub" --repo acme/web --title "Fix login retry" \
410
+ --number 42 --status open --creator ci-bot \
411
+ --source-branch-id <id> --target-branch-id <id> --json
412
+
413
+ pingcode build create --name nightly --identifier 1042 --provider jenkins --status success \
414
+ --start-at 2026-08-05T01:00:00Z --end-at 2026-08-05T01:07:30Z --duration 450 \
415
+ --work-item PLM-1 --json
416
+ pingcode release env list --json
417
+ pingcode release deploy create --env staging --status deployed --release-name 1.4.0 \
418
+ --start-at 2026-08-05T02:00:00Z --end-at 2026-08-05T02:03:00Z --duration 180 --json
419
+ ```
420
+
421
+ ```bash
422
+ # cross-object: the same four families on any of the five entities
423
+ pingcode project work-item comment add SCR-5 --text "blocked by the SSO rollout" --json
424
+ pingcode project work-item relation add SCR-5 --target-type test_case --target-id <id> --json
425
+ pingcode product idea activity list SLC-1 --json
426
+ pingcode testhub runs attachment list 7hK2mQ9x --json
427
+ ```
428
+
429
+ `--dry-run` on a mutating command prints the request it *would* have sent and exits 0 without
430
+ sending it. Read requests still run, so ids are genuinely resolved first.
431
+
432
+ ---
433
+
434
+ ## The `--json` contract
435
+
436
+ - **stdout carries JSON only.** Tables, logs, warnings, dry-run notes and errors go to stderr.
437
+ - Timestamps stay raw **unix seconds** in `--json`; human mode renders local time.
438
+ - Three list shapes, by command family:
439
+ - one page of any refined `list` (`project work-item list`, `product idea list`,
440
+ `testhub cases list`, `scm repo list`, `build list`, `release deploy list`, …) →
441
+ `{"page_index":0,"page_size":30,"total":123,"values":[…]}`
442
+ - any list with `--all` → `{"values":[…],"count":42,"all":true}`
443
+ - every `meta` lookup (`product meta …`, `project meta …`, `testhub meta …`, `settings users`) → `{"values":[…],"count":20}`
444
+ - Single-resource commands (`get`, `create`, `update`, `transition`) print the resource object.
445
+ - `--dry-run` prints `{"dry_run":true,"request":{"method":…,"url":…,"headers":…,"body":…}}` — with
446
+ `Authorization` and any `client_secret` masked.
447
+ - Errors print to **stderr** as `{"error":{"kind":…,"message":…,"code":…,"exit":…}}`.
448
+ - **`pingcode api` is different: stdout is always the API's raw JSON, so `--json` is a no-op on the
449
+ five verbs.** There is no table to switch off. Its own `api list` / `api describe` are local
450
+ catalog views and do honour `--json`.
451
+ - **Read keys defensively — an absent key means null *or* empty.** `api/parse.ts` normalises both
452
+ `null` and `""` to "not present", so they are simply missing from the output and cannot be told
453
+ apart. See the follow-ups; this is the one output change queued as breaking.
454
+
455
+ ### Exit codes
456
+
457
+ | Exit | Kind | Meaning |
458
+ |---|---|---|
459
+ | 0 | — | success, including a printed dry-run plan |
460
+ | 1 | `unexpected` | unhandled internal error |
461
+ | 2 | `usage` | bad/missing flags, ambiguous or unresolvable name, empty update |
462
+ | 3 | `auth` | no or invalid credentials |
463
+ | 4 | `permission` | 403, or a scope the app was never granted |
464
+ | 5 | `not_found` | the work item, state or other resource does not exist |
465
+ | 6 | `rate_limit` | 429 — the limit is 200 requests/minute per token |
466
+ | 7 | `api` | any other non-2xx, carrying the API's `{code, message}` |
467
+ | 8 | `transport` | DNS / TCP / TLS / timeout / unparseable body |
468
+
469
+ This API answers **HTTP 400 where REST convention would use 401 or 404**, so a table of observed API
470
+ `code` values is mapped by code rather than by status. It currently holds **32 rows** — 1 → exit 3
471
+ and 31 → exit 5 — and the authoritative copy, with the live observation behind every single row, is
472
+ `ERROR_CODE_OVERRIDES` in `src/core/wire.ts`. A sample:
473
+
474
+ | API `code` | HTTP | Observed on | → exit |
475
+ |---|---|---|---|
476
+ | `100024` | 400 | `GET /v1/auth/token` with a wrong client id/secret | 3 (`auth`) |
477
+ | `100317` | 400 | `GET /v1/pjm/work_items/{unknown id}` | 5 (`not_found`) |
478
+ | `100725` / `100711` | 400 | unknown ship idea / ticket | 5 (`not_found`) |
479
+ | `100601` / `100603` / `100600` | 400 | unknown testhub case / run / library | 5 (`not_found`) |
480
+ | `100051` / `100045` / `100801` / `100077` | 400 | unknown comment / attachment / relation / activity | 5 (`not_found`) |
481
+ | `100200` / `100202` / `100209` | 400 | unknown scm platform / repository / git identity | 5 (`not_found`) |
482
+
483
+ The rule for growing that table is in
484
+ [`.trellis/spec/backend/error-handling.md`](.trellis/spec/backend/error-handling.md): match on the
485
+ `code` string only (the API is Chinese-only and its wording is not a contract), and add a row only
486
+ with a recorded live observation cited next to it.
487
+
488
+ Any other code keeps the status-first mapping and is surfaced verbatim on exit 7 — read `code`
489
+ before drawing conclusions. (An invalid *bearer* token on a resource endpoint does return a real
490
+ 401, so the 401 branch is still live.) Note what is deliberately **absent**, and why the absences
491
+ matter as much as the rows: ship's `100719` / `100702` ("state does not exist") also fire for a state
492
+ that plainly exists but is unreachable under the state plan, so mapping them to `not_found` would be
493
+ a lie; testhub's `100619` rejects a *whole* bulk batch, so exit 5 would name one run while implying
494
+ the others landed; and `100000` is a real HTTP 500 that must keep it.
495
+
496
+ ---
497
+
498
+ ## Caveats that matter in practice
499
+
500
+ The exhaustive per-module traps live in `skills/pingcode/modules/*.md` — one file per module, and
501
+ they are written for an agent, which makes them the most detailed reference in the repository. What
502
+ follows is only what applies everywhere.
503
+
504
+ - **Ids are parent-scoped — run the module's `meta` lookups first.** The parent is a **project** in
505
+ pjm, a **product** in ship, a **test library** in testhub and a **hosting platform** in scm. The
506
+ same state name has a different id under a different parent. System work-item types are bare slugs
507
+ (`task`, `story`, `bug`); custom types, states and priorities are 24-hex ids; users are 32-hex.
508
+ Never reuse an id across parents, and never let a script validate an id's shape.
509
+ - **`update` replaces, it does not merge.** Every field you pass overwrites the stored value, and
510
+ arrays plus `properties` objects are replaced wholesale. Read the item first if you need to keep
511
+ anything. There is no way to clear a field, and an update with no fields is exit 2, not a no-op.
512
+ - **Every `delete` needs `--yes`, and the refusal echoes the resolved name**, not just the id — the
513
+ confirmation costs one extra GET and buys back the one class of mistake that cannot be undone.
514
+ `pingcode api list --method DELETE` enumerates all 49 deletable endpoints.
515
+ - **`PATCH` only. No refined command issues a `PUT`** — see
516
+ [why there is no `scm platform replace`](#why-there-is-no-scm-platform-replace).
517
+ - **`--all` is best effort, not a snapshot.** It walks 0-based pages (`page_size` ≤ 100),
518
+ de-duplicates by id, stops at `--limit` (default 500) and bails if the server stops honouring
519
+ `page_index`. **No endpoint supports sorting**, so offset paging over changing data can duplicate
520
+ or skip rows. Sort what you collected yourself.
521
+ - **Rate limit: 200 requests/minute per token.** 2xx responses carry no rate-limit headers, so the
522
+ budget is invisible until a 429 arrives. Prefer one `--page-size 100` call over many small ones,
523
+ and let the cache work.
524
+ - **Timestamps are unix seconds everywhere.** Date flags accept `1730000000` or a calendar date;
525
+ read the flag's own `--help`, because the two families differ deliberately: `project --start-at`
526
+ stores the instant verbatim, while `project sprint` / `project version` / `testhub plans` snap
527
+ `--start` to `00:00:00` and `--end` to `23:59:59` of the date.
528
+ - **A 200 is not proof the field landed.** This API accepts unknown body fields, several read-only
529
+ fields and (in `work-item bulk-update`) whole unsupported properties with a 200 and no warning.
530
+ Where a command knows about one, it refuses locally or warns on stderr; where it cannot know, read
531
+ the object back.
532
+ - **Metadata is cached for 24 h** under `~/.pingcode/cache/` (mode `0600`, hashed filenames), keyed
533
+ by `(apiBase, clientId, parentId, kind)`. Pass `--no-cache` if a parent was reconfigured and an id
534
+ looks stale; a write rejected on a cached id invalidates that entry and retries **once**, and only
535
+ if re-resolving actually changed an id — the CLI never sends the same mutating body twice.
536
+ `auth login` and `auth logout` both clear the cache.
537
+ - **`pingcode resolve` is the same lookup as a hand-typed name**, exposed as one id on stdout so it
538
+ can feed `pingcode api`, which takes ids only.
539
+ - **Two flag shapes, split by module, both accepting a name or an id.** `testhub`, `scm` and
540
+ `release` use **pairs** — `--library` / `--library-id`, `--platform` / `--platform-id`, `--repo` /
541
+ `--repo-id`, `--env` / `--env-id` — where `--x` looks the name up and `--x-id` is sent verbatim with
542
+ no lookup; the two are mutually exclusive (exit 2). `pjm` and `ship` use a **single** flag
543
+ (`--project`, `--sprint`, `--release`, `--product`) that decides for you and offers no way to skip
544
+ the lookup. Neither shape ever validates an id's format. `SKILL.md` has the table plus the three
545
+ deliberate exceptions (`testhub runs list --case-id`, `scm … list --work-item-id`, and
546
+ `project update --state-id`, which has no `--state` because no resolver kind covers project states).
547
+
548
+ ### pjm-specific caveats
549
+
550
+ - **`--state <name>` always needs `--type`.** States live in a `(project, work item type)` pair and
551
+ the API never reports a work item's type, so the CLI cannot infer it — not on `list`, and not on
552
+ `update`/`transition` (`create` already requires `--type`). Pass `--type <name|id>`, or skip the
553
+ lookup with `--state-id <id>`. On `update`/`transition`, `--type` is *only* a lookup aid: it is
554
+ never written to the work item. `--state` and `--state-id` are mutually exclusive.
555
+ - **State changes are workflow-validated server-side.** On rejection the CLI prints the server
556
+ message plus the states configured for that type — but only if you passed `--type`.
557
+ - **A project can never be deleted or archived**, and a **sprint can never be deleted at all**.
558
+ `project create`, `project sprint create` and `sprint bulk-create` are irreversible; `--dry-run` first.
559
+ - **`link` and `relation` are different families.** `link` is work item ↔ work item with a required
560
+ type; `relation` is work item ↔ anything *else* and refuses two work items outright.
561
+ - **There is no `sprint list` or `work-item tag list` leaf.** The sprint list is `project meta
562
+ sprints` (it doubles as the `--sprint` lookup); a work item's tags are the `tags[]` field of
563
+ `work-item get`, because upstream publishes no collection GET for them.
564
+ - **A work item's assignee cannot be cleared via the API.** `PATCH`'s `assignee_id` is a plain
565
+ string with no `nullable`: `null` is a silent HTTP 200 no-op and `""` is an HTTP 400. So
566
+ `work-item update <id> --assignee ""` fails fast (exit 2) rather than sending a request that would
567
+ look like success. To unassign, use the PingCode web UI.
568
+
569
+ ### Ship-specific caveats
570
+
571
+ Everything above still applies; [`modules/ship.md`](skills/pingcode/modules/ship.md) is the full
572
+ version. These are the differences that will cost you time:
573
+
574
+ - **A product is ship's project.** `state_id`, `priority_id`, `suite_id`, `type_id`, `channel_id`,
575
+ the writable `properties` keys and the assignable people are all **product-scoped**, even though
576
+ several of them look org-global (the same `P0` priority id appears under multiple products). The
577
+ API demands `product_id` on every lookup; never reuse an id across products.
578
+ - **`--assignee` resolves against product members**, not `/v1/directory/users`. A user who is not a
579
+ member of the product cannot be assigned, so `product meta members` is the candidate set.
580
+ - **`product idea list` and `product ticket list` are `POST …/search`.** The plain list endpoints cannot filter by
581
+ assignee, date or custom property. The DSL allows **one operator per field and no `$and`/`$or`**;
582
+ several filters are AND-ed. Body pagination puts the cursor in `payload.page_index`, and the CLI
583
+ applies the same `--page` / `--page-size` (≤100) / `--all` / `--limit` semantics as elsewhere.
584
+ - **State changes are decided by the server; ticket refusals are explained.** Ship publishes the
585
+ legal transitions of a ticket state plan, and the CLI reads them — but only to *explain* a
586
+ refusal, never to pre-empt one. `product ticket transition` sends the PATCH; if the server refuses, the
587
+ error `message` carries the configured states, the current state and the states reachable from
588
+ it. `product ticket transition --dry-run` previews that reachable set on stderr without writing. Ideas
589
+ have **no state-flow endpoint at all**, so `product idea update --state` gets the configured states on
590
+ rejection and nothing more. The only local refusal is moving a ticket to the state it is already
591
+ in. Rationale: the server refuses atomically, so nothing is saved by checking first, while a
592
+ mis-identified plan would block a legal move outright (`s7-smoke.md` F5).
593
+ - **Locating a ticket's state plan is a scan, and only ever advisory.** The ticket payload carries
594
+ no plan reference and the plan list has no `product_id` filter, so the CLI lists every plan and
595
+ matches the embedded `product.id`, falling back to the org-default (`product: null`) plan when
596
+ there is exactly one — which live is the common case. Cached per product. Since the answer only
597
+ feeds an explanation, a wrong guess costs a wrong suggestion, never a blocked write.
598
+ - **`--set key=value` sends the value verbatim, and select-type properties want the option `_id`,
599
+ not its label.** `product meta idea-properties` / `product meta ticket-properties` print both, and are also the
600
+ authoritative list of writable keys. `properties` replaces wholesale.
601
+ - **Nothing in ship can be deleted.** There is no DELETE for products, ideas or tickets, and
602
+ `is_archived` / `is_deleted` are read-only. Anything you create during a test is permanent —
603
+ prefix the title before creating it.
604
+ - **An identifier works on the resource, and nowhere below it.** `product idea get` / `ticket get`
605
+ accept the id, the 8-char `short_id` a pasted URL ends in **and** the human `SLC-1` — all three
606
+ answer 200 live. A sub-resource (a comment, an attachment) is addressed by the parent's real id, so
607
+ every write resolves the reference first.
608
+ - **`--suite` filtering on `product idea list` is undocumented** — the API lists `suite.id` as neither
609
+ filterable nor unfilterable, so an empty result proves nothing. The CLI warns when you use it.
610
+ - **`ticket.channel` is an object or the bare string `"internal"`**, and `--channel` can only be set
611
+ at create time. Tags cannot be written at all, and a ticket's `submitter_id` is silently ignored
612
+ under a client-credentials token — neither is exposed.
613
+ - **"Plan" is three unrelated resources**, and mixing them up produces a not-found nobody can
614
+ explain: `product plan` is a 需求排期 (requirement schedule, read-only upstream — a write answers
615
+ HTTP 405), `testhub plans` is a test plan, and `ticket_state_plans` is a configuration scheme
616
+ reachable only through `pingcode api`.
617
+ - **`product idea history` is state changes only.** A title or assignee edit is not there; that is
618
+ `product idea activity list`, the free-form feed.
619
+
620
+ ### Testhub-specific caveats
621
+
622
+ Everything above still applies. Testhub's parent scope is a **test library**, and its write path is
623
+ the sharpest in the CLI. [`skills/pingcode/modules/testhub.md`](skills/pingcode/modules/testhub.md)
624
+ is the exhaustive version, including the `--set` traps and the two bulk families' opposite failure
625
+ modes.
626
+
627
+ - **A test library is testhub's project.** `state_id`, `type_id`, `status_id`, `suite_id` and the
628
+ plan list are all library-scoped — two libraries never share a state, type or status id, even when
629
+ the names match. Six `meta` leaves are library-scoped (`case-states`, `case-types`,
630
+ `case-properties`, `run-statuses`, `plan-types`, `suites`) and two are organisation-level
631
+ (`important-levels`, `plan-states`); a missing `--library` on a library-scoped command is exit 2.
632
+ `cases get|update|delete`, `plans get|update` and `runs update` read the resource first and inherit
633
+ its library; `runs list` needs one only to resolve `--plan` / `--status` by name.
634
+ - **`cases list` and `runs list` are `POST …/search`.** The plain `GET` lists are never used —
635
+ unfiltered, `GET /v1/testhub/cases` scans every library the token can see. Same DSL limits as
636
+ ship: one operator per field, no `$and`/`$or`, no sorting.
637
+ - **`--step` is all-or-nothing, because `steps[]` replaces.** A run's step array is overwritten
638
+ wholesale and a step sent without its `step_id` is re-created with a new id, orphaning its
639
+ history. Re-emitting an untouched step is impossible: a run step reports a status **slug** while
640
+ the write needs a status **id**, and only the localized (renameable) name joins them. So the CLI
641
+ refuses a partial step edit and prints the full list of step ids. `--set` / `properties` on a case
642
+ replace wholesale too.
643
+ - **`runs update` always sends `status_id`, and carries the executor over.** `status_id` is required
644
+ by the API even on PATCH, so the CLI pre-reads the run and re-sends its current result — and its
645
+ current executor — when you do not name one. If the run has no executor and you name none,
646
+ `executor_id` is omitted and the CLI warns that the run stays unassigned (omitting it is a
647
+ verified no-op on PATCH: it neither clears the field nor reassigns the run). With no recorded
648
+ result at all it asks for `--status` (exit 2) rather than sending a half-formed body.
649
+ - **`runs bulk` is the only way to *delete* a run** — there is no run DELETE endpoint at all. Runs
650
+ can be *created* three ways (`runs create`, `runs bulk-create`, or `runs bulk --add-case`), and the
651
+ caps differ: `cases bulk-*` and `runs bulk-*` are capped at **100 by the server**, while
652
+ `runs bulk` enforces nothing upstream and the CLI caps each of its three arrays at **50** locally.
653
+ A bulk response is **counts only**: re-list the plan to see the new ids.
654
+ - **The two bulk families fail in opposite ways.** `runs bulk` is per-element best effort under a
655
+ 200, `cases bulk-*` is atomic. Do not generalise one to the other — always read the counts.
656
+ - **`runs list` cannot filter by `library.id`** — it is on the API's exclusion list for run search,
657
+ so scope runs with `--plan`. The CLI warns when `--library` is given without one.
658
+ - **`meta important-levels` takes no `--library`.** Importance levels are organisation-wide, the one
659
+ testhub lookup with no per-library variant, so the flag is refused with exit 2 instead of being
660
+ ignored. The refusal is hidden from `--help`, which is why it is written down here.
661
+ - **`pcp:read:testhub:configuration` is a trap, not an option.** `meta case-states` and
662
+ `meta run-statuses` need it; their sibling `meta case-types` does not. Without it a token gets a
663
+ bare 403 from exactly the two lookups that produce a `state_id` and a `status_id` — so it cannot
664
+ write a run at all. The CLI rewrites that 403 to say so.
665
+ - **`cases create` sends the library as `test_library_id`** (not `library_id`), and `state_id` is
666
+ **PATCH-only**: a case is created in the library's initial state and moved with `cases update`.
667
+ - **`cases delete` takes the case's runs with it**, soft-deleted alongside it. It is gated behind
668
+ `--yes` and the confirmation names the case, because the blast radius is invisible from the
669
+ reference you typed.
670
+ - **`short_id` is read-only.** Reads accept an id or a `short_id`; every write documents `id` only,
671
+ so the write paths resolve it through a pre-read.
672
+ - **Dates: the end of a range is inclusive.** `--start` / `--end` on `plans create` and
673
+ `plans update` take a zero-padded `YYYY-MM-DD` or a 10-digit unix **seconds** integer. A calendar
674
+ date resolves to **00:00:00 local** for `--start` and **23:59:59 local** for `--end`; a raw integer
675
+ is passed through **verbatim** on both. The asymmetry is deliberate — a range runs *through* its
676
+ end date. Rejected with exit 2 **before any request**: an unpadded `2026-8-1`, slashes, an ISO
677
+ string carrying a time, a 13-digit milliseconds value, an impossible date like `2026-02-30`, and
678
+ `--end` before `--start`.
679
+ - **`plans create` requires all six flags and `--assignee` has no default.** `--library`, `--name`
680
+ (unique within the library), `--type`, `--start`, `--end`, `--assignee`. There is no "assign to me"
681
+ because a client-credentials token acts as the **bot user**, so a default would quietly make a bot
682
+ the 负责人 of every plan the CLI creates.
683
+ - **A plan type carries no `kind`, so the CLI cannot classify it.** Iteration and release types also
684
+ need `sprint_id` / `version_id` (and the `project_id` those make mandatory), but the plan-type
685
+ resource exposes only `id` / `name` / `url` / `library` — and tenants rename these, so the name is
686
+ not a safe discriminator. `plans create` sends what you gave it and surfaces the **server's**
687
+ refusal for a type that needs more. Use the plain (普通) type unless you know the tenant's setup.
688
+ - **A library can be created but never deleted.** `--identifier` is unique across the organisation
689
+ and the server enforces it. There is **no library DELETE**; a library PATCH *does* exist upstream
690
+ and is reachable generically —
691
+ `pingcode api PATCH /v1/testhub/libraries/<id> --set description="…"` — but it cannot clear a
692
+ field, so name a library right the first time. The CLI prints that warning after every create.
693
+ - **Still not exposed, on purpose:** library members, case-module (suite) writes, plan **delete**,
694
+ configuration writes, and `PUT /runs/{id}` (documented to blank the executor when the field is
695
+ omitted — unverified, and `runs update` covers the same ground safely). All are reachable through
696
+ `pingcode api` if you really need them.
697
+
698
+ ### SCM, build and release caveats
699
+
700
+ These three groups are the DevOps **write-back** surface: a CI/CD job tells PingCode what happened,
701
+ and PingCode links it to work items. None of them reads your git server or your pipeline.
702
+ [`modules/scm.md`](skills/pingcode/modules/scm.md) and
703
+ [`modules/cicd.md`](skills/pingcode/modules/cicd.md) are the full versions.
704
+
705
+ - **All 企业令牌 only**, which is exactly what `client_credentials` yields — but under three
706
+ *separate* scopes (`devops:code`, `devops:build`, `devops:deploy`). A token that can write commits
707
+ cannot write builds, and the only symptom is exit 4.
708
+ - **`/v1/scm/products` is a hosting platform (托管平台), not a ship product.** Every scm command
709
+ except the commit family starts by resolving a platform; commits are organisation-level.
710
+ - **A "platform user" is a git author identity, not a PingCode member** — it carries no `user_id`,
711
+ and naming an unknown one on a write **creates** it. Since scm has **no DELETE anywhere** except
712
+ branches, a typo in `--sender` or `--creator` is a permanent row.
713
+ - **`full_name` (`owner/name`) is a repository's unique key**, and `?name=` is ignored upstream —
714
+ hence `scm repo list --full-name`, an exact filter.
715
+ - **`build list` has no filters at all** (five plausible ones were probed live and silently ignored),
716
+ and a build `identifier` is not unique, so it is not a lookup key either.
717
+ - **`release deploy list` hides an unknown environment behind an empty list**, so an empty result is
718
+ not evidence the environment exists. Resolve it first.
719
+ - **Work items are linked by `--work-item <identifier>`** (`PLM-1`), not by id, and an unknown
720
+ identifier is **silently dropped** by the API under a 200.
721
+
722
+ ---
723
+
724
+ ## The `pingcode` skill
725
+
726
+ `skills/pingcode/` is the source of truth for the agent-facing docs. It is layered the same way this
727
+ README now is: `SKILL.md` carries only what does not scale with the surface — the authentication
728
+ gate, the `--json` / `--dry-run` contracts, the exit-code table, the escape hatches and a map — and
729
+ one file per module carries that module's flags and traps:
730
+
731
+ | File | Covers |
732
+ |---|---|
733
+ | [`modules/pjm.md`](skills/pingcode/modules/pjm.md) | projects, work items, sprints, releases, members |
734
+ | [`modules/ship.md`](skills/pingcode/modules/ship.md) | products, ideas, tickets, requirement schedules |
735
+ | [`modules/testhub.md`](skills/pingcode/modules/testhub.md) | libraries, cases, plans, runs |
736
+ | [`modules/scm.md`](skills/pingcode/modules/scm.md) | platforms, git identities, repos, branches, commits, refs, PRs, reviews |
737
+ | [`modules/cicd.md`](skills/pingcode/modules/cicd.md) | build records, environments, deployments |
738
+ | [`modules/crosscutting.md`](skills/pingcode/modules/crosscutting.md) | relations, comments, attachments, activities and their five mounts |
739
+ | [`modules/api.md`](skills/pingcode/modules/api.md) | the generic executor and `api list` / `api describe` |
740
+
741
+ `test/help/skill.test.ts` asserts that **every `pingcode …` path mentioned in any of those files
742
+ resolves in the real commander tree**, so a documented command that does not exist fails the suite.
743
+ The reverse is deliberately *not* asserted: at 254 leaves, requiring every leaf to be documented
744
+ would make the docs a merge point for every parallel change. Sync them to your agent skill
745
+ directories:
746
+
747
+ ```bash
748
+ npm run skill:install -- --dry-run # show the destinations, write nothing
749
+ npm run skill:install # pick a target (prompts on a TTY, else installs both)
750
+ npm run skill:install -- --target claude # Claude Code only
751
+ npm run skill:install -- --target opencode # OpenCode only
752
+ npm run skill:install -- --target claude,opencode # or --target all
753
+ npm run skill:install -- --force # overwrite existing copies
754
+ ```
755
+
756
+ Installs are **global (user-level)** only, and copy the `modules/` directory alongside `SKILL.md`:
757
+
758
+ | Target | Destination |
759
+ | --- | --- |
760
+ | `claude` | `~/.claude/skills/pingcode/SKILL.md` |
761
+ | `opencode` | `$XDG_CONFIG_HOME/opencode/skills/pingcode/SKILL.md` (default `~/.config/opencode/…`) |
762
+
763
+ `--target` is repeatable, comma-separated and case-insensitive. With no `--target` the script
764
+ prompts when stdin is a TTY (prompt on stderr, `q` aborts without writing) and installs **both**
765
+ targets when it isn't, so CI and pipes keep their old behaviour. An unknown target exits `2`.
766
+
767
+ ---
768
+
769
+ ## CI/CD
770
+
771
+ Three GitHub Actions workflows, all dependency-free: every gate is an npm script you can run
772
+ locally with the identical command, so a red run never needs a "push and see" loop.
773
+
774
+ **`.github/workflows/ci.yml`** — on every push to `main` and every pull request. Superseded runs
775
+ for the same ref are cancelled.
776
+
777
+ | Job | What it does |
778
+ | --- | --- |
779
+ | `node 20` / `node 22` / `node 24` | `npm ci` → `typecheck` → `test` → `build` → run the built bundle's `--version` and `--help` → `skill:install --dry-run` |
780
+ | `secret scan and commit gate` | `scan:secrets` and `check:commits` over the pushed/PR commit range, once per run |
781
+
782
+ **`.github/workflows/catalog-check.yml`** — a **weekly** (Mondays 03:17 UTC) and on-demand watch that
783
+ diffs the vendored endpoint catalog against the live apiDoc bundle. It is deliberately **not** a PR
784
+ gate: it depends on a third-party host, and an upstream documentation edit has nothing to do with
785
+ whichever pull request happens to be open when it lands. On drift it files or refreshes a **single**
786
+ `catalog-drift` issue and stays green; it closes that issue when upstream matches again, and fails
787
+ only when the check itself could not run. What to do with the issue is
788
+ [`.trellis/spec/backend/catalog-drift.md`](.trellis/spec/backend/catalog-drift.md) — in particular,
789
+ live behaviour outranks the catalog, and a command is never deleted because an endpoint vanished for
790
+ one cycle.
791
+
792
+ Permissions are `contents: read` at workflow level; the release job and the drift watch each elevate
793
+ exactly one scope. No secrets are used or needed — the test suite injects `fetch` and never opens a
794
+ socket, and there are no PingCode credentials in CI.
795
+
796
+ Run the same gates locally:
797
+
798
+ ```bash
799
+ npm run typecheck && npm test && npm run build
800
+ node dist/bin/pingcode.js --version && node dist/bin/pingcode.js --help
801
+ npm run skill:install -- --dry-run
802
+
803
+ npm run catalog:check # diff the vendored catalog against the live docs
804
+ npm run scan:secrets # tracked files
805
+ npm run scan:secrets -- origin/main..HEAD # + those commit messages
806
+ npm run check:commits # whole history
807
+ npm run check:commits -- origin/main..HEAD # just your branch
808
+ npm run check:commits -- --file .git/COMMIT_EDITMSG # one message file (what the hook runs)
809
+ ```
810
+
811
+ `scan:secrets` (`scripts/scan-secrets.ts`) looks for `client_secret=…` assignments,
812
+ `PINGCODE_CLIENT_ID` / `PINGCODE_CLIENT_SECRET` assignments with a real-looking value, `Bearer`
813
+ token literals, and tenant hosts (a `*.pingcode.com` subdomain containing a digit). The patterns
814
+ are deliberately keyword-anchored: a generic "hex id" rule would match every git sha and every
815
+ work-item id. Documented placeholders are ignored, and a line carrying `scan-secrets:allow` is
816
+ never reported.
817
+
818
+ `check:commits` (`scripts/check-commits.ts`) enforces
819
+ [`.trellis/spec/guides/commit-conventions.md`](.trellis/spec/guides/commit-conventions.md): the
820
+ `type(scope): subject` shape, the type table, a lowercase non-empty subject with no trailing period
821
+ and at most 72 characters. Merge commits are exempt, and on a pull request the PR title is checked
822
+ too because a squash merge turns it into the commit subject.
823
+
824
+ > **Node version note.** `skill:install`, `scan:secrets` and `check:commits` are TypeScript run
825
+ > through `node --experimental-strip-types`, which exists from Node **22.6** only. On the Node 20
826
+ > matrix leg the `skill:install --dry-run` step is therefore skipped, and the hygiene job runs on
827
+ > Node 24. `engines` still says `>=20` because the *published bundle* is built for Node 20 and is
828
+ > smoke-tested there; the restriction is on the repository's own scripts, not on the CLI.
829
+
830
+ ### Git hooks
831
+
832
+ Native git hooks in [`.githooks/`](.githooks), pointed at by `core.hooksPath`. `npm install` wires
833
+ them up through `prepare`; to do it by hand:
834
+
835
+ ```bash
836
+ npm run hooks:install # git config core.hooksPath .githooks
837
+ ```
838
+
839
+ | Stage | What it runs |
840
+ | --- | --- |
841
+ | `pre-commit` | `npm run scan:secrets`, `npm run typecheck` |
842
+ | `commit-msg` | `npm run check:commits -- --file "$1"` |
843
+ | `pre-push` | `npm test`, `npm run build`, then the built bundle's `--version` and `--help` |
844
+
845
+ **A hook only ever runs a command CI also runs.** No rule lives in a hook alone, so `git commit
846
+ --no-verify` / `git push --no-verify` *defers* feedback to CI rather than skipping a check — which is
847
+ exactly what you want when you are mid-thought and the fix is one commit away. The split follows how
848
+ expensive a mistake is to undo: a leaked credential is the only irreversible one here (the
849
+ `client_secret` travels in a query string, and history already needed one sanitisation pass), so the
850
+ secret scan sits in the cheapest, most frequent gate. `check:commits` gained a `--file` mode for the
851
+ hook, because in CI it can only look at commits that already exist — a bad message is found after the
852
+ commit is written and the fix is a rebase, whereas `commit-msg` catches it before the commit is born.
853
+ The slow suite waits for `pre-push`, the point at which code starts reaching other people.
854
+
855
+ > **Caveat worth knowing: `pre-commit` validates the working tree, not the staged snapshot.** With
856
+ > unstaged changes present, `typecheck` and `scan:secrets` check something other than what is being
857
+ > committed. Stashing around the hook (`git stash --keep-index`) would fix that and is deliberately
858
+ > *not* implemented: an interrupted hook can then lose work, and a documented limitation beats an
859
+ > unexplainable failure mode. `scan:secrets` does enumerate `git ls-files`, so newly staged files are
860
+ > included — it just reads their contents from disk. CI has the last word either way.
861
+
862
+ The installer (`scripts/install-hooks.mjs`) no-ops when `CI` is set or when it is not inside a git
863
+ work tree, so it can never fail an install. It is plain `.mjs` rather than `.ts` like everything else
864
+ in `scripts/` for one reason: `prepare` runs during `npm ci`, including on the Node 20 leg, where
865
+ `--experimental-strip-types` does not exist.
866
+
867
+ **`.github/workflows/release.yml`** — on tags matching `v*`. To cut a release:
868
+
869
+ ```bash
870
+ # 1. bump the version in package.json (src/version.ts is asserted to match by test/version.test.ts)
871
+ # 2. commit it, and make sure main is green
872
+ git tag -a v0.2.0 -m 'v0.2.0'
873
+ git push origin v0.2.0
874
+ ```
875
+
876
+ The job first asserts `v<version>` equals `package.json`'s version and fails immediately on a
877
+ mismatch, then re-runs typecheck/test/build plus the binary smoke on Node 20, `npm pack`s the
878
+ tarball, and creates a GitHub Release with auto-generated notes and the tarball attached. It does
879
+ **not** push to the npm registry: the package name is unclaimed, so that stays a non-goal.
880
+
881
+ ---
882
+
883
+ ## Security notes
884
+
885
+ - Credentials and token live in `~/.pingcode/config.json` (mode `0600`), never in the repository.
886
+ - The `client_secret` travels in the **URL query string** on the token endpoint, so every printable
887
+ URL — `--verbose` logs, dry-run plans, error messages, body snippets — goes through `redactUrl()`.
888
+ `Authorization` headers and `access_token` / `client_secret` JSON values are masked too.
889
+ - `--verbose` prints request URLs with the full `client_id` visible, while `auth status` shows it
890
+ masked (`abcd…wxyz`). That asymmetry is intentional: the `client_id` is an identifier, not a
891
+ secret — only the `client_secret` and the access token are, and both are redacted everywhere.
892
+
893
+ ---
894
+
895
+ ## Known limitations / follow-ups
896
+
897
+ Recorded rather than forgotten. Three items that used to sit here are **done** and have been
898
+ retired: codegen from the apiDoc bundle (there is now a vendored 459-entry catalog, a generator and a
899
+ weekly drift watch), `POST /v1/pjm/work_items/search` (wired and live-verified — six flags switch
900
+ `project work-item list` to it), and bulk `PATCH /v1/pjm/work_items` (`project work-item bulk-update`).
901
+
902
+ - **Ergonomics is 158 of 459, and that is a backlog rather than a bug.** 301 endpoints are reachable
903
+ only through `pingcode api`. The gap is largest in `pjm` configuration (schemes and boards),
904
+ `directory` (departments, groups, roles, jobs) and `wiki` (0 of 19, by decision). Nothing is
905
+ *unreachable* because of it; see [Coverage](#coverage-reach-vs-ergonomics).
906
+ - **7 endpoints cannot be reached at all**: `/v1/myself`, `/v1/permission/my/*` and
907
+ `/v1/permission/check/*` need a *user* token, i.e. the OAuth2 authorization-code flow, which is not
908
+ implemented. They are refused before any request with an explanation. `GET /v1/permission/points`,
909
+ which looks like it belongs to that set, does work.
910
+ - **File attachments cannot be uploaded.** `POST /v1/attachments` has two documented forms: JSON for
911
+ a code snippet and `multipart/form-data` for a real file. Only the snippet form exists, by name
912
+ (`<entity> attachment add-snippet`) and generically (`pingcode api POST /v1/attachments`), because
913
+ a multipart body needs a change to the frozen transport layer that this work was not allowed to
914
+ make. So the *path* is reachable but the *file* form is not expressible in either layer — the one
915
+ place where "459 / 459" is about endpoints rather than about every documented request shape.
916
+ - **Keychain storage.** Credentials sit in a `0600` file; an OS keychain (Keychain Access,
917
+ libsecret, DPAPI) would be stronger.
918
+ - **`--json` drops `null` and `""` fields.** `api/parse.ts` normalises both to `undefined`, so they
919
+ vanish from the output; an absent key currently means "null, empty, or genuinely missing". `null`
920
+ → absent is defensible, `""` → absent is not (an empty string is a value someone chose). The fix
921
+ — preserve both and reserve `undefined` for genuinely missing — is a **breaking output change**
922
+ and wants its own commit before there are consumers. Note this applies to refined commands only:
923
+ `pingcode api` passes the API's JSON through untouched.
924
+ - **`state_flows` pre-validation — tried on ship, and deliberately rolled back.** Reading the state
925
+ flow up front to reject illegal transitions locally sounds better than it is: live evidence
926
+ (`08-01-ship-cli/research/s7-smoke.md` F5) showed the server refuses atomically anyway, so
927
+ nothing is saved, while plan discovery is a scan, depends on an optional scope, and can
928
+ mis-identify the plan — turning a legal move into a terminal local refusal with no override. Ship
929
+ now reads the flows only to *explain* a refusal and to answer `--dry-run`. If pjm ever grows the
930
+ same feature, it should be advisory in the same way.
931
+ - **Self-hosted `--host` verification.** The `<host>/open` derivation is unit-tested only; it has
932
+ never been exercised against a real self-hosted instance.
933
+ - **429 and 403 paths are unit-tested only.** Provoking a real 429 means ~200 requests/minute
934
+ against a production org, and the token used for the live verification was org-admin-scoped, so
935
+ nothing ever denied it with a 403.
936
+ - **Smoke data cannot be cleaned up.** Ship exposes no DELETE at all, and neither do projects,
937
+ sprints or test libraries, so anything created while verifying against a live tenant is permanent.
938
+ Prefix it before you create it.
939
+
940
+ Requirements, design and the live-API findings live under `.trellis/tasks/` — the original MVP in
941
+ `07-31-pingcode-cli-mvp/` (`prd.md`, `design.md`, `research/pingcode-api.md`, `research/s8-smoke.md`)
942
+ and the full-coverage work in `08-02-full-api-coverage/`, whose `research/open-api-surface-460.md` is
943
+ the endpoint-by-endpoint map of all 459 and whose `design.md` records every live finding per module.