@saptools/jira
v0.5.1
Published
Jira Cloud CLI and typed API that reuses the JiraOps OAuth token store
Maintainers
Readme
🧭 @saptools/jira
Jira Cloud CLI and typed API that reuse the same OAuth token store as JiraOps.
Use the JiraOps browser login once, then script Jira reads and focused write actions from the terminal without copying tokens between tools.
Install • Authentication • CLI • Security
✨ Features
- 🔁 Shared JiraOps token — reads and refreshes
~/.jira-oauth/tokens.json, the defaultjira-oauth-clientstore used by JiraOps. - 🎫 Assigned issue list — uses the same assigned-ticket JQL as JiraOps.
- 📖 Issue details — returns summary, status, priority, assignee, ADF description text and raw ADF, paginated comments, attachments, and clone-linked issues.
- 📝 Issue content writes — updates summaries, media-safe descriptions, and ADF comments.
- 🔗 Remote links — lists Jira remote links such as GitLab MRs, runbooks, or dashboard URLs.
- 🔄 Transitions — lists available status transitions and applies a selected transition ID.
- 👤 Safe assignment — assigns one issue only after resolving exactly one active issue-assignable Jira account.
- ⏱️ Worklogs — adds focused time entries with optional ADF text comments and records successful writes in local history.
- 🧭 Custom fields — discovers Jira Cloud custom fields, pins useful display names, and updates editable pinned fields without hard-coded site IDs.
- 🧩 Typed API — every CLI workflow is available as a TypeScript function.
- 🧪 Fake-backed E2E — test coverage validates the real built CLI without calling Atlassian.
📦 Install
npm install -g @saptools/jira
# Or as a project dependency
npm install @saptools/jira
# pnpm add @saptools/jira[!NOTE] Requires Node.js ≥ 20 and a Jira Cloud account. This package targets Atlassian Cloud's
api.atlassian.com/ex/jiraAPI, not Jira Data Center.
🔐 Authentication
@saptools/jira intentionally uses the same token store as JiraOps and jira-oauth-client:
~/.jira-oauth/tokens.jsonIf JiraOps already connected successfully, this CLI can read the same stored access token immediately:
jira status
jira issuesWhen the token expires, refresh and connect flows need the Atlassian OAuth app credentials in the CLI environment:
export JIRA_CLIENT_ID="your-atlassian-oauth-client-id"
export JIRA_CLIENT_SECRET="your-atlassian-oauth-client-secret"
jira connectTo remove the local shared token file:
jira logoutYour Atlassian OAuth app must allow the jira-oauth-client callback URL:
http://localhost:30129/callbackUse a custom token file only when you deliberately do not want to share the JiraOps token:
jira --token-store ./tmp/jira-tokens.json status🧰 CLI
jira status
Show whether a shared Jira token is present and still usable.
jira status
jira status --jsonjira connect
Run the browser OAuth flow and write tokens to the shared token store.
jira connect
jira connect --jsonjira disconnect
Delete the shared token file.
jira disconnectjira logout
Delete the shared token file. This is equivalent to jira disconnect.
jira logoutjira token
Print the current access token for scripts.
jira token[!IMPORTANT]
jira tokenprints a live bearer token. Do not paste it into tickets, logs, commits, shell history captures, or screenshots.
jira issues
List assigned, not-done issues ordered by update time.
jira issues
jira issues --max 10
jira issues --jsonjira issue <key>
Read one issue's detail payload.
jira issue OPS-123
jira issue OPS-123 --json
jira issue OPS-123 --no-imagesInline Jira images in the description or comments are saved to the OS temp directory by default. Downloaded local image metadata is returned only in the top-level images[] array as fileUrl/filePath entries plus attachment metadata; join images to attachments[] with image.attachmentId === attachment.id. Use --image-dir <path>, --max-image-bytes <number>, or --max-images <number> to control local image capture.
JSON issue details include both descriptionText and descriptionAdf. descriptionText is a flattened convenience string. descriptionAdf is the raw ADF document when Jira returns a valid document, or null when the issue has no valid description ADF.
jira describe <key>
Print or update one issue's description as raw ADF.
Read the current raw ADF without updating Jira:
jira describe OPS-123 --print > description.adf.json
jira describe OPS-123 --print --jsonDefault --print output is deliberately raw pretty-printed ADF JSON even without --json, so shell redirection creates a valid --adf-file artifact. --json wraps the document as { "issueKey": "OPS-123", "description": <ADF|null> }. If the issue has no description, default --print exits non-zero instead of writing an invalid empty file; use --json when callers need to handle null.
Update mode still requires exactly one body source:
jira describe OPS-123 --text "Plain text description"
jira describe OPS-123 --text-file ./description.txt
jira describe OPS-123 --adf-file ./description.adf.json
jira describe OPS-123 --text "Follow-up notes" --append
jira describe OPS-123 --text "Replace anyway" --force
jira describe OPS-123 --adf-file ./description.adf.json --jsonPlain text is converted to ADF paragraphs. Blank lines create separate paragraphs; single newlines inside a paragraph become ADF hardBreak nodes. --adf-file reads a complete raw ADF JSON document and sends it after validation.
Description replacement is safe by default. If the current description contains ADF media nodes, plain-text replacement is refused unless --force is passed. Use --append to preserve the current ADF content and append new paragraphs. Use --adf-file when a caller needs to provide a full document that already includes media nodes.
To edit a complex description with images, fetch the current ADF, change only the relevant text node, and push the complete document back:
jira describe OPS-123 --print > description.adf.json
# Edit one {"type":"text","text":"..."} node and leave media/mediaSingle nodes untouched.
jira describe OPS-123 --adf-file description.adf.jsonThis preserves embedded images because existing media.attrs.id values are carried through unchanged; no media upload or regeneration is needed. Text-only flows (--text and --text-file) cannot preserve media because flattened text does not contain the media nodes.
The read-edit-write flow is not transactional. If the description changes in Jira between --print and --adf-file, the later write overwrites the current server description.
Native local-image inline embedding is not implemented. Jira's attachment upload API returns attachment metadata, but native ADF media file nodes require a Media Services ID that the public attachment endpoint does not return reliably. Use raw ADF input for image-preserving or image-bearing descriptions.
Use --no-notify-users to send notifyUsers=false on the Jira update. By default, the CLI leaves Jira's notification behavior unchanged.
JSON output:
{
"issueKey": "OPS-123",
"updated": ["description"]
}jira summary <key> <summary>
Update one issue's summary after verifying the field is editable on that issue.
jira summary OPS-123 "New issue title"
jira summary OPS-123 "New issue title" --json
jira summary OPS-123 "New issue title" --no-notify-usersJSON output:
{
"issueKey": "OPS-123",
"updated": ["summary"]
}jira comment <key>
Add a comment to an issue. Exactly one body source is required:
jira comment OPS-123 --text "Reviewed the rollout logs."
jira comment OPS-123 --text-file ./comment.txt
jira comment OPS-123 --adf-file ./comment.adf.json
jira comment OPS-123 --text "Reviewed the rollout logs." --jsonPlain text is converted to ADF the same way as descriptions. --adf-file is available for callers that need to supply a complete rich ADF comment body.
JSON output:
{
"issueKey": "OPS-123",
"commentId": "40001"
}jira links <key>
List remote links attached to an issue.
jira links OPS-123
jira links OPS-123 --jsonjira transitions <key>
List available status transitions.
jira transitions OPS-123
jira transitions OPS-123 --jsonjira transition <key> --id <id>
Apply a transition by ID.
jira transition OPS-123 --id 31jira assign <key>
Assign one Jira issue after deterministic assignee resolution:
jira assign OPS-123 --me
jira assign OPS-123 --to "Example User"
jira assign OPS-123 --account-id "account-id-from-ambiguity"
jira assign OPS-123 --to "Example User" --jsonExactly one selector is required: --me, --to <name-or-query>, or --account-id <account-id>. The CLI rejects missing, combined, or blank selectors before calling Jira.
--mefetchesGET /rest/api/3/myself, then verifies that active account through the issue-scoped assignable-user search before writing.--tois an approximate Jira display-name query. The CLI searches only users assignable to the target issue withissueKey=<KEY>,query=<name-or-query>, andmaxResults=1000.--account-idis the deterministic retry path. It still verifies the account through the same issue-scoped assignable-user endpoint withaccountId=<account-id>before assignment.
Jira can return broad name matches. The CLI never auto-selects among multiple unresolved candidates. A unique normalized exact display-name match wins over weaker fuzzy matches, and a single fuzzy candidate is accepted only when no exact full-name match exists. Multiple exact display-name matches or multiple fuzzy candidates are ambiguous and no Jira mutation occurs.
Human ambiguity output lists the candidate display names and account IDs and recommends retrying with --account-id:
Multiple active assignable Jira users match "Example"; no assignment was changed.
2 candidates:
Example One account-id-1
Example Two account-id-2
Retry with: jira assign OPS-123 --account-id <account-id>JSON ambiguity is written to stderr with a non-zero exit status:
{
"error": "ambiguous_assignee",
"issueKey": "OPS-123",
"query": "Example",
"message": "Multiple active assignable Jira users matched; no assignment was changed.",
"candidates": [
{ "accountId": "account-id-1", "displayName": "Example One" },
{ "accountId": "account-id-2", "displayName": "Example Two" }
]
}Successful JSON output has no hint footer:
{
"issueKey": "OPS-123",
"assignee": { "accountId": "account-id-1", "displayName": "Example One" },
"resolution": "exact"
}Assignment requires Jira Browse Projects and Assign Issues permissions, any applicable issue-security access, and OAuth scopes that allow user lookup and assignment (read:jira-user and write:jira-work for classic scopes). Jira user search operations are documented around a first-1,000-user search window, so the CLI requests maxResults=1000; zero results mean only that no active assignable candidate was returned for that issue and query.
jira fields
Discover, cache, pin, and update site-specific Jira custom fields by display name. Field IDs such as customfield_10101 are Jira-site-specific, so agents should discover and pin names for each connected cloud instead of hard-coding IDs.
jira fields discover
jira fields discover --search "custom text"
jira fields search "custom text"
jira fields pin "Custom text A"
jira fields pin "Custom text B"
jira fields pinned
jira fields unpin "Custom text A"
jira fields update OPS-123 --field 'Custom text A=analysis notes'
jira fields update OPS-123 --field 'Custom text A=analysis notes' --field-file 'Custom text B=./review.md'jira fields discover always refreshes from Jira Cloud and has no --refresh flag. jira fields discover --search <query> still fetches and saves the complete refreshed snapshot; the search only filters the terminal output so agents can inspect candidates immediately. jira fields search <query> searches the cached snapshot without calling Jira and fails clearly if discovery has not run.
Local custom field metadata is stored under the current user's home directory with Node path handling:
~/.saptools/jira/clouds/<cloudId>/fields.json
~/.saptools/jira/clouds/<cloudId>/pinned-fields.jsonThe cache stores normalized field metadata only. It never stores access tokens, refresh tokens, Authorization headers, OAuth client secrets, request headers, field values, or raw Jira responses. Pinned fields are cloud/site-specific and persist the resolved Jira field ID internally, but normal pin, unpin, update, and footer workflows use Jira display names only; aliases are not generated or accepted.
jira fields update <KEY> resolves names against pinned-fields.json, fetches editmeta for that issue, verifies every target field is editable before writing, and then sends a Jira issue field update. Textarea custom fields are sent as Atlassian Document Format; single-line text fields are sent as strings. Success output lists only display names and does not echo field values.
After fields are pinned, normal human output includes a display-name-only footer such as:
Updatable custom fields: Custom text A, Custom text B. Use: jira fields update <KEY> --field 'FIELD NAME=value'The footer never includes customfield_* IDs, custom numeric IDs, schema details, aliases, or values. It is never appended to --json, jira token, help, or version output; use global --no-hints to suppress it in human output.
jira worklog <key>
Add a worklog entry.
jira worklog OPS-123 --minutes 30
jira worklog OPS-123 --minutes 30 --comment "Reviewed rollout logs"
jira worklog OPS-123 --minutes 30 --started "2026-05-01T08:20:00.000+0000"Successful worklog writes are also appended to a local, human-readable Markdown history file under:
~/.saptools/jira/worklog-history/YYYYMM.mdThe monthly file is chosen from the worklog started timestamp, so logging time today for a previous month updates that previous month file. If local history cannot be written after Jira accepts the worklog, the CLI prints a warning and does not retry or undo the Jira write. The history stores only the logged-at timestamp, started timestamp, issue key, minutes, hours, and sanitized comment text; it never stores OAuth tokens, refresh tokens, client secrets, Authorization headers, request headers, or raw Jira responses.
jira worklogs
Summarize local worklog history without calling Jira, reading tokens, or requiring a network connection. Missing history files produce zero totals.
jira worklogs --day 2026-05-01
jira worklogs --day 2026-05-01 --json
jira worklogs --issue OPS-123 --month 202605 --json
jira worklogs --issue OPS-123 --from 2026-05-01 --to 2026-05-31
jira worklogs --month 202605 --group-by day
jira worklogs --month 202605 --group-by issueHuman output includes total minutes/hours and grouped totals. --json returns the parsed local entries plus structured totals for agents and scripts.
Test API root
For deterministic integration tests, point the CLI at a fake Atlassian-compatible API root:
jira --api-root http://127.0.0.1:4010/ex/jira issues --json🧪 Development
pnpm install
pnpm --filter @saptools/jira build
pnpm --filter @saptools/jira lint
pnpm --filter @saptools/jira typecheck
pnpm --filter @saptools/jira cspell
pnpm --filter @saptools/jira test:unit
pnpm --filter @saptools/jira test:e2eE2E tests pre-seed a temp HOME/.jira-oauth/tokens.json and run the built dist/cli.js against a fake Jira HTTP server.
🔒 Security
- OAuth app credentials come from
JIRA_CLIENT_ID,JIRA_CLIENT_SECRET, or explicit flags. - Access and refresh tokens are stored only in the shared token file, with owner-only permissions when this package writes it.
- Jira HTTP errors are reported as neutral messages and do not include response bodies.
- Custom field snapshots and pinned-field configs under
~/.saptools/jira/clouds/<cloudId>/store only normalized metadata, never credentials, Authorization headers, raw Jira responses, or field values. - Do not commit
~/.jira-oauth/tokens.json, custom token stores, access tokens, refresh tokens, or Authorization headers.
👨💻 Author
dongtran ✨
📄 License
MIT
Made with ❤️ to make your work life easier!
