@saptools/jira
v0.10.0
Published
Jira Cloud CLI and typed API that authenticate with an Atlassian API token or the shared JiraOps OAuth token store, read and update issues, create new tickets, and attach files
Maintainers
Readme
🧭 @saptools/jira
Jira Cloud CLI and typed API that authenticate with a static Atlassian API token or the shared JiraOps OAuth token store.
Export one API token for CI and containers, or reuse the JiraOps browser login, then script Jira reads and focused write actions from the terminal without copying tokens between tools.
Install • Authentication • CLI • Security
✨ Features
- 🔑 Atlassian API token — HTTP Basic auth from
JIRA_API_TOKEN, taking priority over OAuth whenever it is set: no browser, no refresh, no token store. - 🔁 Shared JiraOps token — reads and refreshes
~/.jira-oauth/tokens.json, the defaultjira-oauth-clientstore used by JiraOps. - 🪪 Connected identity — reads the current Jira account profile without exposing its bearer token.
- 🎫 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, locally downloaded attachments, and clone-linked issues.
- 🆕 Ticket creation — creates a new issue with a validated project, issue type, optional description/priority/labels/parent/custom fields/attachments, and an optional create-then-assign step, failing before any write when a project-required field is missing.
- 📎 Attachment upload —
jira attachuploads local files to an issue, with an optional best-effort inline embed into a new comment or the description via an undocumented Jira technique. - 📝 Issue content writes — updates summaries, media-safe descriptions, and ADF comments.
- 🛟 Recoverable comment deletion — saves a private, durable local backup before deleting one comment.
- 🔗 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 — both the
api.atlassian.com/ex/jiragateway and youryour-domain.atlassian.netsite — not Jira Data Center.
Updates
Every command first checks npm for a newer @saptools/jira (at most once an hour, one small request
with a 2-second timeout) and, when one exists, installs that exact version with the package manager
that owns the running binary and re-runs the command you typed on the new version. Both steps are
announced on stderr; nothing is printed when the install is already current:
jira: updating 0.8.0 -> 0.9.0 ...
jira: updated to 0.9.0; re-running the commandIf the install cannot complete, one stderr line gives the manual command and the command runs on the
installed version; that version is not retried for a day. jira self-update forces the check and
install now; jira self-update --check only reports.
| Control | Effect |
| --- | --- |
| SAPTOOLS_AUTO_UPDATE=on\|notify\|off | on (default) installs and re-runs; notify prints the manual command once per version; off never checks. Applies to every @saptools CLI. |
| JIRA_AUTO_UPDATE | same values, this CLI only; wins over the global variable |
| SAPTOOLS_UPDATE_INTERVAL_MINUTES | minutes between checks (default 60; 0 checks on every run) |
| SAPTOOLS_NPM_REGISTRY | registry to check and install from (default: npm's configured registry, then npmjs) |
| SAPTOOLS_UPDATE_DEBUG=1 | explain on stderr why nothing happened |
The updater switches itself off in CI (CI set), under NODE_ENV=test or NO_UPDATE_NOTIFIER, when
the binary runs from a source checkout, an npm link or an npx cache, and inside the re-run itself.
It never writes to stdout, never asks for input, never uses sudo, and never moves onto a prerelease.
Its state lives in ~/.saptools/updates/.
🔐 Authentication
Two credentials are supported. An Atlassian API token wins whenever the environment supplies one; otherwise the CLI falls back to the shared JiraOps OAuth token store.
jira status # always names the credential in use, without calling JiraOption 1 — Atlassian API token (HTTP Basic)
Create a token at https://id.atlassian.com/manage-profile/security/api-tokens, then export it with the account email that owns it and the site it belongs to:
export JIRA_API_TOKEN="your-atlassian-api-token"
export JIRA_EMAIL="[email protected]"
export JIRA_SITE_URL="https://your-domain.atlassian.net"
jira issuesEvery request then sends Authorization: Basic base64(email:token). No browser login, no token
store, no refresh — which makes this the right choice for CI, containers, and cron jobs.
[!IMPORTANT] Atlassian issues two flavours of API token from that one page, and they need different base URLs. A classic token answers only on your site (
https://your-domain.atlassian.net); a scoped token answers only on the Atlassian gateway (https://api.atlassian.com/ex/jira/<cloud-id>). Using the wrong one returns401.
| Token flavour | Set this | Resulting base URL |
| --- | --- | --- |
| Classic (unscoped) | JIRA_SITE_URL | https://your-domain.atlassian.net |
| Scoped | JIRA_CLOUD_ID (no site URL) | https://api.atlassian.com/ex/jira/<cloud-id> |
Find your cloud ID at https://your-domain.atlassian.net/_edge/tenant_info. Setting
JIRA_CLOUD_ID alongside JIRA_SITE_URL keeps the site base URL and only makes the local state
directory match the one OAuth mode uses, so pinned custom fields carry across both credentials.
Variables
| Purpose | Names, first non-empty wins | Required |
| --- | --- | --- |
| API token | JIRA_API_TOKEN, ATLASSIAN_API_TOKEN | yes — presence is what selects this mode |
| Account email | JIRA_EMAIL, ATLASSIAN_EMAIL | yes |
| Site URL | JIRA_SITE_URL, JIRA_BASE_URL | one of these two |
| Cloud ID | JIRA_CLOUD_ID | one of these two |
--site-url and --cloud-id override the last two. The token itself is deliberately not a
flag: command-line arguments show up in ps output and shell history.
If JIRA_API_TOKEN is set but its companions are missing or malformed, the command fails with
the exact variable named rather than quietly falling back to OAuth and acting as a different
Jira identity.
Option 2 — shared JiraOps OAuth token store
@saptools/jira reads the same 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 issuesChoosing explicitly
jira --auth oauth issues # ignore JIRA_API_TOKEN even when it is exported
jira --auth api-token issues # fail rather than fall back to the OAuth store
jira --auth auto issues # default: API token when configured, else OAuthWhen 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/callbackThe new REST operations are covered by the existing classic Jira OAuth scopes:
jira whoami needs read:jira-user, issue and attachment reads need
read:jira-work, and comment deletion needs write:jira-work. The default
jira-oauth-client login also requests offline_access so stored tokens can be
refreshed.
Use 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 which credential is active. This never calls Jira.
jira status
jira status --jsonConnected to your-domain.atlassian.net with an Atlassian API token as [email protected]
Base URL: https://your-domain.atlassian.net--json adds authMode (api-token or oauth), baseUrl, email, and siteUrl to the
existing connected / cloudId / cloudName / usable fields. No credential is ever printed.
Under API token auth, usable only reports that the credential is well-formed — an Atlassian
token can still be expired or revoked. Run jira whoami to verify it against Jira.
jira whoami
Show the connected Jira account without printing or extracting its bearer token:
jira whoamiHuman output includes the display name, account ID, email availability, and
active/inactive status. Jira may omit the email address because of profile privacy
settings; jira whoami --json keeps the emailAddress key and returns null in
that case. Use the JSON form only when a script needs to parse these fields.
jira connect
Run the browser OAuth flow and write tokens to the shared token store.
jira connect
jira connect --jsonIf an Atlassian API token is configured, connect still writes the OAuth tokens but prints a
stderr note: later commands keep using the API token until you pass --auth oauth or unset it.
jira 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 OAuth 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.
Under API token auth this command refuses rather than echoing a static secret that is already in your environment. Authenticate scripts directly instead:
curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN" "$JIRA_SITE_URL/rest/api/3/myself"Use jira --auth oauth token when a script genuinely needs a stored bearer token.
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-images
jira issue OPS-123 --no-attachmentsJSON issue details include both descriptionText and descriptionAdf. descriptionText is a plain-text convenience string that preserves the description's block structure: headings, paragraphs, and rules each land on their own line, list items get a -/number marker, and each inline media/mediaSingle node is rendered in place as [image: <filename-or-id>]. descriptionAdf is the raw ADF document when Jira returns a valid document, or null when the issue has no valid description ADF. Every comment's bodyText is produced the same way.
Issue Images
Inline Jira images in the description or comments are saved to the OS temp
directory by default. Downloaded local image metadata is returned in the top-level
images[] array as fileUrl/filePath entries. Use --no-images,
--image-dir <path>, --max-image-bytes <number>, or --max-images <number> to
control local image capture.
Image capture defaults to at most 20 images and 10,000,000 bytes per image under
os.tmpdir()/saptools-jira/issue-images/<issue-key>/....
Issue Attachments
jira issue <key> downloads every attachment type by default, including non-image
files such as XML and XLSX. Each successfully saved attachments[] entry gains
localPath and fileUrl; a skipped or failed entry keeps its metadata and gains a
neutral downloadError without aborting the other downloads.
Attachment downloads default to at most 20 entries and 10,000,000 bytes per
attachment under
os.tmpdir()/saptools-jira/issue-attachments/<issue-key>/.... Control them with:
jira issue OPS-123 --no-attachments
jira issue OPS-123 --attachment-dir ./controlled-jira-files
jira issue OPS-123 --max-attachments 5
jira issue OPS-123 --max-attachment-bytes 2000000--no-attachments skips only the general attachment list; --no-images skips only
inline-image capture, and the flags compose independently. If an attachment is also
an inline image, the CLI reuses the saved image path and fetches that attachment ID
only once.
[!IMPORTANT] Issue reads now write bounded attachment files locally by default. Use
--no-attachmentsfor metadata-only reads, or--attachment-dirfor a controlled location. Remove sensitive downloads when they are no longer needed.
jira create <summary>
Create a new Jira issue.
jira create "Investigate flaky checkout test" --project OPS --type Task
jira create "Fix login regression" --project OPS --type Bug --priority High --label ci --label flaky
jira create "Update runbook" --project OPS --type Task --text "See the linked incident for context."
jira create "Split auth work" --project OPS --type Subtask --parent OPS-100
jira create "Track vendor request" --project OPS --type Task --field 'Custom text A=Needs legal review'
jira create "Onboard new service" --project OPS --type Task --assign-me
jira create "Investigate flaky checkout test" --project OPS --type Task --json--project <key> and --type <name> are required. --type matches a Jira issue type display name
for that project case-insensitively (for example Task, Bug, Story, Subtask); an unmatched
name fails with the available issue type names listed. There is no default project or issue type —
creation is a hard-to-undo write, so both are always explicit.
Description input reuses the same body flags as describe/comment: --text, --text-file, or
--adf-file, and all three are optional for create (omit them for no description). At most one
may be given.
Before creating anything, the CLI loads that project and issue type's Jira create-issue field
metadata (issue/createmeta) and validates locally:
- A subtask issue type requires
--parent <key>; a non-subtask issue type refuses one. --priority <name>and--label <name>(repeatable) are refused when the project does not expose that field for the issue type.--field <name=value>/--field-file <name=path>(repeatable) resolve Jira display names against that issue type's fields the same wayjira fields updatedoes, including textarea, textfield, number, date, option, and multi-option conversion. A priorjira fields discover/pinis not required forcreate— field names are resolved directly from the create-issue metadata.- Any other field the project requires for that issue type and that summary/description/priority/
labels/parent/
--fielddoes not already cover fails before the create request, naming every missing field by display name.
Optionally assign the created issue immediately, reusing the exact same deterministic resolution as
jira assign:
jira create "Self-assigned task" --project OPS --type Task --assign-me
jira create "Review needed" --project OPS --type Task --assignee "Example User"--assign-me and --assignee <name-or-query> are mutually exclusive. The issue is created first;
if the follow-up assignment is ambiguous, fails permission checks, or otherwise cannot complete, the
CLI does not roll back or fail the command — it prints a warning naming the created issue key
and leaves it unassigned, since the ticket already exists and re-running create would make a
duplicate. Retry the assignment with jira assign <key> ... once the ambiguity is resolved.
Use --no-notify-users only when the user explicitly wants to suppress Jira's creation
notifications.
JSON output:
{
"id": "30001",
"issueKey": "OPS-456",
"issueType": "Task",
"assignee": { "accountId": "account-id", "displayName": "Example User" },
"assigneeResolution": "me"
}assignee/assigneeResolution are present only when an assignee selector was given and the
assignment succeeded.
Attach local files right after creation with repeatable --file <path>:
jira create "Investigate flaky checkout test" --project OPS --type Task --file ./screenshot.pngThe issue is created first; if the follow-up upload fails, the CLI does not roll back or fail
the command — it warns on stderr and leaves the issue without that attachment, since the ticket
already exists and re-running create would make a duplicate. Retry with jira attach <new-key>
<file>.
jira attach <key> <file...>
Upload one or more local files as Jira issue attachments.
jira attach OPS-123 ./screenshot.png
jira attach OPS-123 ./a.txt ./b.txt
jira attach OPS-123 ./screenshot.png --jsonEvery file in one jira attach call uploads as a single request, matching Jira's own multipart
contract. Each file is capped at 10,000,000 bytes by default; a missing or oversized file fails
before any request is sent.
Best-effort inline embedding
jira attach OPS-123 ./screenshot.png --embed comment
jira attach OPS-123 ./screenshot.png --embed description--embed <comment|description> uploads the file, then tries to make it render inline — inside
a new comment, or appended to the description — instead of only listed in the Attachments panel.
[!IMPORTANT] This is undocumented Jira behavior, not a supported API contract. It works by requesting the uploaded attachment's own content URL without following the redirect, then reading the Media Services file id out of the
Locationheader — a technique Atlassian has changed before (some tenants now redirect through an opaque proxy that hides the id) and could change again without notice.--embedis opt-in for exactly this reason. When the id cannot be resolved, the upload still succeeds; the command prints a warning and skips the embed instead of failing.--embedaccepts exactly one file at a time — embed images one at a time.
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 has no officially supported API. See jira attach --embed description above for the best-effort, undocumented path, or 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 comment-delete <key> <comment-id>
Delete one issue comment only after its full current content has been written and synced to a private local backup:
jira comment-delete OPS-123 10098There is no backup-skip option. If the comment cannot be fetched or the backup
cannot be written, Jira receives no DELETE request. If Jira rejects the deletion,
the backup remains in place and the command does not retry. Successful human output
reports the absolute recovery path; scripted callers can use
jira comment-delete OPS-123 10098 --json.
Backups are cloud-scoped to avoid collisions between Jira sites:
~/.saptools/jira/clouds/<cloudId>/comments/<issueKey>/<commentId>.jsonjira 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
- The Atlassian API token is read from the environment only. It is never accepted as a flag, never written to disk by this package, and never printed —
jira status,jira connect, andjira whoamiall omit it, and it is masked out of error output along with its base64 Basic encoding. - 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 report the status line (for example
(HTTP 403 Forbidden)) and never the response body. - Attachment and inline-image downloads that follow a signed media redirect drop the
Authorizationheader before requesting the signed URL, under both credentials. - Downloaded issue attachments can contain sensitive ticket data. Prefer a controlled
--attachment-dirand remove files after use. jira attachuploads with the same resolved credential as every other write;X-Atlassian-Token: no-checkis Jira's CSRF-bypass marker, not a secret.--embedreads a signed, time-limited Media Services URL to extract a file id (see the command's own docs above) but never logs, stores, or returns that URL or its token — only the extracted id crosses into the rest of the command.- Comment backups contain the full original comment and remain under the private cloud-scoped
~/.saptools/jira/tree, including when Jira rejects a delete. - 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!
