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 --access-token xxx # access token, checked before it is storedEither 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) and {"enabled": false} removes it.
# 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":{"manually_close_rule":{"closed":true}}}' # stops it nowshow_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 aB3dE9Filtering, 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--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 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 --disableDeletion 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.
Four 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.pngjinshuju 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 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