Command-line interface for the Jinshuju Open API v1.
npm install -g @jinshuju/cliTwo equivalent entry points are installed:
jinshuju --help
jsj --helpnpm install
npm link # jinshuju / jsj now resolve to your checkoutIf npm link leaves you with permission denied, dist/cli-bin.js has lost
its execute bit — npm run build restores it.
Two kinds of credential are supported: an interactive browser login (OAuth),
and an access token (personal or account). Both go in through auth login:
jinshuju auth login # browser login
jinshuju auth login --device # approve a short code on any other device
jinshuju auth login --access-token xxx # access token, checked before it is storedOn a machine without a browser — a server over SSH, a container, an agent's
sandbox — --device prints a link and a short code. Open the link on any
device, a phone will do, check the code matches and approve; the CLI is
polling and finishes on its own. Nothing has to reach back into the machine.
Over SSH, or on Linux without a display, auth login does this by itself.
Someone without an account can sign up on that page and come back to approve.
An agent whose commands are cut off after a minute or two can split it in two:
jinshuju auth login --no-wait --output json # prints verification_uri_complete and user_code, then exits
jinshuju auth login --resume --timeout 60 # waits up to 60s; exit code 9 means still pendingEither one is written to the config file (~/.jinshuju/config.json, mode 600)
and replaces whatever credential was stored before. jinshuju auth logout
forgets it (revoking it first, for a browser session).
For CI and scripts, an environment variable works without touching the file:
export JINSHUJU_ACCESS_TOKEN=xxxThe precedence is JINSHUJU_ACCESS_TOKEN, then the stored credential. A
token set for this run always beats a stored login: acting as last week's
login instead is a surprise nobody wants. jinshuju auth status reports which
credential is in use and where it came from:
$ jinshuju auth status
Authenticated with an access token (from env).
config set holds only host, auth_host and client_id; credentials are
not config.
Field types use the API v1 type names. Do not supply api_code when creating a
field — the server assigns it.
jinshuju form create --json @form.json --scene registry --layout card --folder Fd2xK8
jinshuju form create --json @exam.json --type exam # the payload may carry exam_setting
jinshuju form edit Kp7mQ2 --json '{"exam_setting":{"limited_time":45}}'
jinshuju form create --json '{
"name": "Event signup",
"fields": [
{ "type": "TextField", "label": "Name", "required": true },
{ "type": "MobileField", "label": "Mobile", "required": true }
]
}'--type exam and --type evaluation select both the scene and the settings
block that belongs to it. That settings block has an endpoint of its own, so a
payload carrying it becomes two requests — and the general form update does not
recognise the key at all (it reads only name, description, setting,
fields and field_rules), so without the dispatch exam_setting would be
dropped in silence.
The settings block is always sent first. It is the half that can be rejected — "this is not an exam form" — and sending it first is what guarantees a rejection leaves every other change unsent.
setting travels as is, on create and on edit. Its close rules decide when the
form is open; they coexist, and form get reads them back in the shape they
were written. On edit a rule replaces what was there (time_periods is the
whole list). A time-range, daily or entries rule is removed by
{"enabled": false} or by null. manually_close_rule is a switch rather
than a rule: {"closed": false} reopens the form, and changing closed either
way deletes the other three rules.
# between two instants; either end may be left out
jinshuju form edit Kp7mQ2 --json '{"setting":{"by_time_range_close_rule":{"start_time":"2026-10-01T09:00:00+08:00","end_time":"2026-10-07T18:00:00+08:00"}}}'
# 11:00-12:40 and 17:00-18:30, every day
jinshuju form edit Kp7mQ2 --json '{"setting":{"daily_repeat_close_rule":{"time_periods":[
{"start_time":{"hour":11,"minute":0},"end_time":{"hour":12,"minute":40}},
{"start_time":{"hour":17,"minute":0},"end_time":{"hour":18,"minute":30}}]}}}'
jinshuju form edit Kp7mQ2 --json '{"setting":{"daily_repeat_close_rule":{"enabled":false}}}'
jinshuju form edit Kp7mQ2 --json '{"setting":{"by_entries_close_rule":{"limit":100}}}' # closes after 100 entries
jinshuju form edit Kp7mQ2 --json '{"setting":{"by_entries_close_rule":null}}' # takes the limit off
jinshuju form edit Kp7mQ2 --json '{"setting":{"manually_close_rule":{"closed":true}}}' # stops it now
jinshuju form edit Kp7mQ2 --json '{"setting":{"manually_close_rule":{"closed":false}}}' # reopens itshow_form_before_open and show_close_count_down only hold with a time-range
or daily rule; without one the save resets them to false.
entry, view, field and comment are all top-level resources; the
container is given by --form or --table (mutually exclusive, both mapping to
the API's form_token).
jinshuju entry list --form Kp7mQ2
jinshuju entry list --table Vn4xR8
jinshuju entry get 1 --form Kp7mQ2
jinshuju entry list --form Kp7mQ2 --view aB3dE9
jinshuju folder get Fd2xK8 # the folder a form or table's folder_token namesFiltering, sorting and paging:
jinshuju entry list --form Kp7mQ2 --filter 'field_3 gte 80'
jinshuju entry list --form Kp7mQ2 --filter 'created_at within_last 30d' --filter 'field_9 not_null'
jinshuju entry list --form Kp7mQ2 --sort created_at:desc
jinshuju entry list --form Kp7mQ2 --limit 10
jinshuju entry list --form Kp7mQ2 --all--limit can only ask for less: a listing's default page size is also its
maximum (50 in most cases), and anything larger is capped.
--all reads at most 200 pages. Past that it stops with an error naming the
cursor; pass it to --next (with --all again) to read on.
--filter is repeatable and the conditions are AND-combined. For conditions it
cannot express, use --filters <json|@file>. Cursors are opaque strings — pass
the next value from the previous response back verbatim.
There is no need to pull rows out and count them yourself. Counts, aggregates and profiles are computed server-side, and the size of the response depends only on how many metrics you asked for and how many groups came back.
jinshuju entry count --form Kp7mQ2 --filter 'field_3 gte 80'
jinshuju entry count --form Kp7mQ2 --form Vn4xR8 # repeatable, up to 10 containers
jinshuju entry aggregate --form Kp7mQ2 --metric avg:field_3
jinshuju entry aggregate --form Kp7mQ2 --metric count:field_1 --by created_at:month --limit 12
jinshuju entry summary --form Kp7mQ2
jinshuju entry summary --form Kp7mQ2 --fields field_3,field_7 --no-overview
jinshuju entry count --form Kp7mQ2 --view aB3dE9 --filter 'field_3 gte 80' # only what the view shows--metric <func>:<field> is repeatable, 1–20 of them; --by <field>[:day|week|month]
takes at most 2, and a date dimension must name a bucket. Which functions a
field accepts is the field's own answer — read analytics.agg_funcs from
form get.
A multi-container count takes no --keyword, and its --filter is limited to
created_at, updated_at and creator_id: the same api_code names a different
field in every container, so comparing across them would mean nothing.
Payload keys are field api_codes, not field labels. --json accepts inline
JSON, @file, and - for stdin.
jinshuju entry create --form Kp7mQ2 --json '{
"field_1": "Alice",
"field_2": "13800138000"
}'
jinshuju entry create --form Kp7mQ2 --json @entry.json
cat entry.json | jinshuju entry create --form Kp7mQ2 --json -Creating, updating or getting one entry answers with a url: where that entry
opens in the Jinshuju data page, for whoever wrote it to check it there. A
--batch write answers without one.
Apart from the settings-block dispatch described above, every write command
issues exactly one request, and payload keys are field api_codes.
jinshuju folder create Ledgers --kind table # folders are form or table; a table cannot go in a form folder
jinshuju form edit Kp7mQ2 --json '{"name":"2026 signups"}'
jinshuju form copy Kp7mQ2 --name Copy
jinshuju form copy Kp7mQ2 --name 第二期 --with-notification-rules
jinshuju form move Kp7mQ2 --folder Fd2xK8 # without --folder, moves it out of its folder
jinshuju form theme set Kp7mQ2 --primary-color "#1F6FEB"
jinshuju table create --json @table.json --folder Nf7mDC
jinshuju table edit Vn4xR8 --json '{"name":"2026 ledger"}'Adding, changing and removing fields all land on a single PATCH of the container:
jinshuju field add --form Kp7mQ2 --json '{"type":"TextField","label":"Notes"}'
jinshuju field update --form Kp7mQ2 field_3 --json '{"required":true}'
jinshuju field update-choices --form Kp7mQ2 field_7 --json '{"add":[{"label":"Third"}]}'
jinshuju field remove --form Kp7mQ2 field_9 --yesData and views:
jinshuju entry create --form Kp7mQ2 --batch @entries.json
jinshuju entry update --form Kp7mQ2 12 --json '{"field_2":99}' # merge
jinshuju entry update --form Kp7mQ2 12 --replace --json '{"field_1":"Bob"}' # replace; omitted fields are cleared
jinshuju entry update --form Kp7mQ2 --batch @rows.json # [{serial_number, entry}]
jinshuju entry delete --form Kp7mQ2 12 --yes
jinshuju view create --form Kp7mQ2 "High scores" --filter 'field_3 gte 80' --sort created_at:desc
jinshuju comment create --form Kp7mQ2 --entry 12 "Contacted, awaiting reply"
jinshuju opensearch edit Qy7nR3 --disable
jinshuju checkin create --form Kp7mQ2 # needs the check-in plugin enabled for the account
jinshuju checkin edit 42 --disable
jinshuju notification create --form Kp7mQ2 --approach SMS --recipients field_2 --template-id 6650a1 --placeholder field_1
jinshuju notification list --form Kp7mQ2 # each rule says whether it can send, and why not
jinshuju notification log 6650b2 --form Kp7mQ2 --from 2026-10-01 # what the rule sent: how many succeeded, failed, and whyDeletion always requires --yes. This CLI is non-interactive — stdin belongs to
--json - — so confirmation is a flag: without it nothing is deleted, rather
than a prompt nobody is there to answer.
jinshuju entry search "Acme Corp" # every form and table you can read, up to 10
jinshuju entry search 13800138000 --form Kp7mQ2 --form Vn4xR8
jinshuju entry search repair --scope-filter 'entries_count gt 100' # picks which forms to search, not which rows
jinshuju entry stats --from 2026-09-01 # how much each form received in the period
jinshuju entry stats --from 2026-09-01 --to 2026-09-07 --kind form --limit 10
jinshuju entry stats --from 2026-09-28 --by day # the same, split into each day, in one call
jinshuju entry stats --from 2026-01-01 --by month # or by week / month: at most 92 days, 53 weeks, 24 monthsA form that could not be searched stays in the result with the reason, rather than being dropped as if it had matched nothing — "not searched" and "nothing there" are different answers.
entry stats and entry count measure different things: the former is how much
arrived (an import counts on the day it ran, and deletions are not subtracted),
the latter is how much is there now.
jinshuju form list --mine # forms you filled in, not forms you own
jinshuju entry list --form Kp7mQ2 --mine # what you submitted to this form
jinshuju entry search "Acme Corp" --mine # search your own submissionsThe scope is pinned to your own submissions: you cannot read anyone else's, and
you need no permission on the form itself. Flags that only make sense from an
owner's point of view (--sort, --view, --scope-filter and the like) are
rejected when combined with --mine.
A fill link — …/f/<token> for a form, …/v/<token> for a table's form view —
can be filled from the CLI the way anyone with the link fills it in a browser:
jinshuju fill get https://acme.jinshuju.net/v/aB3dE9 # what it asks for, and whether you can submit
jinshuju fill submit https://acme.jinshuju.net/v/aB3dE9 --json '{"field_1":"Ada"}'No role on the form is needed, and only what the page shows its fillers comes
back. The entry is recorded as yours. A page that asks for something only the
person at it can do — a captcha, an SMS code, WeChat, a payment — lists it under
blockers, and is refused until it is filled in a browser. An OAuth session
needs the submit_entries scope for these.
A routine is an instruction Jiri carries out on a schedule, or when the data of a form or table changes. One created here is active at once, the same as one created on the 自动任务 page, and is held to that page's rules and words.
jinshuju routine create --name 每周报名汇总 --description 每周一汇总上周报名,按渠道统计 \
--instruction '汇总上周报名表新增的数据' --object form:Kp7mQ2 --schedule 'weekly 1 09:00'
jinshuju routine create --json @routine.json # an event trigger and its conditions
jinshuju routine list --state active
jinshuju routine get 6ac5f0e2b1d4c3a2f1e0d9c8 # trigger_text, next_runs, objects, url
jinshuju routine update 6ac5f0e2b1d4c3a2f1e0d9c8 --schedule 'workdays 08:30'
jinshuju routine pause 6ac5f0e2b1d4c3a2f1e0d9c8
jinshuju routine resume 6ac5f0e2b1d4c3a2f1e0d9c8
jinshuju routine run 6ac5f0e2b1d4c3a2f1e0d9c8 # once, now
jinshuju routine runs 6ac5f0e2b1d4c3a2f1e0d9c8
jinshuju routine delete 6ac5f0e2b1d4c3a2f1e0d9c8 --yes--schedule is Beijing time and covers the six frequencies: once 2026-10-12 09:00, hourly 30, daily 09:00, workdays 09:00, weekly 1,5 09:00 (1 is
Monday) and monthly 1,-1 09:00 (-1 is the last day). --object kind:token
names a form, table, view or public query, and given, it is the whole list.
--description is the one sentence the routines list shows people, up to 100
characters; the instruction is written for Jiri.
Flags lay over --json; routine create --help shows the full payload, events
and conditions included. A routine against the page's rules exits 5 with every
reason the page gives.
Letting a routine delete data (delete_authorized) is turned on on the
自动任务 page only. There is no flag for it, and a payload naming it, true or
false, is refused before anything is sent — routine get answers with it, so
take it out before sending a routine read back. A routine's own run cannot
create or change routines; it may read them.
Routines belong to a person, so an account access token cannot manage them. A
browser login asks for the routines OAuth scope; a login made with an older
version has no such scope, and the routine commands answer it with 403 (exit
3). Run jinshuju auth login again to get a token that has it.
Seven commands take a file. The CLI uploads with its own credential; there is no ticket to fetch first.
jinshuju entry import --form Kp7mQ2 ./signups.xlsx --map field_1=Name --map field_2=Mobile
jinshuju entry import --table Vn4xR8 ./rows.csv --map field_1=1 --map field_2=2 --header-row 2 --unique field_1
jinshuju entry create --form Kp7mQ2 --json '{"field_1":"Alice"}' --attach field_5=./id-card.jpg
jinshuju form theme set Kp7mQ2 --wallpaper ./bg.png
jinshuju opensearch edit Qy7nR3 --header ./banner.png
jinshuju form image upload ./venue-map.png # answers with a url for an <img> in a form or field description
jinshuju sms signature create 小金培训 --license ./license.jpg --company-name 小金培训有限公司 --company-id 91110000MA00000000 \
--id-card-front ./front.jpg --id-card-back ./back.jpg --id-card-name 张三 --id-card-number 110101199001011234 --mobile 13800000000
jinshuju sms signature update 6512a1c2d3e4f5a6b7c8d9d3 --license ./license-clear.jpgjinshuju entry import --form Kp7mQ2 ./signups.xlsx --map field_1=Name --wait # wait for it; non-zero exit on failure
jinshuju entry import-status --form Kp7mQ2 <job-id> # or look it up later--map takes a field api_code on the left and a column name or column
number on the right (a bare number is read as a position). Everything knowable
up front is checked before the import starts — the file, the size your plan
allows, the header row, the column mapping — so a rejected import has written
nothing, and the error names the sheet's actual layout. Once accepted, the rows
are written in the background: the command returning means started, not
finished.
That is what --wait is for. It waits for the job to settle, reports how many
rows were written, skipped and rejected, and exits non-zero on failure.
It gives up after thirty minutes, naming the job so it can still be looked up.
Without it a failed import is invisible — the command succeeds and not a single
row is written. After the fact, entry import-status answers the same question
from a job id.
--output jsonl answers one JSON object per line: a listing becomes its rows,
anything else is one line. With --all the rows are written as each page
arrives and nothing is held back, so a listing of any size streams through
jq -c, a shell loop or a log shipper without first fitting in memory.
jinshuju entry list --form Kp7mQ2 --all --output jsonl | jq -c 'select(.field_3 > 80)'
jinshuju entry list --form Kp7mQ2 --all --output jsonl | while read -r row; do …; doneErrors are JSON on stderr under jsonl exactly as under json.
Long-running commands (--all paging, uploads, --wait) report progress.
Progress is written to stderr, and only when stderr is a terminal, so
--output json | jq receives exactly the same bytes it would without it: a pipe,
or an agent on the other end, never sees a stray character.
The exit code says what kind of failure it was, so a script can branch without parsing anything:
| code | meaning |
|---|---|
| 0 | done |
| 1 | something unexpected; please report it |
| 2 | usage: a flag, argument, input file or unknown command |
| 3 | authentication: no credential, or one the server refused (401, 403) |
| 4 | not found (404) |
| 5 | refused by the API: validation, conflict, quota (other 4xx) |
| 6 | the server failed (5xx) |
| 7 | transport: the connection failed or timed out |
| 8 | rate limited: wait and retry; the message and retry_after say how long |
With --output json, stderr carries the same as JSON, plus the status and body
the server answered with:
{
"error": {
"kind": "not_found",
"message": "form cannot be found",
"status": 404,
"body": { "error_description": "form cannot be found" }
}
}Every request has a deadline of one minute; JINSHUJU_TIMEOUT_MS changes it. A
request the server answers with 429 or 503 is tried again after backing off,
honouring Retry-After when it is sent. A read is also retried after a gateway
failure or a dropped connection. A write is not: a POST that timed out may well
have landed, and sending it again would create the entry twice.
jinshuju field check --form Kp7mQ2 field_3 field_7:choice_1
jinshuju field preview-convert --form Kp7mQ2 field_1 --to RadioButtonfield check answers whether a field or a choice has data under it — removing
it takes that data along, so it is worth asking first. preview-convert reports
how much a type conversion would keep and how much it would clear.
jinshuju field filterable --form Kp7mQ2
jinshuju field filterable --form Kp7mQ2 --for routine field_5 --output jsonA routine's or a notification rule's scope_conditions test fields, and a
condition's value is not the value the field takes in an entry: a goods field
is tested with [{"api_code": …}], a choice by its code. field filterable
lists the fields a condition can test, the operators each takes, and for each
operator the value it takes, with examples written in the field's own codes;
--for routine narrows it to the fields a routine's data trigger can test.
Copy an example's shape. Creating or updating a routine with conditions
answers with preview_condition_matches: which of the latest entries meet each
group, the quickest way to see that a condition means what it was meant to.
An SMS notification needs a signature and a template, and both wait on a
review, so submit them as early as you can. The materials a signature is
applied with replace your ID card information, and the account's business
license unless the account has passed license verification. A template can go in while its
signature is still under review; sms status follows both, with the reason
when one is rejected.
sms signature list shows every signature of the account and sms template list
your templates with the ones the account shares; both carry the ids the updates
and deletes take. A rejected signature, and a template of yours that is not under
review, can be changed and sent back to review; only the flags given change. The
same ones can be deleted with --yes; deleting a signature takes its templates
with it.
While your own are under review, sms template list --scope official lists the official
templates, which are approved already and can be sent right away.
A template's variables are written $(var_1), $(var_2) and so on, each once,
and each is declared with a --variable <label>:<type>:<example>;
the reviewer sees the examples, which take 6 characters or more. A notification or sms send --placeholder then
fills them from form fields in the order the content uses them.
jinshuju sms signature create 小金培训 --license ./license.jpg --company-name 小金培训有限公司 --company-id 91110000MA00000000 \
--id-card-front ./front.jpg --id-card-back ./back.jpg --id-card-name 张三 --id-card-number 110101199001011234 --mobile 13800000000
jinshuju sms template create --signature 小金培训 --content "你报名的课程明天上午 9 点开课,请准时参加。"
jinshuju sms template create --signature 小金培训 --content '$(var_1),你好,套餐 $(var_2) 到期。' \
--variable 姓名:name:姓名,示例:张三 --variable 到期日期:time:2026-10-25
jinshuju sms status
jinshuju sms signature list --status FAIL
jinshuju sms template list --keyword 开课 --scope owned
jinshuju sms template list --scope official
jinshuju sms signature update 6512a1c2d3e4f5a6b7c8d9d3 --license ./license-clear.jpg
jinshuju sms template update 6512a1c2d3e4f5a6b7c8d9e0 --content "你报名的课程后天上午 9 点开课。"
jinshuju sms template delete 6512a1c2d3e4f5a6b7c8d9e0 --yessms send and email send pick the entries of a form the way entry count
narrows them, by --filter, --keyword or --view, or the whole form with
--select-all, and send to the field --field names. Sending uses quota and
cannot be recalled, so each takes --estimate or --yes: --estimate answers
how many entries and recipients it reaches, how many are missing a number or an
address, what it bills and whether the quota covers it, and sends nothing;
--yes sends. The entries are picked again when it sends.
An SMS goes out from a template, by the id sms template list gives; variables
are filled from the fields --placeholder names, one per variable in order.
--send-at schedules it and --manual holds it for sms task send. A task whose
template failed review can be changed with sms task change, and a sent one
sent again with sms task resend to the entries that got a result.
jinshuju sms send --form Kp7mQ2 --field field_2 --template-id 6512a1c2d3e4f5a6b7c8d9e0 --filter 'field_5 eq 已报名' --estimate
jinshuju sms send --form Kp7mQ2 --field field_2 --template-id 6512a1c2d3e4f5a6b7c8d9e0 --filter 'field_5 eq 已报名' --yes
jinshuju sms task list --form Kp7mQ2 --from 2026-10-01 # each task with how many numbers succeeded or failed
jinshuju sms task activities 6716f2a8c1d2e3f4a5b6c7d9 --form Kp7mQ2 --status FAILED
jinshuju sms task resend 6716f2a8c1d2e3f4a5b6c7d9 --form Kp7mQ2 --status FAILED --yes
jinshuju email smtp-account list
jinshuju email send --form Kp7mQ2 --field field_3 --select-all --subject 上课提醒 --content '<p>明天上午 9 点开课</p>' --yesAn artifact is a file Jiri made for you; any of your conversations can revise
it. artifact list lists the ones Jiri made for you in the account you are in,
newest first, one row per artifact however many versions it has. Each row
carries conversation_url, the Jiri conversation that delivered its latest
version, and audience, who its link is open to (empty when it was never
shared). In --output json the first is latest_version.conversation_url,
beside created_conversation_url (where the artifact was made) and
latest_version.download_url; the second is
published_artifact.allowed_audience, beside the version the link shows
(published_version). The link itself comes from artifact share get.
--name keeps the artifacts whose title
contains the keyword, case-insensitive; --limit, --next and --all page as
other listings do.
An HTML version can be shared by a link, and artifact share set says who can
open it: private (only you, the default), internal (members of your
account) or public (anyone with the link). A plan without share links is
refused with 402 by artifact share get and by sharing beyond private;
internal needs a plan with members-only sharing, and artifact share get
says whether the plan has it. The first get or set creates the link.
artifact share get and --audience private take the id of an
artifact list row. --audience internal and public
bind the link to a version, so <artifact> names one: the row's
latest_version.id (version_id in text), a version token, or the address of
the page that shows the version (https://…/artifacts/<token>). The artifact
id is refused there, since its latest version can change before the share is
made. To share another version of the same artifact, name that one.
--audience private keeps the version the link shows, whichever version
<artifact> names.
An OAuth session needs the artifacts scope, which jinshuju auth login
asks for; a session from an older CLI has to log in again. An account access
token is refused with 400: an artifact belongs to a person.
jinshuju artifact list --name 周报
jinshuju artifact share get https://next.jinshuju.net/artifacts/Xy7Qm2PaLk9W
jinshuju artifact share set Xy7Qm2PaLk9W --audience public
jinshuju artifact share set Pq4nR8sT2vWx --audience internal # now the link shows this version
jinshuju artifact share set Xy7Qm2PaLk9W --audience private # the link still shows Pq4nR8sT2vWxjinshuju form list --name signup --name survey # several keywords match any, not one joined phrase
jinshuju entry list --form Kp7mQ2 --labels # carry the field label with each value, saving a form read
jinshuju table move Vn4xR8 --folder Nf7mDC # a table only goes into a kind=table folder
jinshuju table create --json @t.json --with-default-entries # seed a few blank rows, as the web UI doesForm, table and view tokens are six characters of mixed-case letters and
digits, for example Kp7mQ2, Vn4xR8, aB3dE9. Examples here and in
--help use that shape throughout.
How the code is organised, and why, is in ARCHITECTURE.md.
npm install
npm test
npm run check # typecheck + oxlint + oxfmt --check, what CI runs
npm run fmt # format in place with oxfmt
npm run generate # src/generated from spec/openapi.yaml
npm run spec:update # fetch the published openapi.yaml, then generateWhat a request may carry — paths, parameters, the values a flag takes — follows
spec/openapi.yaml, a copy of the published API v1 spec. A daily workflow
fetches it and opens a pull request when it changes.