npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.

npm version license node types

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 default jira-oauth-client store 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 attach uploads 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/jira gateway and your your-domain.atlassian.net site — 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 command

If 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 Jira

Option 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 issues

Every 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 returns 401.

| 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.json

If JiraOps already connected successfully, this CLI can read the same stored access token immediately:

jira status
jira issues

Choosing 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 OAuth

When 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 connect

To remove the local shared token file:

jira logout

Your Atlassian OAuth app must allow the jira-oauth-client callback URL:

http://localhost:30129/callback

The 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 --json
Connected 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 whoami

Human 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 --json

If 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 disconnect

jira logout

Delete the shared token file. This is equivalent to jira disconnect.

jira logout

jira token

Print the current OAuth access token for scripts.

jira token

[!IMPORTANT] jira token prints 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 --json

jira 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-attachments

JSON 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-attachments for metadata-only reads, or --attachment-dir for 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 way jira fields update does, including textarea, textfield, number, date, option, and multi-option conversion. A prior jira fields discover/pin is not required for create — 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/--field does 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.png

The 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 --json

Every 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 Location header — a technique Atlassian has changed before (some tenants now redirect through an opaque proxy that hides the id) and could change again without notice. --embed is 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. --embed accepts 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 --json

Default --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 --json

Plain 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.json

This 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-users

JSON 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." --json

Plain 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 10098

There 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>.json

jira links <key>

List remote links attached to an issue.

jira links OPS-123
jira links OPS-123 --json

jira transitions <key>

List available status transitions.

jira transitions OPS-123
jira transitions OPS-123 --json

jira transition <key> --id <id>

Apply a transition by ID.

jira transition OPS-123 --id 31

jira 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" --json

Exactly 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.

  • --me fetches GET /rest/api/3/myself, then verifies that active account through the issue-scoped assignable-user search before writing.
  • --to is an approximate Jira display-name query. The CLI searches only users assignable to the target issue with issueKey=<KEY>, query=<name-or-query>, and maxResults=1000.
  • --account-id is the deterministic retry path. It still verifies the account through the same issue-scoped assignable-user endpoint with accountId=<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.json

The 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.md

The 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 issue

Human 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:e2e

E2E 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, and jira whoami all 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 Authorization header before requesting the signed URL, under both credentials.
  • Downloaded issue attachments can contain sensitive ticket data. Prefer a controlled --attachment-dir and remove files after use.
  • jira attach uploads with the same resolved credential as every other write; X-Atlassian-Token: no-check is Jira's CSRF-bypass marker, not a secret.
  • --embed reads 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!