JIRA Skill
Controls JIRA Cloud tickets via the REST API v2 through a self-contained Python
(uv) client behind a thin zsh wrapper. It fills the gaps the Atlassian MCP leaves
open — most importantly arbitrary status transitions (the MCP cannot reach every
workflow status), a predictable v2 wiki-markup comment/description body (the MCP
mangles Markdown → wiki: **x**→*x*, backticks→{{…}}), assignee changes, issue
creation, and attachment upload/embed (the MCP server can't see the local filesystem).
How to run (always use the helper script)
zsh ${CLAUDE_SKILL_DIR}/scripts/jira.sh $ARGUMENTS
Important: Run the script directly (
${CLAUDE_SKILL_DIR}/scripts/jira.sh). Do not prefix it withbash— it requires zsh and will fail under bash. It runs from any directory (no git, nobb).
Operation tiers (read / write / dangerous)
Every command belongs to one tier, gated by global flags accepted anywhere in the
argument list. -- ends global flag parsing only — per-command flags after it are
still interpreted, so it is not a way to pass a body that starts with a dash; use
--file or stdin for that:
| Tier | Flag | What it covers |
|---|---|---|
| read | (none) | Never changes anything. |
| write | --write | Creates or modifies ticket data. |
| dangerous | --dangerous (implies --write) | Irreversible deletes. |
Running a write/dangerous command without its flag prints a clear refusal and
does nothing — no partial mutation. This makes read-only inspection safe by default.
Commands
J=${CLAUDE_SKILL_DIR}/scripts/jira.sh
# --- read (no flag; every read command also takes --output PATH|-) ---
$J whoami # account behind the token (verify auth)
$J get JIRA-3052 # status/type/assignee/labels/updated/description_chars
$J description JIRA-3052 # the raw description body (read side of describe)
$J status JIRA-3052 # current status name only
$J transitions JIRA-3052 # available transitions (id, target, name)
$J comments JIRA-3052 --max 50 # newest comments first (idempotency pre-check)
$J comment-get JIRA-3052 121771 # one comment's raw body (read side of comment-edit)
$J search 'project = VUKFZIF AND status = "In QA"' --max 20 # JQL search
$J links JIRA-3052 # issue links: id, relation, key, status, summary
$J attachments JIRA-3052 # id/filename/size/mime/content-url per attachment
$J download JIRA-3052 crawllog.zip # download an attachment (by id or filename)
$J user alex.beispiel@example.com # resolve accountId (cached; see below)
$J users bob --issue JIRA-3052 # assignable-user search (paged + cached)
$J undo --list --issue JIRA-3052 # list undoable journal entries
# --- write (need --write) ---
# Ticket summaries, descriptions and comments are prose that other people read:
# write them with Skill(technical-writing) — see "Writing the prose" below.
$J --write transition JIRA-3052 "In Code Review" # any status, by name, idempotent
$J --write transition JIRA-3052 61 # or by transition id
printf '%s' "<wiki body>" | $J --write comment JIRA-3052 - # body via stdin (no escaping)
$J --write comment JIRA-3052 --file note.md
$J --write comment-edit JIRA-3052 121771 --file note.md # replace a comment body
$J --write assign JIRA-3052 @me # email / accountId / alias / --unassign
$J --write label JIRA-3052 --add security --remove wip
$J --write link JIRA-3052 "Blocks" JIRA-3060 # -> 3052 blocks 3060
$J --write link JIRA-3052 "is blocked by" OPS-2328 # the inward phrasing too
$J --write unlink JIRA-3052 72461 # remove a link (id from `links`)
$J --write watch JIRA-3052 # add self as watcher (or unwatch)
printf '%s' "<description>" | \
$J --write create --type Task --label security --summary "[ServiceA] High CVEs (netty)" -
$J --write create --summary "…" --description-file finding.wiki # or from a file
# --- attachments (write) ---
$J --write attach JIRA-3052 screenshot.png crawllog.zip # upload only
$J --write comment JIRA-3052 --embed screenshot.png "Error:" # upload + inline !screenshot.png!
$J --write comment JIRA-3052 --embed crawllog.zip "Log:" # upload + link [^crawllog.zip]
$J --write describe JIRA-3052 --file description.wiki # replace the description
$J --write describe JIRA-3052 --embed diagram.png "New desc:"
# --- dangerous (need --dangerous) ---
$J --dangerous comment-rm JIRA-3052 121771 # delete a comment (snapshot kept)
$J --dangerous attach-rm JIRA-3052 45231 # delete an attachment (bytes kept)
# --- undo (write) ---
$J --write undo --issue JIRA-3052 # revert the last change on this ticket
$J --write undo --id 42 # revert a specific journal entry
transitionresolves the target status at runtime against the live/transitionsendpoint (case-insensitive, matches the resulting status name) and is idempotent (already in the target status → no-op). Accepts a numeric transition id too.commentreads the body from stdin by default (-), so you post exactly the wiki-markup you intend without shell-quoting issues.link/unlink— the arguments read as a sentence:link A "Blocks" Bmeans A blocks B. The middle argument may be the type name (Blocks), its outward description (blocks) or its inward one (is blocked by), which reverses the direction — so you never have to reason about which end of the REST payload is which. An ambiguous phrase is refused, not guessed:"Used by"is the outward description ofUsedand the inward one ofDepends, and the error names both.linkis idempotent — an identical link is reported with its id instead of being created a second time (Jira duplicates links silently). Everylinkreads the result back and prints what Jira actually stored, e.g.JIRA-3333 is blocked by OPS-2328 (link 72461, type Blocks); stdout gets the bare link id.unlink <KEY> <link-id>takes the id fromlinks;KEYmust be one of the link's two ends, which is the guard against deleting the wrong link from a stale id.commentsshows the newest--max(default 50) and warns on stderr when the ticket has more (Showing newest N of M …) — raise--maxbefore trusting a negative idempotency check on a long-history ticket.createdefaults to project$JIRA_PROJECT_KEY(defaultVUKFZIF) and prints the new key on stdout; the sizes it actually sent go to stderr (summary: 37 chars, description: 1183 chars). Check that line — a description of1 charafter a long heredoc means the body never arrived. Its description comes from exactly one of--description TEXT,--description-file PATH,--description-file -or a bare-; combining them is an error.--description -is honoured too but warns, because-is a filename convention and this used to be read as the literal text-.describereplaces the description (no merge). The prior text is journaled, soundorestores it.
Long fields: read, edit, write back
- is the stdin filename everywhere — a bare -, --file - and --description-file -
all mean the same thing. Inline text, a file and stdin are mutually exclusive; combining
them is an error rather than a silent winner.
Every read command takes --output:
| --output | Where the payload goes | Use it for |
|---|---|---|
| (omitted) | stdout, truncated above $JIRA_OUTPUT_MAX_BYTES (32 KiB) | the default — keeps big results out of context |
| --output PATH | PATH, byte-exact; stdout gets only PATH<TAB>bytes | editing a long body without ever loading it |
| --output - | stdout, byte-exact and untruncated | piping into sed/perl |
# via a file — the long body never enters the conversation
$J description JIRA-3052 --output /tmp/d.wiki
perl -pi -e 's/netty 4\.1\.\d+/netty 4.1.118/g' /tmp/d.wiki
$J --write describe JIRA-3052 --file /tmp/d.wiki
# or as a straight pipe
$J description JIRA-3052 --output - | sed 's/^h2\./h3./' \
| $J --write describe JIRA-3052 --file -
# same for a single comment
$J comment-get JIRA-3052 121771 --output - | sed 's/typo/fixed/' \
| $J --write comment-edit JIRA-3052 121771 --file -
In a pipe you must spell out
--output -. Without it the spill guard is active, and above 32 KiB it injects a truncation header into the stream. There is deliberately noisatty()auto-detection: under an agent harness stdout is always a pipe, which would disable the context protection entirely.
get reports description_chars rather than the description itself, so a body can be
size-checked in one call without paying for it: $J get KEY --format json | jq .description_chars. Note that JQL description IS EMPTY will not find a ticket whose
description is a stray - — it is not empty.
Attachments & embedding
attach, --attach, and --embed upload via POST /rest/api/2/issue/<KEY>/attachments
(multipart, X-Atlassian-Token: no-check). Each local file is validated first (regular,
readable, non-empty). ZIPs/binaries work.
| Purpose | Command | Body |
|---|---|---|
| Upload only | attach <KEY> <file>… | — |
| Upload while commenting/describing | comment/describe … --attach <file> | unchanged |
| Upload and embed | comment/describe … --embed <file> | wiki reference appended |
Reference syntax (Jira v2 wiki-markup; --embed generates it automatically):
- Image (
png/jpg/jpeg/gif/webp/bmp/svg/tif/tiff/ico/heic):!name.png!(inline). - Everything else (ZIP/PDF/log/…):
[^name.zip](clickable file link). - Order is upload-then-body, so a failed upload never leaves a dead reference.
Assignable-user search (paged + cached)
user and users (and assign's email resolution) page through
/user[/assignable]/search and cache results in a local SQLite DB. This avoids the
timeouts / HTTP 429 you hit when repeatedly enumerating a huge user directory (e.g.
check24). Cache hits skip the API; --refresh forces a re-fetch. users needs a
--project or --issue scope. Tunables: JIRA_USER_CACHE_TTL (default 86400s),
JIRA_USER_SEARCH_CAP (default 1000), JIRA_CACHE_DIR.
Undo journal
Before any op that overwrites or deletes data (transition, comment-edit,
assign, describe, label, comment-rm, attach-rm), the client snapshots the prior
value into a durable local SQLite journal (JIRA_STATE_DIR, default
$XDG_STATE_HOME/jira-skill); attach-rm also backs up the attachment bytes so the
delete is reversible.
undo --list [--issue KEY]— read; shows recent entries (id, time, key, op, status).undo [--issue KEY] [--id N]— write; applies the inverse of the most recent (or chosen) entry and marks it undone.
Restores are honest about their limits: a deleted comment comes back as a new
comment (original id/author/timestamps can't be recreated); this is stated in the output.
link and unlink are journaled too, and their inverses are exact: undo of a link
deletes it, undo of an unlink re-creates it from the recorded type and both ends. Only
the link id changes (ids are not reusable), which the output says. The remaining
additive ops (comment, attach, create, watch) overwrite nothing and are not
journaled.
Credentials
Same chain as the bitbucket-pr skill: one env name, then one file. The
$SOPS_SECRETS_DIR file is the default source; the environment variable is a
deliberate manual override.
JIRA_URL|jira_url(required — no built-in default)JIRA_USERNAME|jira_usernameJIRA_API_TOKEN|jira_api_token
There is deliberately no generic alias tier (no ATLASSIAN_API_TOKEN, no
atlassian_c24_bitbucket_api_token). It was removed on 2026-08-24: the shell
exported ATLASSIAN_API_TOKEN globally holding a Bitbucket token, and because
the chain tried every env name before any file, that dead alias outranked the
correct jira_api_token file and every call failed. Worse, it failed as HTTP
404, since Jira hides issue existence from unauthenticated callers — so it read
as "that ticket does not exist". Do not reintroduce it.
HTTP Basic against the Jira site; httpx puts the token in a header, so it never reaches
the process argv. Missing credentials → exit 2 with a clear hint (no silent 401). A 401
or 404 now also names the source the token came from ($JIRA_API_TOKEN vs. the file),
never the token itself.
Exit codes: 0 ok, 1 bad args / gating refusal, 2 missing prereq/credentials,
3 API/auth/network, 4 not found, 124 timeout (killed by gtimeout).
Security
- The client speaks only issue-scoped endpoints (
/issue/…,/user/…,/myself,/search,/issueLink,/issueLinkType,/attachment/<id>). It knows no board/workflow-config endpoints — boards and the workflow itself cannot be changed, only individual tickets./issueLinkTypeis read-only here: it is queried to resolve a relation phrase, never to create or edit a link type. createis pre-set to$JIRA_PROJECT_KEY(--project/ env overrides deliberately).describereplaces the description (journaled for undo). Deletes require the explicit--dangerousflag and are journaled (with bytes, for attachments).
Workflow statuses (VUKFZIF)
Global "Any status" transitions — reachable from every status, transition name equals
the target status. Runtime source of truth is the /transitions endpoint; the id table is
documented for humans in references/workflow.md:
Open, Backlog, To Do, In Progress, In Code Review, In QA, In Test PM, Ready for Release, Done, Closed (In Review is a decoupled legacy status — no global transition reaches it).
Writing the prose
Everything this skill posts — summaries, descriptions, comments — is read by
colleagues, not by a machine. Compose it with Skill(technical-writing),
which names JIRA tickets explicitly and asks for a TL;DR summary first.
Two reasons it matters more here than in most places:
- A JIRA description is often read once, by someone deciding whether the ticket concerns them. If the point is in the last paragraph, it is not read.
- Descriptions and comments here are wiki markup, and long bodies go through
--file/--outputround-trips (see "Long fields" above). Restructuring a finished body into a summary-first one after the fact means another read-edit-write cycle, so load the skill before writing, not after.
It covers German and English: match the language the ticket is already in rather than switching.
Related skills
technical-writing: how to write the summaries, descriptions and comments this skill posts. TL;DR first — see "Writing the prose" above.bitbucket-pr: JIRA ↔ Bitbucket bridge (linked PRs/branches/repos for a ticket) and PR management. Shares this skill's credential chain.