acli
Atlassian CLI (official `acli` binary, v1.3+ as of 2026) for Jira Cloud, Confluence Cloud, and org admin tasks from the terminal. Use whenever the user wants to create, view, edit, transition, assign, clone, archive, comment on, link, or bulk-operate on Jira work items; list or m
Install
npx skills add https://github.com/upex-galaxy/agentic-qa-boilerplate/tree/main/.agents/skills/acli
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install upex-galaxy-agentic-qa-boilerplate@llmmart
git clone https://github.com/upex-galaxy/agentic-qa-boilerplate.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole upex-galaxy/agentic-qa-boilerplate collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Atlassian CLI (acli)
acli is Atlassian's official command-line tool for Jira Cloud, Confluence Cloud, and org admin operations. It replaces terminal-based Jira automation that previously required raw REST calls, and unifies Jira + Confluence + admin actions behind one binary with one credential store per product.
This skill teaches how to drive acli for any intent: one-off commands, batch mutations, scripted pipelines, and CI jobs. Repo-specific integration (how this skill plugs into the host repo's workflow, TMS modality, project conventions, anti-patterns) lives in the companion file <repo-core>/references/acli-integration.md — load it on demand. See "Navigation" below.
Compact Rules
- DO: pass
--paginate(or an explicit--limit) on any search whose result is counted, iterated, or decided on. Pagination is opt-in and truncation is silent — there is no warning. - DO NOT: read exit 0 as proof a subcommand exists. An unknown subcommand falls back to the parent help and exits 0. Check that the help body actually changed, and never invent a flag — every multi-word flag is kebab-case.
- DO: verify auth status before any bulk mutation. Auth is per-product (jira / confluence / admin / global are separate sessions) and a silent expiry leaves the batch half-applied with no clean rollback.
- DO: pass the non-interactive confirmation flag on every mutating command in CI, or the command hangs waiting on stdin.
- DO NOT: hand-author raw ADF JSON, and do not pass Markdown to a rich-text flag — the CLI never converts it and stores the literal characters. Author in Markdown, convert with
scripts/md-to-adf.ts, pass the ADF. - DO: let the converter's validation gate run on every ADF document before publishing, and round-trip read the field after writing. The gate catches node-level errors; only the read-back catches Jira's silent server-side coercion.
- DO NOT: assume
workitem edittakes custom-field values. It hard-rejects every shape with exit 1; editing a custom field on an EXISTING item works only through the REST PUT path. - DO NOT: expect
workitem editto set an issue's COMPONENTS either. There is no flag and no--from-jsonkey, so the edit succeeds while leaving components untouched and says nothing. Set them at create time, or change them through the same REST PUT path as custom fields. - DO NOT: copy an example out of the vendor's own
--help. Several omit the subcommand the flags actually live on (workitem comment --key …instead ofworkitem comment create --key …) and fail withunknown flag. The forms in this skill's references are the tested ones. - DO NOT: hardcode a
customfield_NNNNNid in a script or in generated output. Resolve it through the host project's slug catalog — ids differ per workspace, slugs travel. - DO NOT: read the Atlassian host from an environment variable. It lives in
.agents/project.yamlunderissue_tracker.atlassian_urland is resolved through the accessor; a stale inherited copy once pointed the sync scripts at a dead site. - WHEN creating an issue link:
--out/--inare empirically INVERTED against Jira's semantics —--outtakes the prerequisite,--inthe dependent. Verify the direction by listing the link afterwards, and recreate with swapped flags if it landed backwards. - DO: capture and surface the trace id from any backend failure. It is the only debug signal, and Atlassian Support needs it.
- WHEN the operation is a known blind spot (enumerate custom fields, edit custom-field values, manage workflows / issue types / versions / components, attachments, watchers, add an item to a sprint): route through REST or the opt-in Atlassian MCP rather than forcing the CLI.
- DO: prefer API-token auth in scripted contexts, and pin the binary to an explicit version in production pipelines — tracking
latesthas caused same-day mass failures.
Read full SKILL.md when: composing a specific command, publishing rich text, running the REST PUT workaround, or working any surface outside Jira work items.
Why this skill exists
acli has several traits that make it easy to misuse:
- Silent pagination truncation.
workitem searchwithout--paginatereturns the first page only — no warning. Scripts that count or iterate keys read the wrong number of items. - Auth is per-product.
acli jira auth logindoes not authenticateacli admin,acli confluence, oracli rovodev. There is also a top-levelacli authfor global OAuth (newer surface). Each scope has its own session. - The "work item" vs "issue" split. The CLI renamed commands (
jira issue→jira workitem) but the JSON response still has a top-levelissues[]array and CSV inputs still useissueType/parentIssueIdspellings. Mixing old and new terminology in the same script works, but confuses readers. - Unknown subcommands fail silently. Typing
acli jira workflow --helpdoes NOT error — it falls back toacli jira --helpwith exit 0. So "no error" ≠ "command exists". Always verify by checking the help body actually changed. - Hard limits the docs do not advertise.
aclicannot list custom fields, edit custom-field values on existing items, manage workflows, manage issue types, or touch project versions/components. Seereferences/gotchas.md.
The body below covers the core that applies to almost every session. The references/ directory holds the deep material — load only the one you need.
Composable Skills (auto-resolved at skill entry)
acli is itself the canonical issue-tracker skill. The category typically has no T3 skills that overlap — acli is the tool surface, not a borrower of community skills.
Steps for protocol consistency:
- Read
complementary_categoriesfrom this skill's frontmatter (issue-tracker). - Resolve via the host repo's skill-registry cache (
.agents/skills/REGISTRY.md, built byscripts/build-skill-registry.ts). Fallback: scan the session-startsystem-reminderskill list. - Apply the threshold rule per the host repo's skill-composition strategy doc (T1 / T3 silent; T4 ASK).
- The Atlassian MCP fallback documented below is OPT-IN, not a skill — enable manually via
docs/mcp/.
Expected matches: typically none. Repo-specific composability (which workflow skills load this) lives in <repo-core>/references/acli-integration.md §Composability.
Skip step if the catalog is unavailable; log skill_resolution: "fallback-inline" plus missing: [<categories>] per the strategy doc's composability fallback contract.
Fallback: Atlassian MCP
Opt-in only: this MCP is NOT enabled in the default boilerplate. To use it, copy the atlassian block from
docs/mcp/<agent>.template.*into.mcp.json/opencode.jsonc, ensureATLASSIAN_*in.envare set, and restart the agent. Behavior below applies only after opt-in.
If acli is not installed or authenticated, fall back to the Atlassian MCP server (MCP tool namespace: mcp__atlassian__* or similar — check the MCP tool list for the exact prefix in the current environment).
When to prefer MCP over acli:
aclibinary is not installed in the environment.acliauth fails and cannot be fixed in the current session.- The operation is one of the documented
acliblind spots: enumerate custom fields, edit custom-field values on existing work items, manage workflows / issue types / priorities / resolutions / project versions / components, upload attachments, add watchers, add an item to a sprint.
When to prefer acli over MCP:
- Bulk operations (acli consumes far fewer tokens per call).
- Scripting / CI pipelines.
- Operations that return large result sets (MCP payloads inflate token usage).
Coverage parity: MCP and acli overlap for issues, projects, boards, sprints, comments, and basic Confluence ops. For org-admin user lifecycle and Confluence space CRUD, acli is more direct. For schema/admin reads (field catalog, workflow definitions), MCP/REST is the only viable path.
Command structure
acli <product> [<feature>] <action> [flags]
| Product | Purpose |
|---|---|
jira |
Jira Cloud — work items, projects, boards, sprints, filters, dashboards, custom-field definitions |
confluence |
Confluence Cloud — spaces (CRUD), blog posts, page view |
admin |
Organization admin — API-key auth, user lifecycle |
auth |
Global OAuth (cross-product, newer top-level surface) |
rovodev |
Rovo Dev AI coding agent (separate beta product) |
feedback |
Send feedback or a bug report to Atlassian |
config |
Atlassian Government Cloud configuration (gov-cloud) |
completion |
Generate shell-autocompletion script (bash / zsh / fish / powershell) |
Every level has --help. Use it aggressively when unsure:
acli --help
acli jira --help
acli jira workitem --help
acli jira workitem create --help
Quick start
# 1. Authenticate against a site using an API token (scriptable path)
echo "$ATLASSIAN_API_TOKEN" | acli jira auth login \
--site "<your-site>.atlassian.net" \
--email "you@example.com" \
--token
# 2. Verify
acli jira auth status
# 3. Create a work item
acli jira workitem create --project "{{PROJECT_KEY}}" --type "Task" --summary "Draft the Q3 OKRs"
# 4. Search with JQL — ALWAYS pass --paginate or --limit explicitly
acli jira workitem search --jql "project = {{PROJECT_KEY}} AND status = 'To Do'" --paginate --json
# 5. Transition one or many
acli jira workitem transition --jql "project = {{PROJECT_KEY}} AND assignee = currentUser()" \
--status "In Progress" --yes --ignore-errors
Repo-specific quick start: when the host repo defines its own workflow (status names, project keys, slug-resolved custom fields), see
<repo-core>/references/acli-integration.md— it documents the project-flavored variant of the steps above.
Top-level command map
Jira (acli jira)
| Subcommand | What it covers |
|---|---|
auth |
login · logout · status · switch — API-token or OAuth |
workitem |
archive · assign · attachment (list / delete) · clone · comment (create / delete / list / update / visibility) · create · create-bulk · delete · edit · link (create / delete / list / type) · search · transition · unarchive · view · watcher (list / remove) |
project |
archive · create · delete · list · restore · update · view |
board |
create · delete · get · list-projects · list-sprints · search |
sprint |
create · delete · list-workitems · update · view |
filter |
add-favourite · change-owner · get · get-columns · list · reset-columns · search · update |
dashboard |
search |
field |
cancel-delete · create · delete · update — custom-field DEFINITIONS only, NOT values, and no listing |
Confluence (acli confluence)
| Subcommand | What it covers |
|---|---|
auth |
login · logout · status · switch — same model as jira auth |
space |
archive · create · list · restore · update · view (full CRUD) |
blog |
create · list · view |
page |
view (read-only as of v1.3.18 — page CRUD not yet exposed) |
Admin (acli admin)
| Subcommand | What it covers |
|---|---|
auth |
login · logout · status · switch — API key |
user |
activate · deactivate · delete · cancel-delete |
The selector pattern (the thing to internalize)
Most mutating workitem commands (edit, transition, assign, archive, clone, comment create) accept one of these target selectors:
| Selector | When to use |
|---|---|
--key KEY-1,KEY-2 |
You already know the exact keys |
--jql "..." |
You want everything matching a JQL query |
--filter 10001 |
You want to reuse a saved Jira filter |
--from-file f |
You have a file listing keys (archive/unarchive/assign) |
When the selector matches many items, the command is a batch operation. Two flags almost always matter:
-y, --yes— skip the interactive confirmation prompt. Required in CI; if omitted the command hangs waiting on stdin. Note: this flag does NOT exist onadmin user delete/admin user cancel-delete(use--ignore-errorsthere instead).--ignore-errors— do not abort the batch when a single item fails.
Output and piping
All list/search/view commands support three shapes:
- default table (human-readable)
--json(forjq/ scripts)--csv(spreadsheet-friendly)
Example pipe patterns:
# Count only
acli jira workitem search --jql "project = {{PROJECT_KEY}}" --count
# Save full result set to CSV
acli jira workitem search --jql "project = {{PROJECT_KEY}}" --paginate --csv > team.csv
# Extract a single field with jq
acli jira workitem view {{PROJECT_KEY}}-123 --json | jq '.fields.summary'
The JSON shape from workitem search has a top-level issues array (not workitems) — the Jira REST v3 wire format shows through.
Publishing rich text (the default workflow)
Jira stores rich-text content (descriptions, comments, and any rich-text field) as ADF — Atlassian Document Format, a JSON tree of typed nodes (heading, paragraph, bulletList, orderedList, codeBlock, blockquote, rule, table, panel, expand) with inline marks (strong, em, code, link, strike).
acli accepts ADF JSON in every rich-text input. acli never converts markdown — passing # Heading to --description or --body stores the literal string # Heading wrapped in a single ADF paragraph.
⚠️ Asymmetry:
createsupports custom-field rich text,editdoes NOT.acli workitem create --from-jsonaccepts custom fields viaadditionalAttributes(ADF doc payloads work).acli workitem edit --from-jsonhard-rejects every custom-field shape (additionalAttributes,fields, flatcustomfield_X) with exit 1 +unknown fielderror. No silent drop, no escape hatch in the binary. To update or correct a rich-text custom field on an existing work item, you MUST use the REST PUT workaround documented below —aclicannot do it.
To publish anything richer than plain prose, use this three-step workflow by default:
1. Author the content in Markdown.
2. Convert MD → ADF JSON using scripts/md-to-adf.ts.
3. Pass the ADF JSON to the matching acli flag — or, for cases acli cannot cover, into a REST body.
The bundled converter
Location: .agents/skills/acli/scripts/md-to-adf.ts. Runtime: Bun.
CLI usage:
bun .agents/skills/acli/scripts/md-to-adf.ts input.md output.adf.json
# stdin form
cat input.md | bun .agents/skills/acli/scripts/md-to-adf.ts - output.adf.json
# stdout form (omit output arg)
bun .agents/skills/acli/scripts/md-to-adf.ts input.md > output.adf.json
Programmatic usage (when batching across many fields or many work items in one script):
import { mdToAdf, validateAdf } from "./.agents/skills/acli/scripts/md-to-adf.ts";
const adf = mdToAdf(markdownString); // returns { type: "doc", version: 1, content: [...] }
const { valid, errors } = validateAdf(adf); // gate ANY ADF before publishing
Covered markdown subset: headings 1–6, bullet lists, ordered lists, nested lists (indentation-based), GFM tables (| a | b | + |---|---| separator), panels (GitHub-alert blockquotes), expand blocks (<details><summary>), Jira-native emoji (:short_name:), status lozenges ({status:color|TEXT}), mentions (@[Name](accountId)), fenced code blocks (with optional language tag), inline code, bold, italic (snake_case-safe), strikethrough, links, blockquotes, horizontal rule, paragraphs.
Rich-block syntax cheat-sheet:
| Markdown you write | ADF node produced |
|---|---|
\| H1 \| H2 \| then \| --- \| --- \| then body rows |
table (header row → tableHeader, body → tableCell; inline marks work inside cells; \| escapes a literal pipe) |
> [!NOTE] / [!INFO] (blue) · [!TIP] / [!SUCCESS] (green) · [!IMPORTANT] (purple) · [!WARNING] (yellow) · [!CAUTION] / [!ERROR] (red), then > body lines |
panel with panelType info / success / note / warning / error. Body re-parsed as Markdown (can hold lists, code, etc.) |
| Two-space (or deeper) indentation under a list item | nested bulletList / orderedList inside that listItem; depth = indent width; bullet/ordered mix per level |
<details> / <summary>Title</summary> / body / </details> |
expand with attrs.title; body re-parsed as Markdown |
:white_check_mark: :x: :warning: … any :short_name: |
emoji node (Jira resolves the shortName; curated status marks also carry a Unicode text fallback). Inline code is parsed first, so a colon inside `code` is safe |
{status:green\|DONE} (colors: neutral purple blue red yellow green) |
status node — the coloured lozenge/pill for transition states. localId not required (Jira injects none on publish) |
@[Display Name](accountId) |
mention node. The accountId is supplied explicitly (resolve it via /rest/api/3/user/search — see references/adf-authoring-style.md §mentions); a bare @name is NOT converted |
Media (images / videos) are NOT Markdown —  does not work, because an ADF media node needs the opaque media-services UUID of an uploaded file. Use the bundled helper scripts/jira-attach-media.ts instead (upload → resolve UUID → emit/publish the mediaSingle > media node). Example: bun scripts/jira-attach-media.ts BUG-123 ./repro.png --caption "Repro step 3" --publish. Full recipe + when-to-use in references/adf-authoring-style.md §media.
Out of scope (extend the converter if your project needs them): nestedExpand (expand inside a table cell).
This section covers HOW Markdown becomes ADF. For WHEN to reach for a table vs a panel vs a nested list — i.e. how to make field content visually scannable instead of flat prose — see
references/adf-authoring-style.md. Workflow skills cite that file at each point they fill a Jira rich-text field.
Validation gate (fail fast before Jira)
The converter validates its output by default against an embedded ADF allowlist, then refuses to write and exits non-zero if the document is invalid. This turns an opaque Jira HTTP 400 INVALID_INPUT at publish time into a node-level diagnostic at author time. The gate is zero-dependency — it does NOT use @atlaskit/adf-utils (that package transitively pulls ProseMirror + Statsig and breaks the converter's zero-dep contract). The rules are inlined in md-to-adf.ts.
What it catches: unknown node types, unknown / invalid marks, code co-occurring with strong/em/strike/underline/subsup/textColor (the HTTP 400 combined-marks bug), heading level outside 1–6, missing link href, empty text nodes, illegal containment (e.g. a paragraph directly under a bulletList), and a malformed root (type ≠ doc or version ≠ 1).
# validate is on by default during conversion; bypass with --no-validate
bun .agents/skills/acli/scripts/md-to-adf.ts input.md out.adf.json --no-validate
# gate an ALREADY-assembled ADF doc (jq create payload field, or a REST PUT body)
bun .agents/skills/acli/scripts/md-to-adf.ts --check field.adf.json # exit 0 valid, 1 invalid
Recommended habit: after splicing ADF into a --from-json create payload or a REST PUT body (where the wrapper is assembled outside the converter), run --check on each ADF field before sending. The gate is necessary but not sufficient — a round-trip GET of the field after write is still the only way to catch server-side coercion (Jira silently drops some invalid nodes).
Recipe by Jira surface
| Surface | How to publish ADF | Notes |
|---|---|---|
description on workitem create |
--from-json payload, description key holds an ADF doc |
Custom-field values live in additionalAttributes of the same payload, same ADF shape |
description on workitem edit |
--description-file <file> accepts a JSON file containing an ADF doc |
acli auto-detects ADF vs plain text by file content |
Rich-text custom field on workitem create |
additionalAttributes.customfield_NNNNN = ADF doc inside --from-json |
Same shape as description |
| Rich-text custom field on an existing item | acli cannot do this — use REST PUT workaround. PUT /rest/api/3/issue/{KEY} with {"fields": {customfield_NNNNN: <ADF>}} via curl |
acli workitem edit hard-rejects additionalAttributes, fields, and flat customfield_X with ✗ Error: json: unknown field …. Confirmed empirically. See gotcha #4 + dedicated workaround section below. |
| Comment create | comment create --body-file <file> (alias -F) accepts ADF |
The --body (plain) flag remains plain text only |
| Comment update | comment update --body-adf <file> |
Dedicated ADF flag |
Worked end-to-end example
# 1. Author each rich-text field as Markdown
cat > /tmp/desc.md <<'MD'
## User Story
- As a user
- I want X
- So that Y
## Context
Some context paragraph with **bold** and `inline_code`.
MD
cat > /tmp/ac.md <<'MD'
## Scenario: happy path
Given a valid input
When the user submits
Then the response is 200 OK
MD
# 2. Convert each MD file to ADF JSON
bun .agents/skills/acli/scripts/md-to-adf.ts /tmp/desc.md /tmp/desc.adf.json
bun .agents/skills/acli/scripts/md-to-adf.ts /tmp/ac.md /tmp/ac.adf.json
# 3. Splice the ADF docs into the create-from-json payload
jq -n \
--arg pk "{{PROJECT_KEY}}" \
--slurpfile desc /tmp/desc.adf.json \
--slurpfile ac /tmp/ac.adf.json \
'{
projectKey: $pk,
type: "Story",
summary: "Example summary",
description: $desc[0],
labels: ["example"],
additionalAttributes: {
customfield_NNNNN: $ac[0]
}
}' > /tmp/story.json
# 4. Submit
acli jira workitem create --from-json /tmp/story.json --json
Batch pattern (many work items, many rich fields)
When the task is to populate N work items with M rich-text fields each, the converter scales linearly with negligible overhead. Recommended pattern:
- Write one generator script (
generate.ts) that holds the per-field Markdown content for every item as inline string literals. - The script imports
mdToAdfand converts every field in-process — no shell hop per conversion. - The script writes one
create --from-jsonpayload per item (/tmp/item-N.json). - A shell loop runs
acli jira workitem create --from-json /tmp/item-N.json --jsonper file, capturing the new key from stdout. - For comments, follow the same approach: write the comment Markdown inline, convert in-process, post with
acli jira workitem comment create -k <KEY> -F /tmp/comment-N.adf.json.
This pattern scales cleanly to dozens of items in one run. The bottleneck is authoring quality, not the conversion mechanic.
WORKAROUND: Editing rich-text custom fields on existing work items (REST PUT)
This is the only working path as of acli v1.3.18 — there is no acli-native channel for editing custom-field values on existing items. The recipe below is the turnkey workaround.
Prerequisites. Two env vars must be exported in the current shell. They are loaded automatically by the project tooling (bun claude, bun opencode, or direnv) from .env:
ATLASSIAN_EMAIL— the API-token owner's emailATLASSIAN_API_TOKEN— the API token paired with the email
The site host is not an env var. It lives in .agents/project.yaml ->
issue_tracker.atlassian_url, and the recipes below read it with
$(bun run --silent jira:url). It was pulled out of .env because a stale copy
inherited from the parent shell silently shadowed the file and pointed the sync
scripts at a dead Jira site. Never reintroduce ATLASSIAN_URL as a shell
variable in a recipe — resolve the host, do not interpolate it.
Recipe.
# 1. Author the new value as Markdown
cat > /tmp/new.md <<'MD'
## New content
- with **bold**, `inline code`, and a [link](https://example.com)
MD
# 2. Convert MD → ADF
bun .agents/skills/acli/scripts/md-to-adf.ts /tmp/new.md /tmp/new.adf.json
# 3. Wrap the ADF doc in the REST `{ "fields": { ... } }` envelope
# (NOTE: same ADF payload acli would consume; only the wrapper key changes)
jq -n --slurpfile adf /tmp/new.adf.json \
'{fields: {customfield_NNNNN: $adf[0]}}' > /tmp/put.json
# 4. PUT against the issue
curl -sS -w "\nHTTP %{http_code}\n" \
-u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \
-X PUT "$(bun run --silent jira:url)/rest/api/3/issue/{{PROJECT_KEY}}-123" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data-binary @/tmp/put.json
# Expected: HTTP 204 (Jira returns no body on a successful PUT)
Reference. Official Atlassian REST v3 PUT endpoint: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-put
Empirical proof this is the only path. Three variants tested against acli workitem edit --from-json on a real workitem:
Payload shape sent to acli edit |
Result |
|---|---|
{issues:[...], additionalAttributes:{customfield_X:<ADF>}} |
✗ Error: json: unknown field "additionalAttributes" · exit 1 |
{issues:[...], fields:{customfield_X:<ADF>}} |
✗ Error: json: unknown field "fields" · exit 1 |
{issues:[...], customfield_X:<ADF>} |
✗ Error: json: unknown field "customfield_X" · exit 1 |
Same ADF doc through REST PUT: HTTP 204 OK.
Batch variant. Loop the recipe per --data-binary @/tmp/put-N.json and capture HTTP codes:
for KEY in {{PROJECT_KEY}}-1 {{PROJECT_KEY}}-2 {{PROJECT_KEY}}-3; do
status=$(curl -sS -o /dev/null -w "%{http_code}" \
-u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \
-X PUT "$(bun run --silent jira:url)/rest/api/3/issue/$KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/put-"$KEY".json)
echo "$KEY -> HTTP $status"
done
When this becomes unnecessary. If Atlassian adds an additionalAttributes-style channel to acli workitem edit, retire this workaround and update the recipe table.
Why this is the default
- Authoring in Markdown is fast, reviewable in pull requests, diffable.
- The conversion is deterministic — the same Markdown always produces the same ADF tree.
- One workflow covers every rich-text surface uniformly: descriptions, comments, custom fields, all the same three steps.
- Identifier-heavy prose (snake_case, kebab-case) survives the conversion because the italic detection has word-boundary guards.
Anti-patterns — NEVER do these (tool-level)
These are tool-level anti-patterns intrinsic to the acli binary and its REST companion. They apply regardless of host repo. Gotchas describe surprising behavior to remember; anti-patterns describe actions to refuse outright. Both apply.
- T1. NEVER hand-author raw ADF JSON for descriptions, comments, or rich-text custom fields. Use
scripts/md-to-adf.ts— deterministic, diffable, snake_case-safe, and avoids the combined-marks bug (inlinecodeco-occurring withstrong/emcauses HTTP 400). - T2. NEVER hardcode Jira
customfield_NNNNNIDs in scripts or AI output that consumesacli. Resolve via the host project's slug catalog (see the host repo'sacli-integration.md). IDs differ per workspace; slugs travel. - T3. NEVER assume
acliaccepts custom-field input onworkitem edit. It hard-rejects every shape (additionalAttributes,fields, flatcustomfield_X) with exit 1. Use the RESTPUT /rest/api/3/issue/{KEY}workaround documented above — there is no acli-native path. - T4. NEVER run a bulk
aclimutation (transition, edit, comment, link, archive) without first verifyingacli jira auth status. Silent auth expiry cascades into HTTP 401s mid-loop, leaving the batch half-applied with no clean rollback.
Repo-specific anti-patterns (workflow abstraction, project-key portability, TMS modality boundaries, prod-workspace safety, CI batching, version pinning, sync-script auth) live in
<repo-core>/references/acli-integration.md. Load it whenever a session touches the host repo's Jira workflow.
Seven gotchas to keep in mind always
--paginateis opt-in. Default limit is server-side (30–50 depending on command). No warning on truncation. If you are counting, iterating, or making decisions based on the result, pass--paginate.- Custom fields on
workitem creatego throughadditionalAttributesin--from-json. Numeric IDs only (customfield_NNNN), no name-addressing. Documented value shapes in thecreatetemplate are:{"value": "..."}(single-select), bare number, bare string.workitem editactively REJECTS custom-field input — hard error, exit 1, not a silent drop (empirically confirmed acrossadditionalAttributes,fields, and flatcustomfield_Xshapes). For editing custom-field values on existing items, the only working path is RESTPUT /rest/api/3/issue/{KEY}viacurlusing the session env vars — see the "WORKAROUND" subsection in "Publishing rich text" above, plusreferences/gotchas.md§4 andreferences/workitem.md. aclicannot enumerate custom fields.acli jira fieldonly does create/update/delete/cancel-delete. To discover field IDs, useworkitem view --json | jqagainst an item that has the field set, or callGET /rest/api/3/fielddirectly. There is no in-CLI listing. Host repos typically cache the catalog under.agents/and resolve fields by slug — see<repo-core>/references/acli-integration.md.- Transitions match by status name, not transition ID. When two transitions lead to the same status with different validators, the CLI picks one and may fail. No
--transition-idescape hatch exists — fall back to REST if this hits. - Trace IDs are the only debug signal. An
unexpected error, trace id: XXXXXXXXline is all you get on backend failures. Capture and log the trace ID always; Atlassian Support needs it. workitem link createflag names are misleading —--outand--inare EMPIRICALLY INVERTED relative to Jira's outward/inward semantics. Runningacli jira workitem link create --out X --in Y --type Dependenciesproduces "Y depends on X" — NOT "X depends on Y" as the flag names suggest. Y becomes the outward party (the one that performs the outward verb, e.g. "depends on" / "blocks" / "causes"); X becomes the inward party. Confirmed empirically against Dependencies; the same inversion applies to ALL outward-asymmetric link types (Blocks, Blocking, Causes, Duplicate, Cloners, Defect, Test, Test Automation, Test Design, Test Execute). Symmetric types (Relates) are immune — direction is lost either way. Reverse-mapping rule of thumb:--outtakes the PREREQUISITE (the inward partner in Jira's UI);--intakes the DEPENDENT (the outward partner in Jira's UI). Mandatory verification after every link create: runacli jira workitem link list --key <expected-dependent> --jsonand confirm the response showsoutwardIssueKey: <expected-prerequisite>. If the direction is wrong, delete the link and recreate with swapped flags — delete first: Jira dedupes a link between the same pair and type regardless of direction, so adding the corrected link on top of the wrong one is a silent no-op. Deep recipe + per-type mapping table →references/workitem.md.- The vendor's own
--helpexamples are sometimes stale, and they fail exactly as a typo would.acli jira workitem comment create --helpprints its examples without thecreatesubcommand (acli jira workitem comment --key "KEY-1" --body "..."), which exits non-zero withunknown flag: --keybecause the flags live oncreate. An agent copying the vendor example loses a round trip and, worse, may conclude the command does not exist. Trust the forms inreferences/workitem.mdover the binary's examples; two other fields document narrower behaviour than they have (parentIssueIddescribes itself as sub-task-only and parents to an Epic fine). Also note what is NOT there:workitem edithas no components flag at all.
Top-level utilities
Quick-reference for the top-level surface that doesn't fit under a product. None of these need a separate reference file — they're documented here in full.
acli completion — shell autocompletion
acli completion bash > /etc/bash_completion.d/acli
acli completion zsh > "${fpath[1]}/_acli"
acli completion fish > ~/.config/fish/completions/acli.fish
acli completion powershell > acli.ps1
Each subcommand prints a shell script to stdout. Pipe to the location your shell expects (above are the conventional paths).
acli feedback — report a problem to Atlassian
acli feedback \
--summary "JSON shape on edit --generate-json is misleading" \
--details "The template doesn't include additionalAttributes for custom fields..." \
--email "you@example.com" \
--time "1h" \
--attachments error.log,trace.txt
Flags: -s, --summary (required-ish), -d, --details (required-ish), -e, --email, -t, --time (estimated timeframe like 1h, 15m), -a, --attachments (multiple files).
acli auth — global OAuth (newer surface)
A top-level OAuth login that authenticates across products in one step. Distinct from per-product jira auth / admin auth / confluence auth. Use the per-product login for token-based CI; use the global one for interactive multi-product browsing.
acli auth login # interactive OAuth
acli auth status
acli auth switch
acli auth logout
See references/auth.md for the full auth model.
acli config gov-cloud — Atlassian Government Cloud
acli config gov-cloud --enable
acli config gov-cloud --status
Niche — only relevant if your org is on Atlassian Government Cloud. Not used in standard commercial Jira/Confluence.
Navigation — when to load which reference
Load the reference that matches the user's current need. Do not preload all of them.
| If the user wants to… | Load |
|---|---|
| Log in, switch sites, handle tokens, authenticate in CI | references/auth.md |
| Work with Jira tickets (create, edit, transition, search, bulk, comments, links, watchers, custom-field shapes) | references/workitem.md |
| Manage projects, boards, sprints, filters, dashboards, custom-field definitions | references/project-board-sprint.md |
| Work with Confluence spaces, blogs, pages | references/confluence.md |
| Run org-level admin tasks (API key, user lifecycle) | references/admin.md |
| Pipe output, produce JSON/CSV, dry-run, run on CI/CD | references/output-and-automation.md |
| Diagnose surprising behavior, known bugs, REST fallback points | references/gotchas.md |
| Publish rich text to descriptions, comments, or custom fields | Inline section "Publishing rich text" + scripts/md-to-adf.ts |
| Make Jira field content visually excellent (when to use tables / panels / nested lists for readability) | references/adf-authoring-style.md |
Plug acli into the host repo's workflow (TMS modality, slug catalog, project conventions, anti-patterns specific to this repo) |
<repo-core>/references/acli-integration.md |
Working style
Default to Markdown authoring for any rich-text field. Never pass raw markdown to
--description,--body, or any custom-field value —aclidoes not convert markdown. Usescripts/md-to-adf.tsto produce ADF, then pass the JSON. See "Publishing rich text" above.Prefer API-token auth in scripted contexts.
--web/ OAuth is for humans at a terminal.Always pass
--yesin CI for any mutating command (where the flag exists).Always pass
--paginatewhen a downstream script consumes the result.Scaffold complex payloads with
--generate-json(create, edit, project create, project update, link create, create-bulk). Pipe to a file, edit, submit with--from-json. Note:--generate-jsonis static — it does NOT introspect the actual project schema, so for custom-field shapes you may need to view a real item.Capture the trace ID on any failure and surface it when reporting to the user.
Do not invent flags. When unsure, run
acli <path> --help— it is authoritative and version-pinned to the installed binary. Convention: every multi-word flag is kebab-case (--from-json,--searcher-key,--filter-id,--order-by). camelCase variants will fail.Verify subcommand existence before assuming. Unknown subcommands silently fall back to parent help with exit 0 — they do NOT error. Read the help body, don't trust the exit code.
Know what
aclicannot do. All of the following require REST or MCP —aclidoes not cover them as of v1.3.18:- Enumerate custom fields (
fieldhas nolist). - Edit custom-field values on existing work items (
workitem editdoes not document custom-field input). - Manage workflows, workflow schemes, statuses, or transition definitions.
- Manage issue types, priorities, resolutions, project versions, project components.
- Add a work item to a sprint (
JRACLOUD-97107). - Upload attachments, add watchers.
- Retrieve the cached auth token for reuse in another tool.
- Bitbucket operations (out of scope entirely).
- Confluence page CRUD beyond
page view(as of v1.3.18 — space and blog have full CRUD).
See
references/gotchas.mdfor the full list with REST recipes.- Enumerate custom fields (
Installation (reference only)
Users usually already have acli installed. If not, point them at:
- Official guide: https://developer.atlassian.com/cloud/acli/guides/install-acli/
- macOS:
brew tap atlassian/homebrew-acli && brew install acli - Linux (Debian/Ubuntu):
apt install acli(after adding the Atlassian apt repo) - Linux (RHEL/Fedora):
yum install acli(after adding the Atlassian yum repo) - Windows: PowerShell
curlinstall (no Chocolatey/MSI yet) - CI one-liner (Linux):
curl -LO "https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli" && chmod +x acli
Pin to a version URL in production pipelines — latest/ has caused same-day mass failures. Each release is supported for six months. Run acli --version to check.
Files (agentic-qa-boilerplate)
-
evals
-
evals.json 4.9 KB
{ "evals": [ { "name": "should-trigger-transition-via-cli", "prompt": "Use acli to transition {{PROJECT_KEY}}-123 to In Review now that the PR is open.", "expected_behavior": "Activates acli skill. Reads SKILL.md for the transition section, then references/workitem.md §transition. Runs `acli jira workitem transition --key {{PROJECT_KEY}}-123 --status \"In Review\"`. If the user has not authenticated, suggests `acli jira auth status` first and points at references/auth.md.", "category": "positive" }, { "name": "should-trigger-create-bug-from-cli", "prompt": "Create a Bug from acli — title 'Login button does nothing on Safari 17', parent {{PROJECT_KEY}}-123, project {{PROJECT_KEY}}, assign to me.", "expected_behavior": "Activates acli skill. Loads references/workitem.md §create. Runs `acli jira workitem create --project {{PROJECT_KEY}} --type Bug --summary \"Login button does nothing on Safari 17\" --parent {{PROJECT_KEY}}-123 --assignee \"@me\"`. Reports the new bug key. Does NOT use Test / Test-Execution issue types (those are TMS-side and out of scope for this skill).", "category": "positive" }, { "name": "should-trigger-fetch-story-details", "prompt": "Pull {{PROJECT_KEY}}-123 from Jira via acli — I need the ACs, scope and mockup link.", "expected_behavior": "Activates acli skill. Loads references/workitem.md §view. Runs `acli jira workitem view {{PROJECT_KEY}}-123 --json` and pipes through jq to extract the relevant custom fields (`customfield_NNNN` placeholders). Notes that real custom-field IDs are workspace-specific and that the host repo's `<repo-core>/references/acli-integration.md` typically holds a slug → ID catalog.", "category": "positive" }, { "name": "should-trigger-bulk-transition-jql", "prompt": "Use acli to bulk-transition every {{PROJECT_KEY}} story that has fixVersion 2026.05 and is in Ready For QA over to Done.", "expected_behavior": "Activates acli skill. Reads SKILL.md selector pattern + references/workitem.md §transition. Runs `acli jira workitem transition --jql \"project = {{PROJECT_KEY}} AND fixVersion = '2026.05' AND status = 'Ready For QA'\" --status \"Done\" --yes --ignore-errors`. Warns about silent pagination and rate limits per references/gotchas.md (#1, #16). Suggests the dry-run preview pattern from references/output-and-automation.md before mutating.", "category": "positive" }, { "name": "should-trigger-spanish-acli-trigger", "prompt": "Necesito loguearme en acli para mi sitio de Atlassian — paso el token por env, ¿cómo es?", "expected_behavior": "Activates acli skill. Mirrors Spanish in conversation. Loads references/auth.md. Explains the API-token path: `echo \"$ATLASSIAN_API_TOKEN\" | acli jira auth login --site \"$(bun run --silent jira:url --slug)\" --email \"$ATLASSIAN_EMAIL\" --token`. Names the TWO env vars (ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN) and states that the site host is NOT an env var: it comes from .agents/project.yaml -> issue_tracker.atlassian_url, read via `bun run jira:url` (`--slug` for the bare host acli --site needs). Does NOT suggest interpolating an ATLASSIAN_URL shell variable, nor deriving the slug by stripping https:// off one. Notes that --token reads only from stdin (pipe/redirect/here-string).", "category": "positive" }, { "name": "should-not-trigger-playwright-tests", "prompt": "Write Playwright E2E tests for the login flow.", "expected_behavior": "Does NOT activate acli skill. Should route to a browser-automation / E2E-testing skill instead. acli is the issue-tracker CLI — it does not write or run tests.", "category": "negative" }, { "name": "should-not-trigger-end-to-end-orchestrator", "prompt": "Implement {{PROJECT_KEY}}-123 — full sprint loop, plan to code to review to deploy.", "expected_behavior": "Does NOT activate acli skill as the entry point. Should route to the host repo's end-to-end story-orchestrator workflow skill (e.g. a sprint-development / sprint-testing mega-orchestrator). That orchestrator will internally call acli for the Jira transitions (Ready For Dev → In Progress → In Review → Ready For QA), but the user-facing entry point is the orchestrator, not the CLI primitive.", "category": "negative" }, { "name": "should-not-trigger-backlog-grooming", "prompt": "Seed the backlog with 30 stories from the PRD — refine ACs, INVEST check, ready-for-dev checklist.", "expected_behavior": "Does NOT activate acli skill as the entry point. Should route to a product-management / backlog-grooming workflow skill because this is backlog seeding + AC refinement, not raw CLI work. That workflow may delegate the bulk-create step to acli (`workitem create-bulk --from-csv`), but the orchestration, INVEST check, and Gherkin authoring belong to the workflow skill.", "category": "negative" } ] }
-
-
references
-
adf-authoring-style.md 14.6 KB
# ADF authoring style — making Jira field content visually excellent The bundled converter (`scripts/md-to-adf.ts`) and the "Publishing rich text" section in `SKILL.md` cover the **mechanics** — how Markdown becomes ADF and reaches a Jira field. `references/gotchas.md` covers what **breaks**. This file covers what makes field content **good**: when to reach for a table, a panel, a nested list, or a heading so a reader scanning the Jira UI grasps the content fast — instead of a wall of flat prose. It is a **style guide, not a mandate generator**. It teaches the generic palette and the decision rules. *Which* structure suits *which* field (an ATP body vs an acceptance-criteria field vs a scope list) is domain knowledge owned by the consuming workflow skill — this file is what those skills cite so the generic doctrine stays single-source and DRY across every field and both boilerplates. > **Doctrine anchor**: the host `AGENTS.md` already prescribes a **Visual Mapping Bias** for the AI's own replies — "prefer a table / diagram over a paragraph when content is naturally mappable." This file extends that exact belief to the artifacts the AI writes *into Jira*. Same philosophy, new surface. It is not new doctrine — it is consistency. ## Table of contents 1. [The one principle: richness with purpose](#principle) 2. [Field hard-rules always win](#hard-rules) 3. [The block palette — when to reach for each](#palette) 4. [Before / after — flat vs structured](#before-after) 5. [The publish path + what can break](#publish) 6. [How a consuming skill cites this file (the thin-hook contract)](#contract) ## <a id="principle"></a>1. The one principle: richness with purpose Structure earns its place by making the content **faster to read**, not by decorating it. A table that replaces six parallel bullets is a win; a table wrapping a single value is noise. Before adding any block, ask: *does this help a tester / PO / dev scan the field faster?* If not, plain prose or a simple list is the right answer. Three failure modes to avoid: - **Decoration** — panels and tables added for visual flair, not comprehension. A one-row "table", a panel holding one sentence that a `**bold**` line would carry. - **Over-nesting** — four indent levels where two suffice. Depth is a cost the reader pays. - **Fighting the field's law** — see §2. Some fields mandate a fixed shape; richness must live *around* it, never replace it. The bar: **every block must replace prose that would be slower to read.** Visual form *replaces* prose; it does not sit alongside it as ornament. ## <a id="hard-rules"></a>2. Field hard-rules always win A field may carry a **hard format law** defined by its consuming skill — a fixed shape that must not be overridden. The most common example: an acceptance-criteria field that requires every scenario wrapped in a fenced ` ```gherkin ` block (that fence is the only shape that renders monospaced + highlighted in the Jira ADF view). When a field has such a law: - The law's shape is non-negotiable. **Never** replace a mandated Gherkin block with a table because a table "looks cleaner." - Enrichment is allowed only in the **free** regions of that field — e.g. a short intro heading above the scenarios, or a panel calling out a shared precondition *between* fenced blocks — and only if the consuming skill permits it. - When unsure whether a field has a law, **default to the field's documented template** and add nothing. The consuming skill's reference is the authority; this file never overrides it. Precedence, top wins: ``` field hard-rule (consuming skill) > this style guide > author preference ``` ## <a id="palette"></a>3. The block palette — when to reach for each Every block below is emitted by the bundled converter from ordinary Markdown — author Markdown, never hand-write ADF JSON (anti-pattern T1). The exact Markdown the converter accepts is in `SKILL.md` → "Covered markdown subset". | Block | Reach for it when… | Do NOT use it for… | Markdown you write | |---|---|---|---| | **Table** | comparing items across the same dimensions; any grid (test step → expected, field → value, option → trade-off, criteria matrix); ≥3 rows that share columns | a single key/value pair; one row; free-flowing narrative | `\| H1 \| H2 \|` then `\|---\|---\|` then rows | | **Nested list** | genuine hierarchy (phase → sub-task, precondition → detail); 2 levels deep, rarely 3 | flat peers (use a single-level list); faking a table | indent 2 spaces under the parent item | | **Panel** | one callout that must not be missed — a risk, a blocking precondition, a "results invalidated if…" warning | routine content; more than ~2 per field (callout inflation kills the signal) | `> [!WARNING]` / `[!NOTE]` / `[!INFO]` / `[!SUCCESS]` / `[!ERROR]` then `> body` | | **Heading** | breaking a long field (impl plan, ATP body) into scannable sections | a field under ~1 screen; replacing what a bold lead-in does | `## Section` / `### Subsection` | | **Code block** | commands, payloads, API responses, IDs, config — anything monospaced or copy-pasted; Gherkin scenarios (fenced) | ordinary prose; emphasis (use bold) | ` ```lang … ``` ` | | **Blockquote** | quoting a source — a stakeholder line, a spec excerpt, an error message verbatim | callouts (use a panel); general emphasis | `> quoted line` | | **Expand** | long supporting detail that would bury the main content — full logs, an exhaustive enumeration, optional deep-dive | content the reader needs up front (expands hide it behind a click) | `<details><summary>Title</summary>` … `</details>` | | **Bold / inline code** | a key term, a literal value, an identifier inline | whole sentences; never put inline `code` *inside* `**bold**` — Jira rejects the combined marks (HTTP 400) | `**term**`, `` `value` `` | | **Emoji** (Jira-native) | a per-line status mark in a checklist or report so a human reads pass/fail/pending at a glance | sprinkling for tone; more than one idea per line | `:white_check_mark:` `:x:` `:warning:` … any `:short_name:` | | **Status lozenge** | a transition/lifecycle state as a coloured pill — `DONE`, `IN PROGRESS`, `BLOCKED`, `TODO` | ordinary emphasis; a value that is not a state | `{status:green\|DONE}` (colors: `neutral` `purple` `blue` `red` `yellow` `green`) | Panel-type semantics (GitHub-alert keyword → ADF `panelType`): `[!NOTE]`/`[!INFO]` → info (blue) · `[!TIP]`/`[!SUCCESS]` → success (green) · `[!IMPORTANT]` → note (purple) · `[!WARNING]` → warning (yellow) · `[!CAUTION]`/`[!ERROR]` → error (red). Pick the colour that matches the *meaning*, not the one that looks nicest. **Emoji & status — the curated set for reports.** A report or checklist where the AI marks each item reads far better with a glyph or a coloured pill than with the word "passed". Keep to this small, meaningful set — do not flood content with emoji: | Intent | Emoji (`:short_name:`) | Status lozenge | |---|---|---| | pass / done | `:white_check_mark:` ✅ | `{status:green\|DONE}` | | fail | `:x:` ❌ | `{status:red\|FAIL}` | | in progress | `:hourglass_flowing_sand:` ⏳ | `{status:yellow\|IN PROGRESS}` | | pending / to-do | `:white_circle:` ⚪ | `{status:neutral\|TODO}` | | blocked | `:no_entry:` ⛔ | `{status:red\|BLOCKED}` | | warning / risk | `:warning:` ⚠️ | — | | note / info | `:information_source:` ℹ️ | `{status:blue\|INFO}` | Use the **lozenge** for a single transition state of the whole item (a pill reads as a state); use the **emoji** for a per-line mark inside a list or table cell (a glyph reads as a tick). A checklist with a leading `:white_check_mark:` / `:x:` per line is exactly the high-value case — a human scans the list and sees every item's status without reading a word. **Mentions — resolve the `accountId` first (one external step).** A mention needs the target's opaque Atlassian `accountId`, not their name — Jira has no way to resolve a bare `@name`. The converter emits the node from an explicit `@[Display Name](accountId)`; you supply the id, resolved out-of-band once: ```bash # by email (exact match) curl -sS -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ "$(bun run --silent jira:url)/rest/api/3/user/search?query=person@example.com" | jq -r '.[0].accountId' # your own account curl -sS -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ "$(bun run --silent jira:url)/rest/api/3/myself" | jq -r '.accountId' ``` Then author `@[Person Name](<accountId>)`. Verified live: the node round-trips as `{id:<accountId>, text:"@Name", accessLevel:""}` — a real, notifying tag. Use mentions sparingly (a mention pings the person); reserve them for assignment / hand-off / blocker call-outs, not decoration. **Issue links — real links, never bare keys.** A plain-text issue key (`UPEX-512`) in an ADF body does NOT auto-link — it publishes as inert text the reader cannot click (unlike the legacy wiki renderer). Any mention of a **blocking, filed, or referenced ticket** inside a comment / ATR / description body MUST be authored as a real Markdown link to the issue's browse URL — `[UPEX-512: short title](https://<your-site>.atlassian.net/browse/UPEX-512)` — which the converter emits as a `link` mark and the Jira UI upgrades to a smart-link card. Bare keys are acceptable only inside code blocks and in machine-read artifact ID lists (e.g. the `Artifacts:` line) where the consuming skill says so. This is doubly binding for blockers: a blocking bug named as plain text defeats the point of naming it — the reader must be one click from the blocker. (The traceability issuelink per `agentic-qa-core/references/traceability-linking.md` is a separate, additional requirement — the link mark aids the human, the issuelink feeds the graph; never substitute one for the other.) **Media (images / videos) — upload first, then embed (use the helper).** `` does NOT work in Jira ADF: a media node needs the opaque media-services UUID of an uploaded file, which the public attachments API does not hand back directly. The bundled helper `scripts/jira-attach-media.ts` runs the verified 3-step recipe (upload attachment → resolve the UUID from the attachment-content redirect → build the `mediaSingle > media` node) so you never assemble it by hand: ```bash # attach a screenshot to a bug AND post it as an evidence comment in one call bun .agents/skills/acli/scripts/jira-attach-media.ts BUG-123 ./repro-step-3.png \ --caption "Repro step 3 — validation error not shown" --publish # or just emit the media node JSON to splice into a larger ADF body you are assembling bun .agents/skills/acli/scripts/jira-attach-media.ts BUG-123 ./diagram.png --doc > media.adf.json ``` The helper auto-detects PNG / JPEG / GIF dimensions (pass `--width`/`--height` for video or other formats), and `collection` is always stored as `""` (Jira ignores the input). Reach for media when a picture genuinely beats words — a bug screenshot, a failing-UI capture, an architecture diagram — not for decoration. The image must be uploaded to the *same issue* it is embedded in. ## <a id="before-after"></a>4. Before / after — flat vs structured **Test steps** — parallel data across the same columns → a table out-scans bullets every time: ``` Before (flat): After (table): - Step 1: open login, expect form | # | Action | Expected | - Step 2: submit blank, expect error |---|-------------------|-----------------| - Step 3: submit valid, expect redirect | 1 | Open login | Form renders | | 2 | Submit blank | Validation error| | 3 | Submit valid | Redirect to home| ``` **A risk that must not be missed** — paragraph buries it; a panel makes it unmissable: ``` Before: Note that results are invalid if run before the nightly sync completes. After: > [!WARNING] > Results are invalid if the suite runs before the nightly sync completes. ``` **Multi-level workflow** — real hierarchy → nested list, not flattened prose: ``` - Checkout - validate cart - reserve stock - Payment - authorize - capture ``` ## <a id="publish"></a>5. The publish path + what can break The style choices above are authored as Markdown, then converted and published through the standard path — there is no separate mechanism for "rich" content: 1. Author the field content as Markdown (using the palette above). 2. Convert + validate: `bun scripts/md-to-adf.ts field.md field.adf.json` (the validator gates structure before publish). 3. Publish via the matching surface (`--description-file`, `comment create -F`, `--from-json` `additionalAttributes`, or REST `PUT` for a custom field on an existing item). Full recipe table in `SKILL.md` → "Publishing rich text". Two failure modes that bite at publish time, not author time — read `references/gotchas.md` before publishing ADF: - **Combined marks** — inline `code` co-occurring with `strong`/`em` → HTTP 400. Keep code spans outside bold/italic. - **Batched custom fields via MCP** — the MCP variant of the issue-tracker tool silently drops ADF conversion on batched custom-field updates. Publish rich custom fields one at a time, then round-trip `GET` to confirm Jira stored the nodes (it silently coerces some). ## <a id="contract"></a>6. How a consuming skill cites this file (the thin-hook contract) Workflow skills do **not** restate the palette. They add a **thin hook** at each point where they instruct the AI to fill a Jira rich-text field — one or two lines that (a) point here for the generic rules and (b) name the structure(s) that suit *that* field's content. Shape of a hook: > When writing `{{jira.<field>}}`, format per `acli/references/adf-authoring-style.md`. For this field, prefer **<structure>** for <content shape> — but the field's hard-rule (if any) wins (§2). Examples of field-appropriate hooks a consuming skill might carry (the skill owns these, not this file): | Field (illustrative) | Suggested structure | Note | |---|---|---| | Test steps / ATP scenarios | table (step → expected) | per the law of the field if it mandates Gherkin | | ATR / results summary | table (case → status) + panel for blockers | | | Scope / Out-of-Scope | single-level list; nested only for real sub-scopes | | | Business rules | nested list or table for rule → boundary | | | Implementation plan | headings per section; table for option trade-offs; panel for risk | | | Acceptance criteria | **fenced Gherkin only** — hard-rule, do not enrich the scenarios | §2 | The authority for *which* structure a given field uses is the consuming skill's own reference. This file is the *how* and *when*; the skill supplies the *which*. That separation is what keeps the doctrine DRY and the field semantics where they belong. -
admin.md 3.5 KB
# Admin (`acli admin`) Org-level operations. Authenticated by an API key, not a user API token. This is rarely used from a DEV workflow — most stories never need org-level user lifecycle. Documented here for completeness; the common DEV moment to reach for `admin` is a one-off "deactivate departed teammate" or "onboard a new dev" task. Subcommands: `auth`, `user`. ## auth Parallel to `acli jira auth` but requires an **organization API key** instead of an Atlassian account API token. ```bash # Login — reads API key from stdin echo "$ATLASSIAN_ADMIN_API_KEY" | acli admin auth login --email admin@example.com --token # Status / switch / logout acli admin auth status acli admin auth switch acli admin auth logout ``` Create the API key at `admin.atlassian.com → Settings → API Keys`. It is organization-wide and carries directory-manager permissions — treat it with more care than a personal API token. A session authenticated via `acli jira auth login` does not permit `admin user activate` and vice versa. Each product keeps a separate credential. ## user Manage directory users at the org level. Subcommands: `activate`, `deactivate`, `delete`, `cancel-delete`. ### activate ```bash # By email (comma-separated) acli admin user activate --email alice@example.com,bob@example.com # By Atlassian account ID acli admin user activate --id 5b10ac8d82e05b22cc7d4ef5,5c10ac8d82e05b22cc7d4ef6 # From a file (one email per line or comma-separated) acli admin user activate --from-file users.txt --ignore-errors --json ``` Flags: | Flag | Meaning | | ----------------- | ------------------------------------- | | `-e, --email` | Comma-separated emails | | `--id` | Comma-separated Atlassian account IDs | | `-f, --from-file` | File containing emails or IDs | | `--ignore-errors` | Continue past per-user failures | | `--json` | JSON output | ### deactivate Identical flag shape to `activate`. Deactivation is reversible via `activate`. ```bash acli admin user deactivate --email former@example.com acli admin user deactivate --from-file leavers.txt --ignore-errors --json ``` ### delete / cancel-delete `delete` schedules a user for permanent removal (with a grace period). `cancel-delete` reverses the request while it is still pending. ```bash acli admin user delete --email gone@example.com acli admin user cancel-delete --email gone@example.com # Bulk via file acli admin user delete --from-file leavers.txt --ignore-errors --json ``` **Note**: `admin user delete` and `admin user cancel-delete` do NOT accept `--yes`. The flag list is `--email, --from-file, --id, --ignore-errors, --json`. Use `--ignore-errors` for batch resilience. The commands run non-interactively by default (no confirmation prompt). ## Common patterns ### Bulk onboarding from a CSV ```bash # Extract email column with awk, feed into activate awk -F',' 'NR > 1 {print $2}' new-hires.csv > emails.txt acli admin user activate --from-file emails.txt --json > activation.json # Capture failures by diffing the input against the success list jq -r '.succeeded[].email' activation.json | sort > succeeded.txt sort emails.txt > expected.txt comm -23 expected.txt succeeded.txt > failed.txt ``` ### Offboarding sweep ```bash acli admin user deactivate --from-file departed.txt --ignore-errors --json > deactivation.log ``` `--ignore-errors` is essential — a single already-deactivated user should not abort the whole batch. -
auth.md 10.1 KB
# Authentication `acli` has **four auth namespaces**, each scoped independently. Logging in to one does **not** authenticate the others — every scope keeps its own session. ## Auth namespaces at a glance | Namespace | Command path | Credential | What it authenticates | | ------------ | ---------------------------- | ------------------------------ | ---------------------------------------------- | | Jira | `acli jira auth login` | Atlassian account API token | All `acli jira *` commands | | Confluence | `acli confluence auth login` | Atlassian account API token | All `acli confluence *` commands | | Org admin | `acli admin auth login` | Org admin API key | `acli admin user *` (directory ops) | | Global OAuth | `acli auth login` | Browser redirect (interactive) | Cross-product OAuth — newer, top-level surface | Three credential mechanics are available depending on the namespace: | Mechanic | Use | Where | | --------- | ------------------------------------------- | ------------------------------------------- | | API token | Scripts, CI, anywhere non-interactive | `jira auth login` · `confluence auth login` | | OAuth | Human at a terminal, multi-site exploration | `jira auth login --web` · `acli auth login` | | API key | Org-level admin commands | `admin auth login` | For most workflows, the Jira namespace covers 99% of the surface. Confluence is occasional (e.g. publishing release notes). Admin is rare (org-wide user lifecycle). ## API token (the scriptable path) Generate the token at https://id.atlassian.com/manage-profile/security/api-tokens. ```bash # Read token from stdin (most portable) echo "$ATLASSIAN_API_TOKEN" | acli jira auth login \ --site "<your-site>.atlassian.net" \ --email "you@example.com" \ --token # Read from a file acli jira auth login \ --site "<your-site>.atlassian.net" \ --email "you@example.com" \ --token < token.txt # Windows PowerShell Get-Content token.txt | .\acli.exe jira auth login ` --site "<your-site>.atlassian.net" ` --email "you@example.com" ` --token ``` `--token` has no argument form — it always reads from stdin. Any of pipe, redirect, or here-string works. ## OAuth (interactive only) ```bash acli jira auth login --web ``` Opens a browser. The user picks the target site in the browser, then picks it again in the terminal — both must match. Two pieces to know: - **You cannot pre-select a site for `--web`.** Atlassian confirmed the site list is populated dynamically from the logged-in user's memberships. OAuth is therefore **unsuitable for CI**. - **On WSL / remote shells, the callback can hang** because the browser launches on the host and the localhost callback cannot reach the WSL process. Fall back to API token. ## API key (org admin) Generate at `admin.atlassian.com → Settings → API Keys`. ```bash echo "$ATLASSIAN_ADMIN_API_KEY" | acli admin auth login \ --email "admin@example.com" \ --token ``` > **Naming note**: `ATLASSIAN_ADMIN_API_KEY` is an organisation-scoped admin key, distinct from the regular per-user `ATLASSIAN_API_TOKEN`, and is only needed for ad-hoc org-admin sessions. Generate and export it for the one shell that runs `acli admin` commands; do not commit it. The API key path is independent of `jira auth`. A session authenticated as a Jira user cannot run `admin user activate`. ## Confluence (`acli confluence auth`) Same shape as `jira auth` — same flag set, same credentials (Atlassian account API token). Maintains a session independent of `jira auth`. ```bash echo "$ATLASSIAN_API_TOKEN" | acli confluence auth login \ --site "<your-site>.atlassian.net" \ --email "you@example.com" \ --token acli confluence auth status acli confluence auth switch --site mysite.atlassian.net --email you@example.com acli confluence auth logout ``` The token is the same one you'd use for Jira (Atlassian-account-scoped, generated at https://id.atlassian.com/manage-profile/security/api-tokens). You typically log in to both `jira` and `confluence` separately, even with the same credentials, because the sessions are stored independently. ## Global OAuth (`acli auth`) Newer top-level surface that does an OAuth login covering multiple products at once. Distinct from per-product `jira auth` / `confluence auth` / `admin auth`. ```bash acli auth login # interactive OAuth — opens a browser acli auth status acli auth switch acli auth logout ``` When to use which: - **`acli auth login`** (global): interactive multi-product setup at a desk. After this, both `jira` and `confluence` operations work without per-product `auth login` calls — handy for ad-hoc human use. - **Per-product `<product> auth login --token`**: scripted / CI use. Token-based auth is per-product on purpose so a leaked Jira token can't also touch Confluence. If both global and per-product sessions exist, the per-product session takes precedence for that product's commands. ## Status / switch / logout ```bash acli jira auth status # show current Jira account acli admin auth status # show current admin account acli jira auth switch # interactive: choose from stored sessions acli jira auth switch --site mysite.atlassian.net --email you@example.com acli jira auth logout ``` Sessions are persisted across shells — once logged in, new terminals reuse the session. The storage location is internal (typically `~/.config/acli/` on Linux, the system keyring on macOS) and **there is no supported way to retrieve the stored token back for reuse in another tool**. If your workflow also needs raw REST, keep a separate basic-auth token. ## Multi-site workflows Users with access to multiple Atlassian sites can store several sessions and switch between them: ```bash # Login to site A echo "$TOKEN_A" | acli jira auth login --site a.atlassian.net --email you@example.com --token # Login to site B (does not replace A) echo "$TOKEN_B" | acli jira auth login --site b.atlassian.net --email you@example.com --token # Show the active session acli jira auth status # Switch acli jira auth switch --site b.atlassian.net ``` If the same email is registered on multiple sites, always pass **both** `--site` and `--email` to `switch` — otherwise the CLI prompts interactively. ## CI patterns Three rules for CI: 1. **Use API-token auth only.** OAuth cannot be automated. 2. **Inject the token via a secret variable**, never commit it. 3. **Use a bot account**, not a human account, so rotations do not break pipelines. ### GitHub Actions ```yaml - name: Install acli run: | curl -LO "https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli" chmod +x ./acli sudo mv ./acli /usr/local/bin/acli - uses: oven-sh/setup-bun@v2 - name: Authenticate to Jira env: ATLASSIAN_EMAIL: ${{ vars.ATLASSIAN_EMAIL }} ATLASSIAN_API_TOKEN: ${{ secrets.ATLASSIAN_API_TOKEN }} run: | echo "$ATLASSIAN_API_TOKEN" | acli jira auth login \ --site "$(bun run --silent jira:url --slug)" \ --email "$ATLASSIAN_EMAIL" \ --token ``` **The site host is NOT a CI variable.** It comes from the checked-out `.agents/project.yaml` via `bun run jira:url`, so there is no repository variable to update on a site migration and nothing to drift out of sync with the repo. Only the two credentials are injected: `ATLASSIAN_EMAIL` (a var) and `ATLASSIAN_API_TOKEN` (a secret). `--site` wants the BARE host, hence `--slug`. The old recipe derived it by stripping the `https://` prefix off the `ATLASSIAN_URL` variable with shell parameter expansion, which silently no-ops on an `http://` value and leaves a trailing slash in place; `jira:url --slug` handles both. If a runner genuinely cannot run `bun`, read the field directly instead — still from the repo, never from a CI variable: ```bash SITE=$(sed -n 's/^[[:space:]]*atlassian_url:[[:space:]]*"\{0,1\}https\{0,1\}:\/\/\([^"[:space:]#]*\).*/\1/p' .agents/project.yaml) ``` Convention: `ATLASSIAN_EMAIL` / `ATLASSIAN_API_TOKEN` are the only Atlassian credentials — no bot-prefixed names, no separate `ATLASSIAN_SITE` variable, and no local `ATLASSIAN_URL`. Pin the version in the URL (`1.3.18/` instead of `latest/`) — unpinned installs have caused same-day mass failures in the past. ### Bitbucket Pipelines (Atlassian's own sample) ```yaml image: atlassian/default-image:3 pipelines: default: - step: name: Authenticate & run script: - bash install-acli.sh - echo "$BOT_API_TOKEN" | ./acli jira auth login --email "$BOT_EMAIL" --site "$SITE" --token - ./acli jira workitem search --jql "project = $PROJECT AND updated > -1d" --paginate --csv > changes.csv ``` ### GitLab CI Same pattern — inject token via `CI_VARIABLES`, call the install script first, then authenticate via stdin. ## Common auth failures | Error | Most likely cause | Fix | | -------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- | | `unauthorized: use acli jira auth login to authenticate` | Session expired, wrong product, or missing login. | Re-run `acli jira auth login`. Check you authenticated the correct product. | | `--web` never completes after "Accept" | Callback blocked (WSL / remote shell / firewall). | Switch to `--token` path. | | `forbidden` on an admin command | You authenticated `jira`, not `admin`. | Run `acli admin auth login` with an API key. | | Token rejected after rotation | Cached credential points at the old token. | `acli jira auth logout` then log in again. | -
confluence.md 15.1 KB
# Confluence Cloud (`acli confluence`) Confluence surface added in `acli` v1.x. Coverage as of v1.3.18: | Group | Subcommands | Completeness | | ------- | ------------------------------------------------- | -------------------------------------------------- | | `auth` | login · logout · status · switch | Full | | `space` | archive · create · list · restore · update · view | Near-full (no `delete` — only archive/restore) | | `blog` | create · list · view | Read + create only (no update / delete) | | `page` | view | **Read only** — no create / update / delete / list | **Critical limitation**: `page` is read-only. To create or modify Confluence pages from the CLI you must fall back to REST (`POST/PUT/DELETE /wiki/api/v2/pages`). Despite this, blog posts and spaces have a usable CRUD-ish surface — fine for the common workflows of "publish release notes", "set up a new team space", "archive a deprecated workspace". ## Table of contents 1. [auth](#auth) 2. [space](#space) 3. [blog](#blog) 4. [page](#page) 5. [Body formats and Confluence storage format](#body-formats) 6. [REST fallback for pages](#rest-fallback) ## <a id="auth"></a>auth Same model as `acli jira auth` — see `references/auth.md` for the full picture. Subcommands: `login`, `logout`, `status`, `switch`. Confluence keeps its own session even when authenticated to the same Atlassian site as Jira. ```bash # API token (scriptable) echo "$ATLASSIAN_API_TOKEN" | acli confluence auth login \ --site "mysite.atlassian.net" \ --email "you@example.com" \ --token # OAuth (interactive) acli confluence auth login --web # Status / switch / logout acli confluence auth status acli confluence auth switch --site mysite.atlassian.net --email you@example.com acli confluence auth logout ``` Token is the same Atlassian-account token you'd use for Jira. The session is stored independently from the Jira session, so you typically log in to both even with identical credentials. ## <a id="space"></a>space Confluence space CRUD. The most fleshed-out group in the Confluence surface. ### list ```bash # All accessible spaces (default limit 50) acli confluence space list # Personal spaces only acli confluence space list --type personal # Filter by specific keys acli confluence space list --keys "ENG,PRODUCT,RND" --json # Include extra detail acli confluence space list --expand description,homepage,permissions --json # Archived spaces acli confluence space list --status archived ``` Flags: | Flag | Meaning | | ------------- | ----------------------------------------------------- | | `--type` | `global` · `personal` | | `--status` | `current` (default) · `archived` | | `--keys` | Comma-separated space keys to filter by | | `--expand` | Comma-separated: `description, homepage, permissions` | | `-l, --limit` | Max rows (default **50**) | | `--json` | JSON output | ### view ```bash # By space ID (note: this is the numeric ID, not the key) acli confluence space view --id 123456 # Include all the optional sections acli confluence space view --id 123456 --include-all --json # Selective inclusion acli confluence space view --id 123456 --icon --labels --permissions ``` Flags: `--id` (numeric), `--icon`, `--labels`, `--operations`, `--permissions`, `--properties`, `--role-assignments` (EAP sites only), `--include-all`, `--desc-format` (`plain` · `view`), `--json`. > **Asymmetry**: `space view` takes `--id` (numeric), but `space create / update / archive / restore` use `--key` (the human-readable key like `ENG`). Use `space list --json` to find the numeric ID for a known key. ### create ```bash # Minimum: key + name acli confluence space create --key "ENG" --name "Engineering" # With description and template acli confluence space create \ --key "ENG" \ --name "Engineering" \ --description "All engineering documentation, ADRs, runbooks" \ --template-key "default" \ --json # Private space acli confluence space create --key "EXEC" --name "Executive" --private # Custom URL alias acli confluence space create --key "ENG" --name "Engineering" --alias "engineering" ``` Flags: `--key`, `--name`, `--description`, `--alias` (URL identifier), `--template-key`, `--private`, `--json`. ### update ```bash # Rename acli confluence space update --key "ENG" --name "Engineering — Platform" # Update description acli confluence space update --key "ENG" --description "Platform engineering: services, infra, observability" ``` Flags: `--key` (required), `--name`, `--description`, `--status`, `--type`, `--json`. ### archive / restore Spaces are NOT permanently deletable via the CLI — only `archive`/`restore`. Permanent deletion requires the Confluence UI (admin → space management) or REST. ```bash # Archive acli confluence space archive --key "OLDPROJ" # Restore from archive (or trash) acli confluence space restore --key "OLDPROJ" ``` ## <a id="blog"></a>blog Create + list + view. No `update` or `delete` (use REST for either). ### create The richest single Confluence command — supports `--body`, `--body-file`, `--from-json`, `--generate-json`, drafts, scheduled timestamps, and private posts. ```bash # Inline body (HTML / Confluence storage format) acli confluence blog create \ --space-id 12345 \ --title "Release Notes — v2026.04" \ --body "<p>This release includes...</p>" # Body from a file (HTML or Markdown-as-HTML) acli confluence blog create \ --space-id 12345 \ --title "Quarterly review" \ --from-file ./review.html # Draft (does not publish — visible only to author) acli confluence blog create \ --space-id 12345 \ --title "Work in progress" \ --status draft \ --body "<p>Draft content</p>" # Private (published but visibility-restricted) acli confluence blog create \ --space-id 12345 \ --title "Internal announcement" \ --private \ --body "<p>...</p>" # Backdated post acli confluence blog create \ --space-id 12345 \ --title "Backfilled history" \ --created-at "2026-01-16T10:20:30.000Z" \ --body "<p>...</p>" # JSON payload (for programmatic generation) acli confluence blog create --generate-json > blog.json $EDITOR blog.json acli confluence blog create --from-json blog.json --json ``` Flags: | Flag | Meaning | | ----------------- | ------------------------------------------------------ | | `--space-id` | Numeric space ID (find via `space list --json`) | | `--title` | Post title | | `--body` | Inline body in Confluence storage format (XHTML) | | `--from-file` | Read body from file | | `--from-json` | Full JSON payload | | `--generate-json` | Print example JSON template | | `--status` | `current` (published, default) · `draft` | | `--private` | Restrict visibility | | `--created-at` | ISO-8601 timestamp (e.g. `"2026-04-28T15:00:00.000Z"`) | | `-j, --json` | Output JSON | ### list ```bash # Latest blog posts in a space (default limit 25) acli confluence blog list --space-id 12345 # By blog post ID(s) acli confluence blog list --id 98765 --json # Across multiple spaces, with status filter acli confluence blog list --space-id 12345,67890 --status current --limit 50 # Title filter acli confluence blog list --space-id 12345 --title "Release Notes" # Include body in storage format acli confluence blog list --space-id 12345 --body-format storage --limit 10 # Pagination via cursor acli confluence blog list --cursor "<cursor-from-previous-call>" --limit 25 --json ``` Flags: `--id`, `--space-id`, `--title`, `--status` (`current` · `deleted` · `trashed`), `--body-format` (`storage` · `atlas_doc_format` · etc.), `--cursor` (pagination token), `--sort`, `-l, --limit` (default **25**), `-j, --json`, `--csv`. > Pagination on `blog list` uses `--cursor`, NOT `--paginate`. The cursor token comes from the previous response (in JSON output). This is the only Confluence command that exposes cursor-based pagination. ### view ```bash # By blog ID acli confluence blog view --id 98765 # With body acli confluence blog view --id 98765 --body-format storage # Specific version acli confluence blog view --id 98765 --version 2 # Draft view acli confluence blog view --id 98765 --draft # Include extras acli confluence blog view --id 98765 --include labels,properties,likes ``` Flags: `--id` (required), `--body-format`, `--version` (int), `--draft`, `--status` (`current` · `trashed` · `deleted` · `historical` · `draft`), `--include` (comma-separated: `labels, properties, operations, likes, versions, version, favorited, webresources, collaborators, all`), `-j, --json`. > `blog view` uses a single `--include` flag with comma-separated values. `page view` (next section) uses individual `--include-*` boolean flags. Inconsistent across the surface — copy from `--help`, do not guess. ## <a id="page"></a>page **Read only.** The only subcommand is `view`. No create, no update, no delete, no list. To make any page change, fall back to REST (see [§REST fallback for pages](#rest-fallback)). ```bash # By page ID acli confluence page view --id 123456789 # With body in a specific format acli confluence page view --id 123456789 --body-format storage acli confluence page view --id 123456789 --body-format atlas_doc_format acli confluence page view --id 123456789 --body-format view # Include children, labels, likes, versions acli confluence page view --id 123456789 \ --include-direct-children \ --include-labels \ --include-likes \ --include-versions # Get a draft version acli confluence page view --id 123456789 --get-draft # Specific historical version acli confluence page view --id 123456789 --version 5 # Filter by status (comma-separated list) acli confluence page view --id 123456789 --status current,draft,archived ``` Include flags (each is a separate boolean): `--include-collaborators`, `--include-direct-children`, `--include-favorited-by-current-user-status`, `--include-labels`, `--include-likes`, `--include-operations`, `--include-properties`, `--include-version`, `--include-versions`, `--include-webresources`. Plus `--get-draft`, `--version <int>`, `--status`, `--body-format`, `--json`. ## <a id="body-formats"></a>Body formats and Confluence storage format Several commands accept `--body-format` (read) or expect a body in a particular format on input (write). The values you'll encounter: | Value | What it is | When to use | | ------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------- | | `storage` | Confluence "storage format" — XHTML with Confluence-specific macros | Writing body content for `blog create` | | `atlas_doc_format` | ADF (the same JSON structure used in Jira rich text) | Programmatic content generation, especially for AI-assisted authoring | | `view` | Pre-rendered HTML as displayed to readers | Reading rendered content for downstream rendering | | `plain` | Plain text (only valid for `space view --desc-format`) | Simple display | For input via `--body` or `--body-file`, the documented expectation is **storage format** (XHTML). Markdown is not converted automatically — convert it yourself before submission, or use a JSON payload via `--from-json` with `atlas_doc_format`. Quick reference for storage format: ```html <p>A paragraph.</p> <h2>A heading</h2> <ul> <li>List item</li> </ul> <ac:structured-macro ac:name="info"> <ac:rich-text-body><p>Info macro content</p></ac:rich-text-body> </ac:structured-macro> <a href="https://example.com">Link</a> ``` ## <a id="rest-fallback"></a>REST fallback for pages and update/delete operations Anything `acli confluence` does not cover, route through Confluence Cloud REST v2 at `/wiki/api/v2/`: ```bash # Auth header (basic with email + API token) AUTH=$(printf '%s:%s' "$EMAIL" "$TOKEN" | base64) # Create a page curl -s -X POST "https://mysite.atlassian.net/wiki/api/v2/pages" \ -H "Authorization: Basic $AUTH" \ -H "Content-Type: application/json" \ -d '{ "spaceId": "12345", "status": "current", "title": "New Page", "body": { "representation": "storage", "value": "<p>Content here</p>" } }' # Update a page (must include current version + 1) curl -s -X PUT "https://mysite.atlassian.net/wiki/api/v2/pages/$PAGE_ID" \ -H "Authorization: Basic $AUTH" \ -H "Content-Type: application/json" \ -d '{ "id": "'"$PAGE_ID"'", "status": "current", "title": "Updated title", "body": { "representation": "storage", "value": "<p>Updated content</p>" }, "version": { "number": 4 } }' # Delete a page curl -s -X DELETE "https://mysite.atlassian.net/wiki/api/v2/pages/$PAGE_ID" \ -H "Authorization: Basic $AUTH" # Update a blog post (no acli equivalent) curl -s -X PUT "https://mysite.atlassian.net/wiki/api/v2/blogposts/$BLOG_ID" \ -H "Authorization: Basic $AUTH" \ -H "Content-Type: application/json" \ -d '{ "id": "'"$BLOG_ID"'", "status": "current", "title": "...", "body": {...}, "version": {"number": N+1} }' # Permanently delete a space (acli only does archive/restore) curl -s -X DELETE "https://mysite.atlassian.net/wiki/rest/api/space/$SPACE_KEY" \ -H "Authorization: Basic $AUTH" ``` > Pages REST takes the **numeric page ID**, not the title or URL slug. To find a page ID, navigate to the page in the browser and look at the URL: `https://mysite.atlassian.net/wiki/spaces/ENG/pages/<PAGE_ID>/Title`. > The `version.number` on update must be the current version + 1, otherwise you'll get `409 Conflict`. Read first via `acli confluence page view --id $PAGE_ID --include-version --json | jq '.version.number'`. ## When to prefer MCP over `acli` For Confluence specifically, the Atlassian MCP server tends to be more ergonomic for page CRUD because the MCP wraps the REST endpoints and handles the version-bumping dance. Use it when: - You need to create or update Confluence pages from an AI session. - You're chaining many page operations and don't want to manage version increments by hand. - You want a uniform interface alongside Jira MCP calls in the same session. Use `acli confluence` instead when: - You're scripting a one-off bulk operation (create 50 spaces from a CSV). - You need CSV/JSON output piped into other shell tools. - You're already authenticated to `acli` and don't want to negotiate MCP credentials separately. -
gotchas.md 21.2 KB
# Gotchas, known bugs, REST fallbacks Everything the official docs do not make obvious. Every item here is something that has surprised at least one user in production; most are confirmed by multiple sources or by explicit language in the docs. ## Table of contents 1. [Silent pagination truncation](#pagination) 2. [The `issue` vs `workitem` split](#terminology) 3. [Custom field payload shape on `create`](#custom-fields) 4. [Custom fields cannot be edited via `acli`](#custom-field-edit) 5. [Custom fields cannot be enumerated via `acli`](#custom-field-list) 6. [No admin for workflows, issue types, priorities, resolutions, versions, components](#no-admin) 7. [Unknown subcommands silently fall back to parent help](#silent-fallback) 8. [Sprint field cannot be set](#sprint) 9. [Transition by status name only](#transitions) 10. [Auth has four namespaces, not three](#auth-scope) 11. [OAuth cannot be automated](#oauth) 12. [Name collision with the Appfire `acli`](#appfire) 13. [Issue-type resolution is global](#issue-types) 14. [Comment create accepts ADF via -F](#comment-adf) 15. [Trace IDs and no verbose mode](#trace) 16. [The 2026 point-based rate limits](#rate-limits) 17. [CI install `latest/` risk](#ci-install) 18. [Naming convention: kebab-case is universal](#naming) 19. [REST fallback checklist](#rest-fallback) ## <a id="pagination"></a>1. Silent pagination truncation **The problem.** `workitem search`, `project list`, and every other list/search command stops at the server default (30–50 rows) when `--paginate` is not set. There is no warning, no non-zero exit code, no stderr message. **Why it matters.** Audit scripts that count tickets, batch scripts that iterate over keys, or anything making decisions based on the result set will silently make the wrong decision. **Fix.** Always pass `--paginate` in automation. If your use case truly wants only the top N, pass an explicit `--limit N` to make the cap intentional. ## <a id="terminology"></a>2. "Issue" → "workitem" rename is surface only **The problem.** The CLI renamed `acli jira issue` → `acli jira workitem` during 2025. But: - JSON responses from `workitem search` still have `{"issues": [...]}` at the top level. - `create-bulk` CSV columns are still `summary, projectKey, issueType, description, label, parentIssueId, assignee`. - The underlying REST v3 endpoints (`/rest/api/3/issue/{id}`) were not renamed. **Fix.** When writing `jq` filters, use `.issues[]`. When writing CSVs for `create-bulk`, use the old column names. Do not try to "modernize" payloads — the CLI rejects anything but the documented shapes. ## <a id="custom-fields"></a>3. Custom field payload shape on `create` **The problem.** `acli jira workitem create --from-json` expects custom fields wrapped in a top-level `additionalAttributes` object — NOT `fields`, NOT flat at the root: ```json { "summary": "...", "type": "Story", "projectKey": "{{PROJECT_KEY}}", "additionalAttributes": { "customfield_NNNN": { "value": "High" }, "customfield_NNNN": 8 } } ``` Two things to remember about the shape: - **Numeric IDs only.** Name-addressing (`"Story Points"`) is not supported. - **The three documented value shapes** are: single-select option `{"value": "..."}`, bare number, bare string. All other shapes (multiselect, date, datetime, user-picker, cascading select, ADF rich text) are inferred from the Jira REST contract and not officially documented by acli — see `references/workitem.md` §Custom fields. **Fix.** Always run `acli jira workitem create --generate-json` to get the canonical template before composing a payload. Validate by trial when using shapes not in the documented three. ## <a id="custom-field-edit"></a>4. Custom fields cannot be edited via `acli` **The problem.** `acli workitem edit` exposes no channel for custom-field values. Beyond what `edit --help` reveals (no `--customfield-*` flag of any kind), the JSON schema for `edit --from-json` is **strict-mode**: every unrecognized key triggers a hard error and exit 1. **Empirical proof.** Three payload shapes tested against a real Jira workitem with `acli workitem edit --from-json`: ```text {issues:[...], additionalAttributes:{customfield_X:<ADF>}} → ✗ Error: json: unknown field "additionalAttributes" (exit 1) {issues:[...], fields:{customfield_X:<ADF>}} → ✗ Error: json: unknown field "fields" (exit 1) {issues:[...], customfield_X:<ADF>} → ✗ Error: json: unknown field "customfield_X" (exit 1) ``` This is asymmetric with `acli workitem create`, which **does** accept custom fields via `additionalAttributes`. Many users assume `edit` works the same way; it does not — and the failure is loud, not silent. **Fix — WORKAROUND via REST PUT** (the only working path as of v1.3.18). Prerequisites: `ATLASSIAN_EMAIL` and `ATLASSIAN_API_TOKEN` are exported in the current shell. The host is NOT an env var — `bun run --silent jira:url` reads it from `.agents/project.yaml`. ```bash # Simple value (number, string, single-select) curl -sS -w "\nHTTP %{http_code}\n" \ -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -X PUT "$(bun run --silent jira:url)/rest/api/3/issue/{{PROJECT_KEY}}-123" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"fields": {"customfield_NNNN": 8}}' # Expected: HTTP 204 (no body on success) ``` For rich-text custom fields (ADF), see the dedicated **"WORKAROUND: Editing rich-text custom fields on existing work items (REST PUT)"** section in `SKILL.md` — same `curl` shape, payload built by piping the `md-to-adf.ts` output through `jq` to wrap it as `{"fields": {customfield_NNNNN: <ADF>}}`. Reference (official Jira REST v3 PUT endpoint): <https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-put>. Note the REST envelope key is `fields`, **not** `additionalAttributes` (which is the `acli create` wrapper). They are not interchangeable. Bulk create (`create-bulk`) has the same blind spot: no `additionalAttributes` in its template, no documented custom-field path. For bulk creates that carry custom fields, loop single-create or batch via REST. ## <a id="custom-field-list"></a>5. Custom fields cannot be enumerated via `acli` **The problem.** `acli jira field` only exposes `create`, `update`, `delete`, `cancel-delete`. There is no `list`, `get`, `view`, or `search` subcommand. Running `acli jira field list --help` does NOT error — it silently falls back to the parent `field` help (see gotcha #7). **Fix.** Two workarounds: ```bash # 1. From an item that has the field set — extract IDs acli jira workitem view {{PROJECT_KEY}}-123 --json \ | jq '.fields | keys[] | select(startswith("customfield_"))' # 2. From REST — enumerate ALL fields on the site curl -s -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ "$(bun run --silent jira:url)/rest/api/3/field" \ | jq '.[] | {id, name, custom, schema}' ``` > Repo integration: host repos commonly cache the field catalog under `.agents/` and reference fields by stable slug rather than numeric ID. See the host repo's `<repo-core>/references/acli-integration.md` for the slug catalog and refresh recipe. ## <a id="no-admin"></a>6. No admin for workflows, issue types, priorities, resolutions, versions, components **The problem.** As of v1.3.18, `acli` has zero coverage for these admin/schema surfaces: | Surface | `acli` coverage | | ---------------------- | ---------------------------------------------------------------------- | | Workflows | None. No `jira workflow` group at all. | | Workflow schemes | None. | | Statuses | None (read-only — appears as nested object inside `workitem view`). | | Transition definitions | None (only `workitem transition` to _execute_ an existing transition). | | Issue types | None — neither read nor write. | | Priorities | None. | | Resolutions | None. | | Project versions | None. | | Project components | None. | | Custom-field options | None — `field create/update/delete` doesn't manage dropdown options. | | Permission schemes | None. | Running e.g. `acli jira workflow --help` does NOT error — it silently falls back to `acli jira` help. So "no error" ≠ "command exists" (see gotcha #7). **Fix.** Use REST or MCP for all schema/admin work. Common endpoints: ```bash # Issue types GET /rest/api/3/issuetype GET /rest/api/3/project/{projectIdOrKey} # Workflows GET /rest/api/3/workflow/search GET /rest/api/3/workflowscheme/{id} # Custom-field options POST /rest/api/3/field/{fieldId}/option ``` ## <a id="silent-fallback"></a>7. Unknown subcommands silently fall back to parent help **The problem.** Typing a subcommand that doesn't exist — e.g. `acli jira workflow --help`, `acli jira field list --help`, `acli jira issuetype --help` — does NOT produce an error. The CLI prints the parent group's help (`acli jira --help`, `acli jira field --help`) and exits 0. **Why it matters.** "No error" is not evidence the command exists. Scripts that key off exit code 0 may silently do nothing useful for entire branches. **Fix.** - After a help call, verify the help body actually changed. If `acli jira workflow --help` prints the same body as `acli jira --help`, the subcommand does not exist. - For programmatic discovery, parse `acli <parent> --help` for the `Available Commands:` section and check membership. - Always cross-reference against `acli --version` — features land per release. ## <a id="sprint"></a>8. Sprint field cannot be set **The problem.** There is no working way to add a work item to a sprint via `acli`. Community attempts using `--from-json` with either a sprint ID or a sprint name fail ("Number value expected as the Sprint id", "failed to generate JSON"). Atlassian tracks this as `JRACLOUD-97107`. `acli jira sprint create / update / view / delete` do exist — you can manage the sprint container itself — but moving tickets in/out of one is REST-only. **Fix.** Call the Jira Software REST endpoint directly: ```bash curl -s -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -X POST "$(bun run --silent jira:url)/rest/agile/1.0/sprint/$SPRINT_ID/issue" \ -H "Content-Type: application/json" \ -d "{\"issues\": [\"{{PROJECT_KEY}}-123\", \"{{PROJECT_KEY}}-124\"]}" ``` Holding a separate basic-auth token for REST calls is unavoidable here — `acli` does not expose its cached token. ## <a id="transitions"></a>9. `transition` matches by status name, not transition ID **The problem.** The Jira REST API distinguishes transitions by ID, but `acli`'s `--status` flag accepts only the target status _name_. When two transitions land on the same status (e.g. both "Approve" and "Cancel" end in "In Review") with different validators, `acli` picks one heuristically and may fail validation with `InvalidPayloadException`. There is no `--transition-id` escape hatch in the CLI. **Acceptable acli usage** — when the project's workflow exposes exactly one transition into the target status: ```bash # Single, unambiguous path acli jira workitem transition --key "{{PROJECT_KEY}}-123" --status "In Progress" ``` **REST fallback** — when the target status has multiple incoming transitions (e.g. "Start working" from `Ready For Dev` AND "Reopen" from `In Review`), `acli --status` may pick the wrong one. Fall back to the REST `transitions` endpoint with an explicit transition ID: ```bash # 1. Discover the available transitions on the issue curl -s -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ "$(bun run --silent jira:url)/rest/api/3/issue/{{PROJECT_KEY}}-123/transitions" | jq # 2. POST with the chosen transition ID (here illustrated as <TRANSITION_ID>) curl -s -X POST -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -H "Content-Type: application/json" \ "$(bun run --silent jira:url)/rest/api/3/issue/{{PROJECT_KEY}}-123/transitions" \ -d '{"transition":{"id":"<TRANSITION_ID>"}}' ``` > Repo integration: host repos often maintain a slug → transition-ID catalog so skill authors can reference unambiguous transitions by name (e.g. `<slug>` resolves to a workspace-specific numeric ID). See the host repo's `<repo-core>/references/acli-integration.md` for the catalog and the refresh recipe. ## <a id="auth-scope"></a>10. Auth has four namespaces, not three **The problem.** `acli` exposes four independent auth namespaces: | Namespace | Login command | Credential | | ------------ | ---------------------------- | --------------------------- | | Jira | `acli jira auth login` | API token | | Confluence | `acli confluence auth login` | API token | | Org admin | `acli admin auth login` | Org admin API key | | Global OAuth | `acli auth login` | Browser OAuth (interactive) | Logging in to one does not authenticate the others. Each scope keeps its own session. A `forbidden` on `admin user activate` after a successful `jira auth login` is the most common symptom. **Fix.** Run the matching `auth login` for each scope you intend to use. For CI, use per-product API tokens — they're scope-limited (a leaked Jira token can't touch Confluence or admin). The global `acli auth login` (interactive OAuth) is the exception — it covers multiple products in one step, but it cannot be automated. ## <a id="oauth"></a>11. OAuth (`--web` or global `acli auth`) cannot be scripted **The problem.** Both `acli jira auth login --web` and the top-level `acli auth login` open a browser, let the user pick a site, then require the same site to be picked in the terminal. There is no way to pre-select the site, no callback hook, and on WSL or remote shells the callback can hang indefinitely. **Fix.** Use API-token auth (`--token` with stdin) for any non-interactive context. OAuth is strictly for humans at a terminal. ## <a id="appfire"></a>12. Name collision with the Appfire/Bob Swift `acli` **The problem.** There is an older commercial CLI from Appfire (formerly Bob Swift) also called `acli`. It is a Java JAR, uses `acli.properties` config, and has completely different syntax (`--action getIssueList` instead of `acli jira workitem search`). If a user has both installed, `which acli` picks whichever is first on PATH. **Fix.** Use `acli --version` to confirm which binary is active. The official Atlassian one reports a version like `1.3.18` and has the subcommand structure documented on `developer.atlassian.com/cloud/acli/`. ## <a id="issue-types"></a>13. Issue-type resolution is global, not project-scoped **The problem.** When you pass `--type Task`, `acli` looks up the issue-type ID globally across the site. If multiple team-managed projects each define their own "Task" type, the CLI may pick the wrong one and fail with "The selected issue type is invalid" even when the target project clearly has a "Task". **Fix.** In sites with heavy team-managed project use, fall back to REST with an explicit issue-type ID for the project. Or consolidate issue-type names across projects. ## <a id="comment-adf"></a>14. `comment create` accepts ADF via `-F` (previous claim was outdated) **Previous claim (incorrect for v1.3.18+).** Older skill documentation stated `comment create` had no ADF input and required a two-step workaround: create placeholder body → `comment update --body-adf`. This was based on `comment update` having a dedicated `--body-adf` flag while `comment create` had only `--body` and `--body-file`. **Current behavior.** `comment create -F <file>` (alias `--body-file <file>`) accepts both plain text and ADF JSON. The flag's `--help` text states: "Plain text file with text or Atlassian Document Format (ADF)". When the file content begins with a JSON object (`{`), `acli` forwards it as ADF to the underlying REST call. Validated against Jira Cloud on `acli` v1.3.18. The plain `-b, --body` flag remains plain text only — Markdown syntax is stored literally as a single ADF paragraph. **Recommended pattern.** Author the comment body in Markdown, convert via `scripts/md-to-adf.ts`, post with `-F`: ```bash bun .agents/skills/acli/scripts/md-to-adf.ts notes.md notes.adf.json acli jira workitem comment create --key {{PROJECT_KEY}}-123 -F notes.adf.json ``` The legacy two-step pattern still works and may be useful if you want a placeholder visible before composing the final body: ```bash CID=$(acli jira workitem comment create --key {{PROJECT_KEY}}-123 --body "init" --json | jq -r '.id') acli jira workitem comment update --key {{PROJECT_KEY}}-123 --id "$CID" --body-adf formatted.json ``` It is no longer required for rich-text creation. ## <a id="trace"></a>15. Trace IDs are the only debug signal **The problem.** Backend failures print `unexpected error, trace id: XXXXXXXX` with no other detail. There is no `--verbose`, `--debug`, or `--log-level` flag. **Fix.** Always capture stderr in logs. For single-command errors, one trace ID; for bulk, multiple IDs — one per failed item. When opening a support case, include every trace ID you saw. ## <a id="rate-limits"></a>16. 2026 point-based rate limits **Coming change.** Atlassian is rolling out per-org point buckets (65k–500k points per hour depending on plan tier) across the REST API that `acli` calls under the hood. A batch `--jql`-scoped edit over thousands of items can burn the whole hourly budget in one shot and produce 429s for the rest of the hour. **Fix.** For sweeping operations: - Shard by date/assignee/component so each run touches a bounded number of items. - Capture 429 responses and implement `Retry-After` backoff in wrapper scripts. - Run during off-peak windows when the org-wide bucket is less contended. ## <a id="ci-install"></a>17. CI install from `latest/` risk **The problem.** The official CI guide uses `curl -LO "https://acli.atlassian.com/linux/latest/acli_linux_amd64/acli"`. A silent minor bump has broken pipelines in the field (late 2025, 1.3.11 → 1.3.13 transition). **Fix.** Pin a specific version in the URL: `https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli`. Upgrade intentionally, not incidentally. ## <a id="naming"></a>18. Naming convention: kebab-case is universal **The problem.** Every multi-word flag in the binary uses kebab-case. CamelCase variants (sometimes seen in older docs or examples) will fail with "unknown flag". | Wrong (camelCase) | Right (kebab-case) | | ----------------- | ------------------ | | `--searcherKey` | `--searcher-key` | | `--orderBy` | `--order-by` | | `--filterId` | `--filter-id` | | `--projectKey` | `--project-key` | | `--leadEmail` | `--lead-email` | | `--fromJson` | `--from-json` | | `--fromFile` | `--from-file` | The flag _value_ may still use camelCase (e.g. CSV column header `projectKey` or JSON key `parentIssueId`) — the convention only applies to flag _names_. **Fix.** When in doubt, run `acli <path> --help` and copy the flag name verbatim. Never guess casing. ## <a id="rest-fallback"></a>19. When to fall back to REST `acli` does not (yet) cover: - Adding work items to a sprint (`POST /rest/agile/1.0/sprint/{sprintId}/issue`) - Editing custom-field values on existing work items (`PUT /rest/api/3/issue/{key}` with `{"fields":{...}}`) - Enumerating custom fields on a site (`GET /rest/api/3/field`) - Managing custom-field options (`POST/DELETE /rest/api/3/field/{fieldId}/option/{optionId}`) - Managing workflows, workflow schemes, statuses, or transition definitions - Managing issue types, priorities, resolutions, project versions, project components, permission schemes - Uploading attachments (`POST /rest/api/3/issue/{key}/attachments`) - Adding watchers (`POST /rest/api/3/issue/{key}/watchers`) - Creating remote links / web links — e.g. attaching a URL to a story (`POST /rest/api/3/issue/{key}/remotelink`) - Transition by ID when the status-name match is ambiguous (see item 9) - Retrieving the cached auth token for reuse - Bitbucket command-line operations (out of scope entirely) - Confluence page CRUD beyond `page view` (space and blog have full CRUD) - Creating epics with a specific parent hierarchy beyond the `--parent` flag For any of these, hold a separate basic-auth credential (email + API token base64-encoded) and use `curl`: ```bash AUTH=$(printf '%s:%s' "$ATLASSIAN_EMAIL" "$ATLASSIAN_API_TOKEN" | base64) curl -s -H "Authorization: Basic $AUTH" -H "Content-Type: application/json" \ "$(bun run --silent jira:url)/rest/api/3/issue/{{PROJECT_KEY}}-123" ``` ## Meta-gotcha: documentation dates Every command-reference page on `developer.atlassian.com/cloud/acli/` shows "Last updated" dates from 2024–2025. The CLI ships updates more often than the docs — when `acli --help` shows a flag that isn't in the online docs, the CLI is the source of truth. As of this writing the binary (v1.3.18) is meaningfully ahead of the public Reference docs in several groups: `board`, `sprint`, `filter`, `field` all have subcommands the docs omit. -
output-and-automation.md 11.3 KB
# Output formats, piping, automation, CI Everything related to getting data **out** of `acli` and feeding it into other tools. ## Output flags Every list/search/view command supports: | Flag | Use | | --------- | ---------------------------------------------- | | (default) | Human-readable table — do not parse in scripts | | `--json` | JSON blob. Structured, stable, script-friendly | | `--csv` | Spreadsheet-friendly flat columns | Most search/list commands also take: | Flag | Use | | -------------- | ---------------------------------------------------- | | `--paginate` | Fetch all pages. Silently overrides `--limit`. | | `-l, --limit` | Cap at N rows. Default ~30–50 depending on endpoint. | | `--count` | Return only the row count (no rows). Fast and cheap. | | `-f, --fields` | Restrict returned columns. | ### Critical pagination rule `workitem search`, `project list`, `filter search`, `dashboard search`, `board search`, `sprint list-workitems`, `board list-sprints` and the comment/attachment/link list variants **all** silently truncate at the server default when `--paginate` is not set. No warning. No exit code signal. **Any script that counts, iterates, or makes decisions on the result set must pass `--paginate`.** The only case where you can skip it is when you have already passed an explicit `--limit` you are comfortable with. ## JSON structure — what to expect The JSON emitted by `workitem search`, `workitem view`, and similar commands mirrors the Jira REST v3 shape. Two key facts: - The top-level array from `search` is `issues`, **not** `workitems` (the rename is UI/CLI-surface only). - Each issue has the standard REST shape: `{ id, key, self, fields: { summary, status, assignee, customfield_NNNN, ... } }`. ```bash # Extract the summary from a view acli jira workitem view {{PROJECT_KEY}}-N --json | jq '.fields.summary' # Extract all keys from a search acli jira workitem search --jql "project = {{PROJECT_KEY}}" --paginate --json \ | jq -r '.issues[].key' # Count by assignee (sprint workload distribution) acli jira workitem search --jql "project = {{PROJECT_KEY}} AND sprint in openSprints()" --paginate --json \ | jq -r '.issues[].fields.assignee.displayName' \ | sort | uniq -c | sort -rn # Pluck a custom field by ID (real IDs are workspace-specific — see references/workitem.md §Custom fields) acli jira workitem view {{PROJECT_KEY}}-N --json \ | jq '.fields.customfield_NNNN' ``` > Repo integration: host repos typically reference custom fields by stable slug rather than the numeric ID shown above. See `<repo-core>/references/acli-integration.md` for the slug catalog. ## Piping with `jq` Patterns that come up repeatedly: ```bash # All keys jq -r '.issues[].key' # Key + summary TSV jq -r '.issues[] | [.key, .fields.summary] | @tsv' # Filter by status jq '.issues[] | select(.fields.status.name == "Done")' # Count jq '.issues | length' # All custom-field IDs present on an item jq '.fields | keys[] | select(startswith("customfield_"))' ``` ## CSV output `--csv` emits a header row and one row per record. Column set follows the `--fields` flag. ```bash # Default fields (issuetype,key,assignee,priority,status,summary) acli jira workitem search --jql "project = {{PROJECT_KEY}}" --paginate --csv > project.csv # Custom columns — sprint snapshot acli jira workitem search --jql "project = {{PROJECT_KEY}} AND sprint in openSprints()" --paginate \ --fields "key,summary,assignee,status,priority,created,updated" \ --csv > sprint-detailed.csv ``` CSV is simpler than JSON for spreadsheet handoff and works cleanly with `csvkit`, `xsv`, or `Miller (mlr)`. ## Redirection, chaining, piping From the official docs: ```bash # Redirect to file acli jira workitem search --jql 'project = {{PROJECT_KEY}}' --limit 10 --csv > output.csv # Chain with && acli jira workitem search --jql 'project = {{PROJECT_KEY}}' --limit 10 && echo "Completed" # Pipe through grep acli jira workitem search --jql 'project = {{PROJECT_KEY}}' --limit 10 | grep "In Review" # Pipe through jq acli jira workitem view {{PROJECT_KEY}}-N --json | jq '.fields.summary' ``` The default human-readable table is stable enough for `grep`/`awk` inspection, but not for production parsing — use `--json` or `--csv`. ### zsh does not word-split an unquoted expansion (bites every loop over key pairs) A loop that expands two keys from one variable works in `bash` and **silently passes ONE argument** in `zsh`, which is the default interactive shell on macOS: ```bash # BROKEN under zsh: each iteration passes the literal string "KEY-1 KEY-2" for pair in "{{PROJECT_KEY}}-180 {{PROJECT_KEY}}-42"; do acli jira workitem link create --out $pair --type "Test" --yes done # PORTABLE: split explicitly, or never put two values in one variable for pair in "{{PROJECT_KEY}}-180 {{PROJECT_KEY}}-42"; do set -- ${=pair} # zsh-only split operator acli jira workitem link create --out "$1" --in "$2" --type "Test" --yes done # BEST: one variable per value, no splitting at all while IFS=, read -r artifact story; do acli jira workitem link create --out "$artifact" --in "$story" --type "Test" --yes done < pairs.csv ``` The failure is loud but misleading: the command reports a missing or malformed argument, so the reader blames the CLI rather than the shell. Measured while repairing links in a batch. Any documented one-liner in this repo that expands a pair from a variable should use the CSV form above, which is portable across `bash`, `zsh` and CI runners alike. ## Confirmation, errors, batches | Flag | Purpose | | ----------------- | ------------------------------------------------------------------- | | `-y, --yes` | Skip the interactive confirmation prompt. **Required in CI.** | | `--ignore-errors` | Continue after a per-item failure in a batch. | | `--generate-json` | Emit an input-template JSON document to stdout. | | `--from-json` | Read payload from a JSON file. | | `--from-csv` | Read payload from a CSV file (create-bulk, link create). | | `--from-file` | Read a plain-text list (summary/description file, or list of keys). | `--yes` and `--ignore-errors` are independent: - Without `--yes`, `acli` prints a preview and waits for user confirmation on stdin. In CI this causes the command to hang until the pipeline times out. - Without `--ignore-errors`, the first item that fails aborts the remaining batch. ## Error handling & trace IDs When something fails server-side, `acli` prints: ``` unexpected error, trace id: XXXXXXXX ``` The trace ID is the only thing Atlassian Support can correlate. Capture it in logs: ```bash acli jira workitem create --project {{PROJECT_KEY}} --type Task --summary "Ship it" 2>&1 \ | tee -a acli.log ``` There is **no `--verbose` or `--debug` flag**. If a command misbehaves and you need more context, options are: - Re-run with `--json` — error bodies may include more detail in JSON form. - Fall back to the equivalent REST call with curl — `-v` gives wire-level detail. - File a support ticket with the trace ID. ## Dry-run pattern (the CLI has none built in) There is no `--dry-run` on any `acli` command. For high-blast-radius batches, wrap the operation: ```bash #!/usr/bin/env bash set -euo pipefail JQL="project = {{PROJECT_KEY}} AND status = 'Ready For Dev'" NEW_STATUS="In Progress" echo "Preview — items that would transition to '$NEW_STATUS':" acli jira workitem search --jql "$JQL" --paginate \ --fields "key,summary,assignee" --csv read -rp "Type YES to proceed: " confirm [[ "$confirm" == "YES" ]] || { echo "Aborted."; exit 1; } acli jira workitem transition --jql "$JQL" --status "$NEW_STATUS" \ --yes --ignore-errors --json > transition.log ``` ## CI pipelines Three pieces to every pipeline: 1. **Install a pinned binary.** Do not use `latest/` in production — a same-day minor bump has broken pipelines in the field. 2. **Authenticate via stdin-piped token** against a bot account. 3. **Always pass `--yes`** on mutating commands. ### GitHub Actions ```yaml name: Sync Jira on: { workflow_dispatch: {} } jobs: sync: runs-on: ubuntu-latest steps: - name: Install acli run: | curl -sSL -o /usr/local/bin/acli \ "https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli" chmod +x /usr/local/bin/acli acli --version - uses: oven-sh/setup-bun@v2 - name: Authenticate env: ATLASSIAN_EMAIL: ${{ vars.ATLASSIAN_EMAIL }} ATLASSIAN_API_TOKEN: ${{ secrets.ATLASSIAN_API_TOKEN }} run: | echo "$ATLASSIAN_API_TOKEN" | acli jira auth login \ --site "$(bun run --silent jira:url --slug)" \ --email "$ATLASSIAN_EMAIL" \ --token - name: Mark stories shipped after a release env: PROJECT_KEY: ${{ vars.PROJECT_KEY }} RELEASE: ${{ inputs.release }} run: | acli jira workitem transition \ --jql "project = $PROJECT_KEY AND fixVersion = '$RELEASE' AND status = 'Ready For QA'" \ --status "Done" --yes --ignore-errors --json ``` ### Bitbucket Pipelines (Atlassian's sample pattern) ```yaml image: atlassian/default-image:3 pipelines: default: - step: name: Jira sync script: - curl -LO "https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli" - chmod +x ./acli # Host from the checked-out repo, never a CI variable. See references/auth.md. - SITE=$(sed -n 's/^[[:space:]]*atlassian_url:[[:space:]]*"\{0,1\}https\{0,1\}:\/\/\([^"[:space:]#]*\).*/\1/p' .agents/project.yaml) - echo "$ATLASSIAN_API_TOKEN" | ./acli jira auth login \ --email "$ATLASSIAN_EMAIL" --site "$SITE" --token - ./acli jira workitem search --jql "project = $PROJECT_KEY AND updated > -1d" --paginate --csv > changes.csv ``` ### GitLab CI ```yaml sync-jira: image: curlimages/curl:latest script: - curl -LO "https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli" - chmod +x acli # Host from the checked-out repo, never a CI variable. See references/auth.md. - SITE=$(sed -n 's/^[[:space:]]*atlassian_url:[[:space:]]*"\{0,1\}https\{0,1\}:\/\/\([^"[:space:]#]*\).*/\1/p' .agents/project.yaml) - echo "$ATLASSIAN_API_TOKEN" | ./acli jira auth login --site "$SITE" --email "$ATLASSIAN_EMAIL" --token - ./acli jira workitem search --jql "project = $PROJECT_KEY AND updated > -1d" --paginate --json > changes.json artifacts: paths: [changes.json] ``` ### Points-based rate limits (2026) Atlassian is rolling out a per-organization point-based rate-limit scheme (65k–500k points/hour depending on plan tier, per-org bucket) for the REST API that `acli` calls under the hood. A batch edit over 3000 items can exhaust the hourly budget in one shot and produce 429s for the rest of the hour. Mitigation: - Spread work across the hour, not all at once. - Prefer `--jql` scoped batches of predictable size over full-project sweeps. - Capture 429 responses (trace ID + HTTP status appear in output) and implement retry-after backoff in wrapper scripts. -
project-board-sprint.md 13.9 KB
# Projects, boards, sprints, filters, dashboards, fields Everything outside the `workitem` surface. These commands are narrower but share the same output conventions (`--json`, `--csv`, `--paginate`, `--limit`) and the same confirmation/error flags on mutations (`--yes`, `--ignore-errors`). ## Table of contents 1. [Projects](#projects) 2. [Boards](#boards) 3. [Sprints](#sprints) 4. [Filters](#filters) 5. [Dashboards](#dashboards) 6. [Custom fields (definitions, NOT values)](#fields) ## <a id="projects"></a>Projects (`acli jira project`) Subcommands: `create`, `view`, `list`, `update`, `archive`, `restore`, `delete`. ### list ```bash # All visible projects — default limit 30 acli jira project list # Recently viewed (up to 20) acli jira project list --recent # Full list for scripting acli jira project list --paginate --json ``` `--paginate` overrides `--limit`. ### view ```bash acli jira project view --key UPEX acli jira project view --key UPEX --json | jq '.lead.displayName' ``` ### create Two input modes: clone an existing project, or supply a JSON payload. ```bash # Clone from UPEX into NEWUPEX (only company-managed projects can be cloned) acli jira project create \ --from-project "UPEX" \ --key "NEWUPEX" \ --name "New UPEX Project" \ --description "Cloned from UPEX" \ --lead-email "lead@example.com" \ --url "https://example.com" # JSON payload acli jira project create --generate-json > project.json $EDITOR project.json acli jira project create --from-json project.json ``` Notes: - **Only company-managed projects can be cloned.** Team-managed projects cannot be used as `--from-project` sources. - If `--lead-email` is omitted, the new project inherits the parent project's lead. - `project create` does NOT let you pick the issue-type scheme or workflow scheme — clone-from-existing is the only way to inherit those. For projects with custom schemes, fall back to REST. ### update ```bash # Change the key (takes effect across all linked work items) acli jira project update --project-key "UPEX1" --key "UPEX" # Multi-field update via JSON acli jira project update --project-key "UPEX1" --from-json project-changes.json # Scaffold acli jira project update --generate-json ``` `--project-key` identifies the project to update; `--key` is the new key value. Updatable fields are limited to: `key, name, description, lead-email, url`. Workflow / issue-type / permission scheme changes still require REST. ### archive / restore / delete ```bash acli jira project archive --key "UPEX" acli jira project restore --key "UPEX" acli jira project delete --key "UPEX" --yes ``` ## <a id="boards"></a>Boards (`acli jira board`) Subcommands: `create`, `delete`, `get`, `list-projects`, `list-sprints`, `search`. ### search ```bash acli jira board search acli jira board search --name "team" --type scrum acli jira board search --project UPEX --paginate --csv ``` Flags: | Flag | Meaning | | ------------ | ---------------------------------------------------------------------- | | `--name` | Case-insensitive partial match | | `--type` | `scrum` · `kanban` · `simple` | | `--project` | Project key filter | | `--filter` | Saved filter ID (**not supported for next-gen / team-managed boards**) | | `--order-by` | `name` · `-name` · `+name` (kebab-case — NOT `--orderBy`) | | `--private` | Include private boards (name/type filters ignored when set) | | `--limit` | Default **50** | | `--paginate` | Pull all pages | ### get ```bash # Fetch a single board by ID acli jira board get --id 123 --json ``` ### create ```bash # Scrum board scoped to one project, sourced from a saved filter acli jira board create \ --name "UPEX Scrum" \ --type scrum \ --filter-id 10001 \ --location-type project \ --project UPEX \ --json # Kanban board scoped to a user's location acli jira board create \ --name "Personal Kanban" \ --type kanban \ --filter-id 10042 \ --location-type user ``` Flags: | Flag | Meaning | | ----------------- | --------------------------------------------------------- | | `--name` | Board name (required) | | `--type` | `scrum` · `kanban` | | `--filter-id` | Saved filter that defines what the board shows (required) | | `--location-type` | `project` · `user` | | `--project` | Project key when `--location-type project` | | `--json` | Emit the created board as JSON | ### delete ```bash # Single or multiple board IDs (comma-separated) acli jira board delete --id 123,124,125 --yes ``` ### list-projects ```bash # All projects associated with a board acli jira board list-projects --id 123 --paginate --json ``` ### list-sprints ```bash # Required: board ID acli jira board list-sprints --id 123 # Filter by sprint state(s) acli jira board list-sprints --id 123 --state active,closed # CSV for spreadsheets acli jira board list-sprints --id 123 --paginate --csv ``` `--state` values: `future`, `active`, `closed`. Comma-separated. ## <a id="sprints"></a>Sprints (`acli jira sprint`) Subcommands: `create`, `delete`, `list-workitems`, `update`, `view`. ### create ```bash # Minimum: name + board acli jira sprint create --name "Sprint 42" --board 6 --json # Full sprint with start/end and goal acli jira sprint create \ --name "Sprint 42" \ --board 6 \ --start "2026-05-01T09:00:00.000-0300" \ --end "2026-05-15T18:00:00.000-0300" \ --goal "Ship the OAuth refresh flow" ``` Flags: `--name` (required), `--board` (required), `--start`, `--end`, `--goal`, `--json`. Dates are ISO-8601 with offset. ### view ```bash acli jira sprint view --id 42 --json ``` ### update ```bash # Move sprint to active state acli jira sprint update --id 42 --state active # Close out and set the actual completion date acli jira sprint update --id 42 --state closed --complete-date "2026-05-15T18:00:00.000-0300" # Adjust dates and goal mid-sprint acli jira sprint update --id 42 \ --end "2026-05-17T18:00:00.000-0300" \ --goal "Updated goal: ship OAuth + audit log" ``` Flags: `--id` (required), `--name`, `--goal`, `--state` (`future` · `active` · `closed`), `--start`, `--end`, `--complete-date`, `--board`, `--json`. ### delete ```bash # Single or batch (comma-separated IDs) acli jira sprint delete --id 41,42,43 --yes ``` ### list-workitems ```bash acli jira sprint list-workitems --sprint 42 --board 6 # Further filter via JQL, restrict fields, output JSON acli jira sprint list-workitems \ --sprint 42 --board 6 \ --jql "assignee = currentUser()" \ --fields "key,summary,status" \ --paginate --json ``` Both `--sprint` (sprint ID, integer) and `--board` (board ID, integer) are required. **Adding individual work items to a sprint is NOT supported by `acli`** (see `references/gotchas.md` for the REST fallback). You CAN create / update / close the sprint itself — just not move tickets in or out of one via the CLI. ## <a id="filters"></a>Filters (`acli jira filter`) Subcommands: `add-favourite`, `change-owner`, `get`, `get-columns`, `list`, `reset-columns`, `search`, `update`. ### list ```bash # My filters acli jira filter list --my # Starred filters acli jira filter list --favourite # JSON output acli jira filter list --my --json ``` ### get ```bash # Single filter detail acli jira filter get --id 10001 --json # Open in browser acli jira filter get --id 10001 --web ``` ### search ```bash acli jira filter search --name "release" acli jira filter search --owner "user@example.com" acli jira filter search --name "release" --owner "user@example.com" --csv --paginate ``` Search params are ANDed. Default limit **30**. `--paginate` to bypass. ### add-favourite ```bash acli jira filter add-favourite --filter-id 10001 ``` **Flag is `--filter-id`, NOT `--id`.** This is the only filter subcommand with that exception — `change-owner`, `update`, `reset-columns` all use `--id`. ### change-owner ```bash # Single acli jira filter change-owner --id 10001 --owner "newowner@example.com" # Bulk via file (one ID per line) acli jira filter change-owner --from-file filter-ids.txt --owner "newowner@example.com" --ignore-errors --json ``` ### update ```bash # Update name/description acli jira filter update --id 10001 --name "Active sprint" --description "Open issues in active sprint" # Update the JQL backing the filter acli jira filter update --id 10001 --jql "project = UPEX AND sprint in openSprints()" # Update share / edit permissions (JSON arrays per the Jira REST contract) acli jira filter update --id 10001 --share-permissions '[{"type":"project","projectId":"10000"}]' ``` Flags: `--id` (required), `--name`, `--description`, `--jql`, `--share-permissions`, `--edit-permissions`, `--json`. ### get-columns / reset-columns Filters can override the default issue-list columns shown in Jira's UI. These two commands inspect and reset that override. ```bash # Inspect (--key takes a filter ID despite the flag name) acli jira filter get-columns --key 10001 --json # Reset to the default project / global columns acli jira filter reset-columns --id 10001 ``` ## <a id="dashboards"></a>Dashboards (`acli jira dashboard`) Only subcommand: `search`. Same flag shape as `filter search`: ```bash acli jira dashboard search acli jira dashboard search --name "sprint health" --owner "user@example.com" acli jira dashboard search --paginate --csv ``` ## <a id="fields"></a>Custom fields (`acli jira field`) Subcommands: `cancel-delete`, `create`, `delete`, `update`. > **Important — what this group does and does not do**: > > - **What it manages**: custom-field DEFINITIONS at the site/admin level (the schema — name, type, description, searcher). > - **What it does NOT manage**: custom-field VALUES on individual work items (use `workitem create --from-json` with `additionalAttributes` instead — and even that has limitations; see `references/workitem.md` §Custom fields). > - **What is missing entirely**: there is **no `list`, `get`, `view`, or `search` subcommand**. To enumerate all custom fields on a site, fall back to REST `GET /rest/api/3/field` (or `cat .agents/jira-fields.json` if `bun run jira:sync-fields` has been run). > - **What is also missing**: there is no command to manage select/dropdown OPTIONS for an existing field. To add or remove dropdown options, fall back to REST `/rest/api/3/field/{fieldId}/option`. ### create ```bash acli jira field create \ --name "Customer Name" \ --type "com.atlassian.jira.plugin.system.customfieldtypes:textfield" # Select field with a multi-select searcher acli jira field create \ --name "Priority Level" \ --type "com.atlassian.jira.plugin.system.customfieldtypes:select" \ --searcher-key "com.atlassian.jira.plugin.system.customfieldtypes:multiselectsearcher" # Date picker with description acli jira field create \ --name "Release Date" \ --type "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" \ --description "The planned release date" ``` Flags: `--name`, `--type`, `--searcher-key` (kebab-case — NOT `--searcherKey`), `--description`, `--json`. `--type` takes the Atlassian field-type key, **not** a friendly name. Common values: | Friendly name | Type key | | -------------------- | ------------------------------------------------------------------- | | Short text | `com.atlassian.jira.plugin.system.customfieldtypes:textfield` | | Paragraph | `com.atlassian.jira.plugin.system.customfieldtypes:textarea` | | Number | `com.atlassian.jira.plugin.system.customfieldtypes:float` | | Date picker | `com.atlassian.jira.plugin.system.customfieldtypes:datepicker` | | Datetime picker | `com.atlassian.jira.plugin.system.customfieldtypes:datetime` | | Select list (single) | `com.atlassian.jira.plugin.system.customfieldtypes:select` | | Select list (multi) | `com.atlassian.jira.plugin.system.customfieldtypes:multiselect` | | Checkbox | `com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes` | | User picker (single) | `com.atlassian.jira.plugin.system.customfieldtypes:userpicker` | | User picker (multi) | `com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker` | | Cascading select | `com.atlassian.jira.plugin.system.customfieldtypes:cascadingselect` | | URL | `com.atlassian.jira.plugin.system.customfieldtypes:url` | | Labels | `com.atlassian.jira.plugin.system.customfieldtypes:labels` | The full catalog is available in Jira's field-type admin UI. ### update ```bash # Rename a custom field acli jira field update --id customfield_10122 --name "Updated Field Name" # Update description and searcher acli jira field update --id customfield_10122 \ --description "Now used for the audit-log link" \ --searcher-key "com.atlassian.jira.plugin.system.customfieldtypes:textsearcher" # Multi-property update via JSON acli jira field update --id customfield_10122 --from-json field-changes.json ``` Flags: `--id` (required), `--name`, `--description`, `--searcher-key`, `--from-json`, `--json`. Note: changing `--type` after creation is NOT supported by Jira itself — to change a field's type you must delete and recreate. ### delete / cancel-delete Field deletion is a two-phase operation in Jira (scheduled, then executed). `cancel-delete` undoes a pending deletion if the field has not yet been removed. ```bash # Schedule a field for deletion acli jira field delete --id customfield_10122 # Cancel before the scheduled deletion runs acli jira field cancel-delete --id customfield_10122 ``` -
workitem.md 34.8 KB
# Work items (`acli jira workitem`) This is the largest surface in `acli`. Every Jira ticket operation routes through `jira workitem`. Actions covered: `create`, `create-bulk`, `view`, `search`, `edit`, `transition`, `assign`, `clone`, `archive`, `unarchive`, `delete`, `comment`, `link`, `attachment`, `watcher`. > Note on terminology: Atlassian renamed `issue` → `workitem` across CLI and UI throughout 2025. The JSON payload shape still uses the old spelling — the response from `workitem search --json` has a top-level `issues[]` array, and `create-bulk` CSV columns are `summary, projectKey, issueType, description, label, parentIssueId, assignee`. The rename is surface-level only. ## Table of contents 1. [The three-selector pattern](#the-three-selector-pattern) 2. [create / create-bulk](#create) 3. [view](#view) 4. [search](#search) 5. [edit](#edit) 6. [transition](#transition) 7. [assign](#assign) 8. [clone](#clone) 9. [archive / unarchive / delete](#archive) 10. [comment (create / delete / list / update / visibility)](#comment) 11. [link (create / delete / list / type)](#link) 12. [attachment](#attachment) 13. [watcher (list / remove)](#watcher) 14. [Custom fields](#custom-fields) ## <a id="the-three-selector-pattern"></a>The three-selector pattern Every mutating command on `workitem` (except `create`, `view`) accepts **exactly one** of: | Flag | Form | Example | | ----------------- | -------------------- | --------------------------------------------------------------------------- | | `-k, --key` | Comma-separated keys | `--key "{{PROJECT_KEY}}-123,{{PROJECT_KEY}}-124"` | | `--jql` | JQL query string | `--jql "project = {{PROJECT_KEY}} AND status = 'Ready For Dev'"` | | `--filter` | Saved filter ID | `--filter 10001` | | `-f, --from-file` | File listing keys | `--from-file keys.txt` (some commands) | JQL and filter selectors can target many items at once — the command becomes a batch. Always pair with `-y, --yes` (skip confirmation) and usually `--ignore-errors` (do not abort the batch on a single failure). ## <a id="create"></a>create Three input modes: ```bash # 1. Direct flags — simplest case acli jira workitem create \ --project "{{PROJECT_KEY}}" \ --type "Story" \ --summary "Add empty-states to the dashboard" \ --assignee "@me" \ --label "frontend,empty-states" # Create a Bug linked to a parent Story acli jira workitem create \ --project "{{PROJECT_KEY}}" \ --type "Bug" \ --summary "Login button does nothing on Safari 17" \ --assignee "@me" \ --label "ui,bug" \ --parent "{{PROJECT_KEY}}-123" # 2. Summary/description from a file (longer bug repro) acli jira workitem create \ --project "{{PROJECT_KEY}}" \ --type "Bug" \ --from-file "bug-repro.md" \ --assignee "user@example.com" # 3. Full JSON payload — needed for custom fields and rich ADF acli jira workitem create --generate-json > workitem.json # scaffold $EDITOR workitem.json # edit acli jira workitem create --from-json workitem.json # submit ``` Useful flags: | Flag | Meaning | | -------------------- | ------------------------------------------------------------------ | | `-p, --project` | Project key (e.g. `{{PROJECT_KEY}}`) | | `-t, --type` | Work item type name (`Epic`, `Story`, `Task`, `Bug`) | | `-s, --summary` | One-line title | | `-d, --description` | Plain text or ADF. Markdown is **not** interpreted. | | `--description-file` | Description from a file | | `-a, --assignee` | Email, account ID, `@me` (self), or `default` (project's default) | | `-l, --label` | Comma-separated labels | | `--parent` | Parent work item key (for subtasks, epic children, bug→story link) | | `-e, --editor` | Open `$EDITOR` to write summary + description | | `--json` | Emit result as JSON | **Publishing rich text in `description` or custom-field values**: pass an ADF JSON document, not Markdown. `acli` does not interpret Markdown. Use `scripts/md-to-adf.ts` (bundled with this skill) to produce the ADF document, then inject it into the `--from-json` payload. See the "Publishing rich text" section in `SKILL.md` for the full recipe and a worked example. **Parenting in `--from-json`: the field is `parentIssueId`, and `parentIssueKey` does not exist.** A payload carrying `parentIssueKey` is rejected outright (`json: unknown field`), which reads as "this tool cannot parent to an Epic" and sends the caller off to REST for nothing. It can: `parentIssueId` accepts a KEY (`{{PROJECT_KEY}}-100`), exactly like the `--parent` flag and the `create-bulk` CSV column of the same name. Its `--generate-json` description mentions sub-tasks only — that description is narrower than the behaviour, and every quality artifact this repo parents to a QA-process Epic goes through this field. Measured against a live instance while parenting Defects to a QA Epic. ### create-bulk For many items at once, use JSON or CSV input: ```bash # CSV path — fastest for spreadsheet-style input acli jira workitem create-bulk --from-csv stories.csv --yes ``` Required CSV columns (literal names, comma-separated header row): ``` summary,projectKey,issueType,description,label,parentIssueId,assignee Add empty-states to dashboard,{{PROJECT_KEY}},Story,FE story for empty states,frontend,{{PROJECT_KEY}}-100,you@example.com Fix login button on Safari,{{PROJECT_KEY}},Bug,Reported by QA,ui,{{PROJECT_KEY}}-123,auto ``` Or scaffold a JSON template: ```bash acli jira workitem create-bulk --generate-json > bulk.json $EDITOR bulk.json acli jira workitem create-bulk --from-json bulk.json --yes ``` `--yes` is **mandatory** in non-interactive contexts — without it, `create-bulk` hangs waiting for stdin confirmation. ## <a id="view"></a>view ```bash # Default fields — quick peek at a story acli jira workitem view {{PROJECT_KEY}}-123 # Select fields acli jira workitem view {{PROJECT_KEY}}-123 --fields "summary,status,assignee,description,parent" # JSON for scripting — feed a downstream generator acli jira workitem view {{PROJECT_KEY}}-123 --json | jq '.fields | { summary, status: .status.name, assignee: .assignee.emailAddress, acceptance_criteria: .customfield_NNNNN, scope: .customfield_NNNNN, mockup: .customfield_NNNNN }' # Open in browser acli jira workitem view {{PROJECT_KEY}}-123 --web ``` The `--fields` selector supports meta-tokens documented by the CLI: | Token | Meaning | | ------------ | -------------------- | | `*all` | All fields | | `*navigable` | All navigable fields | | `fieldName` | Include named field | | `-fieldName` | Exclude named field | Example: `--fields "*navigable,-comment"` — everything navigable except the comment list. Default view fields: `key,issuetype,summary,status,assignee,description`. > The `customfield_NNNNN` IDs above are placeholders. Real IDs are workspace-specific and have no name-addressing in `acli` — see the "Custom fields" section below for the discovery recipe. ## <a id="search"></a>search ```bash # JQL search — what's ready for someone to pick up acli jira workitem search --jql "project = {{PROJECT_KEY}} AND status = 'Ready For Dev' AND assignee = currentUser()" # Saved filter (sprint dashboard, etc.) acli jira workitem search --filter 10001 # Count only — sprint scorecards acli jira workitem search --jql "project = {{PROJECT_KEY}} AND sprint in openSprints() AND status = 'In Progress'" --count # Full result set — always pass --paginate when iterating acli jira workitem search --jql "project = {{PROJECT_KEY}} AND sprint in openSprints()" --paginate --json # CSV for spreadsheets acli jira workitem search --jql "project = {{PROJECT_KEY}} AND sprint in openSprints()" --fields "key,summary,assignee,status" --csv > sprint.csv # Open search in browser acli jira workitem search --jql "project = {{PROJECT_KEY}} AND assignee = currentUser()" --web ``` Flags: | Flag | Meaning | | ------------------ | ------------------------------------------------------------------------------------- | | `-j, --jql` | JQL query (mutually exclusive with `--filter`) | | `--filter` | Saved filter ID | | `--count` | Return row count only | | `-f, --fields` | Comma-separated field list (default `issuetype,key,assignee,priority,status,summary`) | | `--json` / `--csv` | Output format | | `-l, --limit` | Max rows (default ~50, server-capped; truncates silently) | | `--paginate` | Fetch all pages. Ignores `--limit`. **Use this in any automation script.** | | `-w, --web` | Open the search in the browser | **Silent truncation** is the top pitfall here. Without `--paginate`, `search` stops at the server page size (~30-50) with no warning. If your logic relies on "all matching items", always pass `--paginate`. ## <a id="edit"></a>edit ```bash # Simple flag-based edit — fix a typo in the summary acli jira workitem edit --key "{{PROJECT_KEY}}-123" --summary "Updated story title" --yes # Re-assign a batch of stories with JQL acli jira workitem edit --jql "project = {{PROJECT_KEY}} AND assignee = formerdev@example.com" \ --assignee "newdev@example.com" --yes --ignore-errors # Remove labels / assignee (cannot be done by passing empty values) acli jira workitem edit --key "{{PROJECT_KEY}}-123" --remove-labels "stale,deprecated" acli jira workitem edit --key "{{PROJECT_KEY}}-123" --remove-assignee ``` Editable flags via `acli jira workitem edit`: `--summary`, `--description`, `--description-file`, `--assignee`, `--labels`, `--type`. Removal flags: `--remove-assignee`, `--remove-labels`. **That list is the whole surface — and `components` is not on it.** There is no `--components` / `--component` flag and no `--from-json` key for it, so the field this repo's defect doctrine makes MANDATORY on every quality issue cannot be set or changed by `workitem edit` at all. Set components at CREATE time where possible; to change them later, use the same REST path as custom fields: `PUT /rest/api/3/issue/{KEY}` with `{"fields": {"components": [{"name": "<Module>"}]}}`. Worth stating plainly because the omission is silent: an edit that does not mention components simply leaves them as they were, and a caller who assumed the flag existed never sees an error. Measured while writing sprint-altitude fields on a live instance. **Critical limitation — `workitem edit` hard-rejects custom fields.** `acli jira workitem edit --from-json` validates the payload against a strict whitelist of built-in keys (`summary`, `description`, `assignee`, `labels`, `type`, `issues`, `labelsToAdd`, `labelsToRemove`). Every custom-field shape — `additionalAttributes.customfield_X`, `fields.customfield_X`, or `customfield_X` at the root — raises `✗ Error: json: unknown field …` and exits 1. Confirmed empirically against a live workitem; no silent drop, no escape hatch. **The only working path** is REST `PUT /rest/api/3/issue/{KEY}` with `{"fields": {customfield_NNNNN: <value-or-ADF>}}` — see the dedicated `SKILL.md` "WORKAROUND" subsection for the turnkey curl recipe and `references/gotchas.md` §4 for the wire-level detail. Both use the session env vars `$ATLASSIAN_EMAIL` and `$ATLASSIAN_API_TOKEN` exported from the shell, plus the host from `bun run --silent jira:url` (read from `.agents/project.yaml`, not from the environment). ## <a id="transition"></a>transition ```bash # Transition by key acli jira workitem transition --key "{{PROJECT_KEY}}-123" --status "In Progress" # Common forward-flow transitions acli jira workitem transition --key "{{PROJECT_KEY}}-123" --status "In Review" acli jira workitem transition --key "{{PROJECT_KEY}}-123" --status "Ready For QA" # Batch via JQL — close out everything that shipped last release acli jira workitem transition --jql "project = {{PROJECT_KEY}} AND fixVersion = '2026.05'" \ --status "Done" --yes --ignore-errors # Via saved filter acli jira workitem transition --filter 10001 --status "Ready For Dev" --yes ``` `--status` is a **status name**, not a transition ID. The target must be reachable from the current status through the project's workflow. Two known limitations: - **No `--transition-id`.** If two transitions lead to the same status with different validators (e.g. both "Resolve" and "Cancel" end in "Closed"), `acli` may pick the wrong one and fail. - **Loop transitions** (actions that keep the status the same) are supported — just pass the same status name. Fallback when the CLI cannot disambiguate: call `POST /rest/api/3/issue/{key}/transitions` directly with an explicit transition ID. See `references/gotchas.md` §9 for the wire-level pattern. ## <a id="assign"></a>assign ```bash # Self-assign acli jira workitem assign --key "{{PROJECT_KEY}}-123" --assignee "@me" # Batch reassign via JQL — handover when someone leaves the team acli jira workitem assign --jql "project = {{PROJECT_KEY}} AND assignee = formerdev@example.com" \ --assignee "newdev@example.com" --yes # Reset to the project default acli jira workitem assign --key "{{PROJECT_KEY}}-123" --assignee "default" # Unassign — putting it back in the pool acli jira workitem assign --key "{{PROJECT_KEY}}-123" --remove-assignee ``` Assignee values: email, Atlassian account ID, `@me`, or `default`. ## <a id="clone"></a>clone `clone` accepts the full selector set (`--key`, `--jql`, `--filter`, `--from-file`) — useful for cloning stories from a template project, or duplicating a recurring chore. ```bash # Clone within the same project (single or many) acli jira workitem clone --key "{{PROJECT_KEY}}-100,{{PROJECT_KEY}}-101" --to-project "{{PROJECT_KEY}}" # Clone into another project on the same site acli jira workitem clone --key "TEMPLATE-1" --to-project "{{PROJECT_KEY}}" # Clone every backlog item into another project acli jira workitem clone --jql "project = TEMPLATE AND status = 'Backlog'" \ --to-project "{{PROJECT_KEY}}" --yes --ignore-errors # Clone a saved-filter result set acli jira workitem clone --filter 10001 --to-project "{{PROJECT_KEY}}" --yes # Clone to a project on another site (cross-site) acli jira workitem clone --key "{{PROJECT_KEY}}-1" --to-project "{{PROJECT_KEY}}" --to-site "othersite.atlassian.net" ``` The clone copies summary, description, labels, and most routine fields. Attachments and comment history are **not** cloned. Parent/epic links may or may not carry depending on project settings. ## <a id="archive"></a>archive / unarchive / delete ```bash # Archive — reversible acli jira workitem archive --key "{{PROJECT_KEY}}-100,{{PROJECT_KEY}}-101" --yes acli jira workitem archive --jql "project = {{PROJECT_KEY}} AND resolved < -180d" --yes --ignore-errors # Unarchive — only --key and --from-file selectors are supported acli jira workitem unarchive --key "{{PROJECT_KEY}}-100,{{PROJECT_KEY}}-101" --yes # Delete — destructive, use with care (only for items created by mistake) acli jira workitem delete --key "{{PROJECT_KEY}}-999" --yes ``` Archived items no longer appear in normal search results and cannot be edited, but the key remains stable and can be restored. ## <a id="comment"></a>comment ### create ```bash # Plain text — renders as a single ADF paragraph, no markdown acli jira workitem comment create --key "{{PROJECT_KEY}}-123" --body "PR opened: https://github.com/org/repo/pull/456" # Comment body from a file (longer notes) acli jira workitem comment create --key "{{PROJECT_KEY}}-123" --body-file notes.md # Batch the same comment across many items acli jira workitem comment create --jql "labels = needs-review" \ --body "Please review by Friday." --ignore-errors # Edit the author's last comment instead of adding a new one acli jira workitem comment create --key "{{PROJECT_KEY}}-123" --body "Updated message" --edit-last # Open $EDITOR for the body acli jira workitem comment create --key "{{PROJECT_KEY}}-123" --editor ``` **`acli`'s own `--help` examples for this command are stale — do not copy them.** `acli jira workitem comment create --help` prints its examples WITHOUT the `create` subcommand: ```bash # WRONG — this is what the vendor's --help shows, and it fails acli jira workitem comment --key "{{PROJECT_KEY}}-1" --body "..." # ✗ unknown flag: --key ``` `--key` / `--body` / `--body-file` live on `create`, not on the `comment` group, so the vendor's own example exits non-zero. Always spell the subcommand. Verified against `acli` v1.3.x; the forms in this file are the tested ones. `comment create` accepts ADF via `-F, --body-file`. The flag's `--help` text reads "Plain text file with text or Atlassian Document Format (ADF)"; when the file begins with `{`, `acli` forwards the content as ADF. The legacy two-step workaround (create placeholder body → `comment update --body-adf`) is no longer required as of `acli` v1.3.18+. To author rich comments: ```bash bun .agents/skills/acli/scripts/md-to-adf.ts notes.md notes.adf.json acli jira workitem comment create --key {{PROJECT_KEY}}-123 -F notes.adf.json ``` The plain `-b, --body` flag is plain text only — Markdown syntax in `--body` is stored literally as a single ADF paragraph. For any rich content, use `-F` with an ADF file produced by the bundled converter. See "Publishing rich text" in `SKILL.md` for the full workflow. ### list ```bash acli jira workitem comment list --key "{{PROJECT_KEY}}-123" --json acli jira workitem comment list --key "{{PROJECT_KEY}}-123" --order "+created" acli jira workitem comment list --key "{{PROJECT_KEY}}-123" --paginate ``` ### delete ```bash # First find the comment ID via `comment list --json` CID=$(acli jira workitem comment list --key "{{PROJECT_KEY}}-123" --json | jq -r '.[] | select(.body | contains("typo")) | .id') # Then delete by ID acli jira workitem comment delete --key "{{PROJECT_KEY}}-123" --id "$CID" ``` Flags: `--key` (target work item), `--id` (comment ID). No batch selectors — operates on one comment at a time. ### update ```bash acli jira workitem comment update --key "{{PROJECT_KEY}}-123" --id 10001 --body "Updated text" acli jira workitem comment update --key "{{PROJECT_KEY}}-123" --id 10001 --body-adf rich.json acli jira workitem comment update --key "{{PROJECT_KEY}}-123" --id 10001 --body "Internal note" \ --visibility-role "Administrators" --notify ``` ### visibility Discover available visibility options before setting them: ```bash # Project roles (requires --project) acli jira workitem comment visibility --role --project {{PROJECT_KEY}} # Atlassian groups acli jira workitem comment visibility --group ``` ## <a id="link"></a>link ### Directionality — EMPIRICAL FLAG INVERSION (read this before any `link create`) The flag names `--out` and `--in` suggest "outward" and "inward" as defined by Jira's link-type catalog (outward description = "depends on" / "blocks" / "causes"; inward description = "is dependency for" / "is blocked by" / "is caused by"). **Empirically, acli swaps them.** Running: ```bash acli jira workitem link create --out X --in Y --type Dependencies ``` produces "**Y** depends on **X**" — i.e. **Y becomes the outward party**, **X becomes the inward party**, the opposite of what the flag names suggest. The same inversion applies to every outward-asymmetric link type. **Reverse-mapping rule of thumb**: - `--out` takes the **prerequisite** (the issue that satisfies someone else's dependency / is blocked / causes / is duplicated) - `--in` takes the **dependent** (the issue that depends on / blocks / is caused by / duplicates) #### Per-link-type quick reference | Type | Outward desc. | Inward desc. | `--out` takes | `--in` takes | | --------------- | -------------------- | ----------------------- | ---------------------------- | --------------------------- | | `Dependencies` | depends on | is dependency for | prerequisite | dependent | | `Blocks` / `Blocking` | blocks | is blocked by | the work being blocked | the blocker | | `Causes` (Problem/Incident) | causes | is caused by | the caused issue | the root cause | | `Cloners` | clones | is cloned by | the original | the clone | | `Duplicate` | duplicates | is duplicated by | the canonical issue | the duplicate | | `Defect` | created | created by | the originating work | the defect | | `Test` | tests | is tested by | the story / requirement | the test | | `Test Automation` | automation test for | is automated by | the manual test | the automation | | `Test Design` | designs | is designed by | the test | the design | | `Test Execute` | executes | is executed by | the test | the execution | | `Relates` | relates to | relates to | either | either (symmetric) | > Symmetric types (`Relates`) are immune to the inversion — direction is lost regardless of argument order. All outward-asymmetric types are affected. #### Mandatory post-create verification Every `link create` call MUST be followed by a direction check: ```bash # Confirm direction for "DEPENDENT depends on PREREQUISITE" acli jira workitem link list --key DEPENDENT --json | jq '.[] | select(.id == "<linkId>")' # Expected: outwardIssueKey == PREREQUISITE # Wrong direction? -> acli link delete --id <linkId> --yes && retry with swapped --out/--in ``` The `outwardIssueKey` field in the JSON response names the outward partner from the DEPENDENT's perspective. If it matches the intended prerequisite, the link is correct; otherwise the link is inverted and must be deleted + recreated. ### create Apply the reverse-mapping rule from the section above: `--out` takes the inward partner (the prerequisite, blocker, canonical, etc.), `--in` takes the outward partner (the dependent, blocked work, duplicate, etc.). Then verify direction immediately after. ```bash # Intent: "{{PROJECT_KEY}}-123 BLOCKS {{PROJECT_KEY}}-124" # 123 = blocker (outward partner in Jira UI) # 124 = blocked work (inward partner in Jira UI) # Per inversion: --out takes the inward = 124, --in takes the outward = 123 acli jira workitem link create --out {{PROJECT_KEY}}-124 --in {{PROJECT_KEY}}-123 --type "Blocks" --yes # Verify direction (mandatory) acli jira workitem link list --key {{PROJECT_KEY}}-123 --json # Expected: outwardIssueKey == {{PROJECT_KEY}}-124 (123 is the outward party, looking outward "blocks" → 124) # Intent: "{{PROJECT_KEY}}-456 RELATES TO {{PROJECT_KEY}}-123" — symmetric, immune to inversion acli jira workitem link create --out {{PROJECT_KEY}}-456 --in {{PROJECT_KEY}}-123 --type "Relates" --yes # Batch via JSON — the same inversion applies to outwardIssue / inwardIssue keys in the payload acli jira workitem link create --generate-json > links.json $EDITOR links.json acli jira workitem link create --from-json links.json --yes # Batch via CSV — 3 columns, header row ignored # outwardId,inwardId,linkType # CSV column names carry the inversion too: outwardId column = inward partner; inwardId column = outward partner. acli jira workitem link create --from-csv links.csv --yes ``` ### list / type ```bash # All links on a story acli jira workitem link list --key "{{PROJECT_KEY}}-123" --json # Available link types on the site (use these as --type values) acli jira workitem link type --json ``` ### delete ```bash # Single link by ID (find IDs via `link list --json`) acli jira workitem link delete --id 10042 # Batch via JSON acli jira workitem link delete --from-json links-to-remove.json --yes --ignore-errors # Batch via CSV (one ID per row) acli jira workitem link delete --from-csv link-ids.csv --yes ``` Flags: `--id`, `--from-csv`, `--from-json`, `--ignore-errors`, `--yes`. No work-item selector — operates on link IDs directly. **Deletion is the only way to fix a link's direction.** Jira dedupes a link between the same pair and the same type regardless of direction, so adding the corrected link on top of a wrong one is a **silent no-op**: no error, no new link, nothing changed. Anyone "repairing" direction by creating a second link achieves exactly nothing and has no way to notice. The sequence is: read the id (`link list --key <KEY> --json`), delete it, then create the link the right way round, then verify. Measured on a live instance while repairing coverage links. > Under Modality jira-xray the Story↔test-artifact coverage link is owned by > `/xray-cli`, which has its own delete + recreate pair and a traceability gate > that prints the offending link id. Use this command for the Jira-layer link > types `/acli` owns; do not repair a coverage link from here without reading > that skill's direction statement first. ### Linking an external URL via remote link `acli` does not expose remote-link (web link) creation. To attach a URL (e.g. a GitHub PR) to a story, fall back to REST: ```bash SITE="$(bun run --silent jira:url --slug)" curl -s -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -X POST "https://${SITE}/rest/api/3/issue/{{PROJECT_KEY}}-123/remotelink" \ -H "Content-Type: application/json" \ -d '{ "object": { "url": "https://github.com/org/repo/pull/456", "title": "PR #456: Add empty-states to the dashboard", "icon": { "url16x16": "https://github.githubassets.com/favicons/favicon.png" } } }' ``` If a GitHub-for-Jira integration is installed on the org, mentioning the story key (e.g. `{{PROJECT_KEY}}-123`) in the PR title or body is enough — the integration auto-links. The REST recipe above is the manual fallback. ## <a id="attachment"></a>attachment ```bash acli jira workitem attachment list --key "{{PROJECT_KEY}}-123" --json acli jira workitem attachment delete --key "{{PROJECT_KEY}}-123" --id 12345 ``` Upload is not yet covered by the CLI — use REST (`POST /rest/api/3/issue/{key}/attachments`). ## <a id="watcher"></a>watcher ### list ```bash # All watchers on a work item acli jira workitem watcher list --key "{{PROJECT_KEY}}-123" --json ``` Returns the watch count and the list of watcher accounts (each with `accountId`, `displayName`, `emailAddress` — the same shape Jira REST returns). ### remove ```bash acli jira workitem watcher remove --key "{{PROJECT_KEY}}-123" --user 5b10ac8d82e05b22cc7d4ef5 ``` `--user` takes an Atlassian account ID (not email). To get an account ID, run `watcher list --json` first (or look it up via `GET /rest/api/3/user/search?query=email`). **Adding watchers is not exposed by `acli`** — REST fallback: `POST /rest/api/3/issue/{key}/watchers` with the account ID as a quoted JSON string in the body. ## <a id="custom-fields"></a>Custom fields This is one of the rougher edges in `acli`. Read this section carefully — the CLI exposes first-class flags only for built-in fields (`summary`, `description`, `assignee`, `labels`, `priority`, `parent`, `type`). Everything else — story points, acceptance criteria, business rules, scope, mockup, workflow — must go through `--from-json` on `create`. **Editing custom-field values on existing items has no documented `acli` path** and requires REST/MCP. ### What `acli` documents officially `acli jira workitem create --generate-json` is the only place in the CLI that documents how to express custom fields. The output template uses a top-level wrapper called `additionalAttributes`: ```json { "summary": "Summary/Title of work item", "type": "Work item type, case sensitive, e.g. 'Story'", "projectKey": "Project key to associate the work item with, e.g. '{{PROJECT_KEY}}'", "assignee": "Assignee email or ID (optional)", "labels": ["feature", "optional"], "additionalAttributes": { "customfield_NNNN": { "value": "Custom field value" }, "customfield_NNNN": 50, "customfield_NNNN": "string value" } } ``` Three things to internalize: 1. **Wrapper key**: `additionalAttributes` (NOT `fields`, NOT flat at the root). 2. **Field address**: numeric `customfield_NNNNN` ID only. **Name-addressing (`"Story Points"`) is not supported.** 3. **Documented value shapes** (only three are illustrated by the template): - **Single-select / option**: `{"value": "..."}` - **Number**: bare numeric literal (e.g. `50`) - **String / text**: bare string ### What `acli` does NOT document — inferred from the Jira REST contract `acli` forwards `additionalAttributes` straight to the Jira REST `/rest/api/3/issue` endpoint, so the Jira REST shape applies for everything beyond the three documented types. The shapes below are inferred from REST and from `workitem view --json` output — validate by trial: | Field type | Likely input shape inside `additionalAttributes` | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Multi-select | `[{"value": "A"}, {"value": "B"}]` | | Date (date-only) | `"2026-01-18"` (YYYY-MM-DD) | | Datetime | `"2026-01-18T19:28:09.762-0300"` (ISO-8601 with offset) | | URL | bare string (`"https://example.com"`) | | Epic Link | bare string (issue key, e.g. `"{{PROJECT_KEY}}-100"`) | | User picker | `{"accountId": "5b10ac8d82e05b22cc7d4ef5"}` | | Cascading select | `{"value": "Parent", "child": {"value": "Child"}}` | | Rich text (ADF) | full ADF doc tree (same shape as `description`). Produce the tree from Markdown via `scripts/md-to-adf.ts`, then nest the result inside `additionalAttributes` for `create`, or inside `{"fields": {...}}` for a REST `PUT` on an existing item. See "Publishing rich text" in `SKILL.md`. | | Sprint | array of sprint IDs `[5]` — but **`JRACLOUD-97107` makes this fail in practice**, see [Sprint field cannot be set](./gotchas.md#sprint) | If a shape isn't listed here, the safest source of truth is `acli jira workitem view <KEY-WITH-FIELD-SET> --fields "*all" --json` — the read shape is usually identical to the write shape for that field type. ### Critical limitation: `workitem edit` does not document custom-field input Running `acli jira workitem edit --generate-json` produces: ```json { "summary": "...", "type": "...", "assignee": "...", "description": { /* ADF */ }, "issues": ["KEY-1", "KEY-2"], "labelsToAdd": ["feature"], "labelsToRemove": ["feature"] } ``` **No `additionalAttributes` block.** The flag list on `edit --help` confirms: only built-in fields are supported (`--summary`, `--description`, `--description-file`, `--assignee`, `--labels`, `--type`, `--remove-assignee`, `--remove-labels`). For editing a custom-field value on an existing work item (e.g. updating Story Points after estimation, polishing ACs after a 3-amigos), fall back to REST: ```bash SITE="$(bun run --silent jira:url --slug)" curl -s -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -X PUT "https://${SITE}/rest/api/3/issue/{{PROJECT_KEY}}-123" \ -H "Content-Type: application/json" \ -d '{"fields": {"customfield_NNNNN": 8}}' ``` Note the REST shape uses `{"fields": {...}}`, not `additionalAttributes`. ### Critical limitation: bulk operations do not document custom-field input `acli jira workitem create-bulk --generate-json` and the CSV column list (`summary, projectKey, issueType, description, label, parentIssueId, assignee`) both omit any way to set custom fields. If you need bulk creation with custom fields (e.g. seeding a backlog with ACs already attached), the workaround is single-create-in-a-loop or REST batch. ### Finding a custom field ID `acli` cannot enumerate custom fields (`field` group only does create/update/delete/cancel-delete). To discover IDs: ```bash SITE="$(bun run --silent jira:url --slug)" # From an existing item that has the field set acli jira workitem view {{PROJECT_KEY}}-123 --json | jq '.fields | keys[] | select(startswith("customfield_"))' # From the field admin UI — the ID is in the URL when editing the field # https://${SITE}/secure/admin/EditCustomField!default.jspa?id=NNNNN # From REST — the only way to enumerate ALL fields on the site curl -s -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ "https://${SITE}/rest/api/3/field" | jq '.[] | {id, name, custom, schema}' ``` > Repo integration: host repos typically cache the field catalog under `.agents/` and reference fields by stable slug rather than numeric ID. See the host repo's `<repo-core>/references/acli-integration.md` for the slug catalog and refresh script. ### Putting it together — full `create` example with custom fields ```bash # 1. Scaffold acli jira workitem create --generate-json > new-story.json # 2. Edit to include custom fields (IDs come from your workspace — see "Finding a custom field ID" above) cat > new-story.json <<'JSON' { "summary": "Add empty-states to the dashboard", "type": "Story", "projectKey": "{{PROJECT_KEY}}", "assignee": "you@example.com", "labels": ["frontend", "empty-states"], "additionalAttributes": { "customfield_NNNN": 5, "customfield_NNNNN": "Given a user with no items\nWhen they open the dashboard\nThen they see the empty-state illustration" } } JSON # 3. Submit acli jira workitem create --from-json new-story.json ``` _(IDs like `customfield_NNNN` and `customfield_NNNNN` are placeholders — substitute your workspace's real IDs.)_
-
-
scripts
-
jira-attach-media.test.ts 2.2 KB
import { expect, test, describe } from "bun:test"; import { buildMediaNode, imageSize } from "./jira-attach-media.ts"; import { validateAdf } from "./md-to-adf.ts"; // 1x1 red PNG const PNG_1x1 = Uint8Array.from( atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="), (c) => c.charCodeAt(0), ); describe("imageSize (zero-dep header read)", () => { test("reads PNG IHDR dimensions", () => { expect(imageSize(PNG_1x1)).toEqual({ width: 1, height: 1 }); }); test("reads GIF dimensions (little-endian)", () => { // GIF89a header, logical screen 3x2 const gif = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x03, 0x00, 0x02, 0x00]); expect(imageSize(gif)).toEqual({ width: 3, height: 2 }); }); test("returns null for an unknown format (e.g. video)", () => { expect(imageSize(Uint8Array.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]))).toBeNull(); }); }); describe("buildMediaNode", () => { test("canonical shape — file type, empty collection, optional dims", () => { const node = buildMediaNode("abc-uuid", { width: 800, height: 600, alt: "shot.png" }); expect(node).toEqual({ type: "mediaSingle", attrs: { layout: "center" }, content: [ { type: "media", attrs: { type: "file", id: "abc-uuid", collection: "", width: 800, height: 600, alt: "shot.png" } }, ], }); }); test("omits width/height when not provided; honours layout", () => { const node = buildMediaNode("u", { layout: "wide" }); expect(node.attrs.layout).toBe("wide"); expect(node.content[0].attrs).toEqual({ type: "file", id: "u", collection: "" }); }); test("the built node passes the ADF validator inside a doc", () => { const doc = { type: "doc", version: 1, content: [buildMediaNode("u", { width: 1, height: 1 })] }; const { valid, errors } = validateAdf(doc); if (!valid) throw new Error(JSON.stringify(errors)); expect(valid).toBe(true); }); test("validator rejects a media node missing id", () => { const bad = { type: "doc", version: 1, content: [{ type: "mediaSingle", attrs: { layout: "center" }, content: [{ type: "media", attrs: { type: "file" } }] }] }; expect(validateAdf(bad).valid).toBe(false); }); }); -
jira-attach-media.ts 9 KB
#!/usr/bin/env bun /** * Embed an uploaded image / video inline in a Jira Cloud ADF field. * * Bundled with the acli skill. ADF media is NOT plain Markdown — `` * does not work, because a media node needs the opaque media-services UUID of an * uploaded file, which the public attachments API does not return directly. This * helper performs the verified 3-step recipe so callers do not have to: * * 1. Upload the file as a Jira attachment * POST /rest/api/3/issue/{key}/attachments (header X-Atlassian-Token: no-check) * → returns a NUMERIC attachment id (unusable in a media node) + a `content` URL. * 2. Resolve the media-services UUID * GET {content-url} with redirect disabled → the 302 `Location` is * https://api.media.atlassian.com/file/<UUID>/binary?... → extract <UUID>. * 3. Build the ADF node * mediaSingle > media{ type:"file", id:<UUID>, collection:"", width, height } * Jira ignores the `collection` input and stores it as "" — verified empirically. * * Image dimensions are auto-detected for PNG / JPEG / GIF (zero-dependency header * reads); pass --width / --height to override or for formats / videos we cannot size. * * Credentials come from the shell env (loaded from .env by the project tooling): * ATLASSIAN_EMAIL · ATLASSIAN_API_TOKEN * * The INSTANCE HOST does not: it is read from `.agents/project.yaml` -> * issue_tracker.atlassian_url, with ATLASSIAN_URL as fallback only. See * cli/lib/atlassian-instance.ts for why (a stale env host attaches evidence to * the wrong Atlassian site). * * CLI: * bun jira-attach-media.ts <ISSUE-KEY> <file> # print the mediaSingle node JSON * bun jira-attach-media.ts <ISSUE-KEY> <file> --doc # wrap in a full ADF doc * bun jira-attach-media.ts <ISSUE-KEY> <file> --publish # post a comment with the image * bun jira-attach-media.ts <ISSUE-KEY> <file> --publish --caption "Repro step 3" * bun jira-attach-media.ts <ISSUE-KEY> <file> --width 800 --height 600 --layout wide * * Module: * import { uploadAttachment, resolveMediaId, buildMediaNode } from "./jira-attach-media.ts"; */ import { formatInstanceMismatchWarning, resolveAtlassianInstance, } from "../../../../cli/lib/atlassian-instance"; type MediaNode = { type: "mediaSingle"; attrs: { layout: string }; content: Array<{ type: "media"; attrs: Record<string, unknown> }>; }; type Attachment = { id: string; content: string; filename: string; mimeType: string }; function env(name: string): string { const v = process.env[name]; if (!v) { throw new Error( `missing env var ${name} — load it from .env (bun claude / bun opencode / direnv) and retry`, ); } return v; } /** * Instance host, resolved from `.agents/project.yaml` -> issue_tracker.atlassian_url * FIRST and only falling back to `ATLASSIAN_URL`. This helper UPLOADS files and * POSTS comments, so a stale env host would push bug evidence into whatever issue * happens to carry the same key on the other Atlassian site. Resolved once per * process; the mismatch warning is printed at most once. * Rationale: cli/lib/atlassian-instance.ts. */ let instanceCache: string | null = null; function instanceUrl(): string { if (instanceCache !== null) return instanceCache; const resolved = resolveAtlassianInstance(); const warning = formatInstanceMismatchWarning(resolved); if (warning) console.error(`⚠ ${warning}`); instanceCache = resolved.baseUrl; return instanceCache; } function authHeader(): string { return "Basic " + btoa(`${env("ATLASSIAN_EMAIL")}:${env("ATLASSIAN_API_TOKEN")}`); } // Zero-dependency intrinsic-size read for the common raster formats. Returns null // when the format is unknown (video, SVG, etc.) — the caller then omits width/height. function imageSize(buf: Uint8Array): { width: number; height: number } | null { const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); // PNG: \x89PNG, IHDR width@16 height@20 (big-endian uint32) if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) { return { width: dv.getUint32(16), height: dv.getUint32(20) }; } // GIF: GIF8, width@6 height@8 (little-endian uint16) if (buf.length >= 10 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) { return { width: dv.getUint16(6, true), height: dv.getUint16(8, true) }; } // JPEG: scan for a Start-Of-Frame marker, height@+5 width@+7 (big-endian) if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) { let off = 2; while (off + 9 < buf.length) { if (buf[off] !== 0xff) { off++; continue; } const marker = buf[off + 1]; if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { return { width: dv.getUint16(off + 7), height: dv.getUint16(off + 5) }; } off += 2 + dv.getUint16(off + 2); } } return null; } async function uploadAttachment(issueKey: string, filePath: string): Promise<Attachment> { const base = instanceUrl(); const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer()); const name = filePath.split("/").pop() || "attachment"; const form = new FormData(); form.append("file", new File([bytes], name)); const res = await fetch(`${base}/rest/api/3/issue/${issueKey}/attachments`, { method: "POST", headers: { Authorization: authHeader(), "X-Atlassian-Token": "no-check" }, body: form, }); if (!res.ok) { throw new Error(`attachment upload failed: HTTP ${res.status} — ${await res.text()}`); } const arr = (await res.json()) as Attachment[]; if (!Array.isArray(arr) || !arr[0]?.content) { throw new Error(`unexpected attachments response: ${JSON.stringify(arr)}`); } return arr[0]; } // Follow (without downloading) the attachment content URL to read the media-services // UUID out of the 302 redirect to api.media.atlassian.com. async function resolveMediaId(contentUrl: string): Promise<string> { const res = await fetch(contentUrl, { method: "GET", headers: { Authorization: authHeader() }, redirect: "manual", }); const loc = res.headers.get("location"); if (!loc) { throw new Error(`no redirect from ${contentUrl} (HTTP ${res.status}) — cannot resolve media id`); } const m = /\/file\/([0-9a-f-]+)\//i.exec(loc); if (!m) { throw new Error(`could not parse media UUID from redirect Location: ${loc}`); } return m[1]; } function buildMediaNode( mediaId: string, opts: { width?: number; height?: number; alt?: string; layout?: string } = {}, ): MediaNode { const attrs: Record<string, unknown> = { type: "file", id: mediaId, collection: "" }; if (opts.width) attrs.width = opts.width; if (opts.height) attrs.height = opts.height; if (opts.alt) attrs.alt = opts.alt; return { type: "mediaSingle", attrs: { layout: opts.layout || "center" }, content: [{ type: "media", attrs }], }; } export { uploadAttachment, resolveMediaId, buildMediaNode, imageSize }; if (import.meta.main) { const argv = process.argv.slice(2); const flag = (name: string): string | undefined => { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : undefined; }; const has = (name: string) => argv.includes(name); const positional = argv.filter((a, i) => !a.startsWith("--") && !argv[i - 1]?.startsWith("--")); const [issueKey, filePath] = positional; if (!issueKey || !filePath) { console.error("usage: bun jira-attach-media.ts <ISSUE-KEY> <file> [--publish] [--doc] [--caption TEXT] [--width N --height N --layout center|wide|full-width]"); process.exit(2); } const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer()); const detected = imageSize(bytes); const width = flag("--width") ? Number(flag("--width")) : detected?.width; const height = flag("--height") ? Number(flag("--height")) : detected?.height; const att = await uploadAttachment(issueKey, filePath); const mediaId = await resolveMediaId(att.content); const node = buildMediaNode(mediaId, { width, height, alt: flag("--alt") || att.filename, layout: flag("--layout"), }); const caption = flag("--caption"); const docContent: unknown[] = []; if (caption) docContent.push({ type: "paragraph", content: [{ type: "text", text: caption }] }); docContent.push(node); const doc = { type: "doc", version: 1, content: docContent }; if (has("--publish")) { const res = await fetch(`${instanceUrl()}/rest/api/3/issue/${issueKey}/comment`, { method: "POST", headers: { Authorization: authHeader(), "Content-Type": "application/json" }, body: JSON.stringify({ body: doc }), }); if (!res.ok) { console.error(`comment publish failed: HTTP ${res.status} — ${await res.text()}`); process.exit(1); } console.error(`✓ published image comment on ${issueKey} (attachment ${att.id}, media ${mediaId})`); } else if (has("--doc")) { process.stdout.write(JSON.stringify(doc, null, 2)); } else { process.stdout.write(JSON.stringify(node, null, 2)); } } -
md-to-adf.test.ts 7.5 KB
import { expect, test, describe } from "bun:test"; import { mdToAdf, validateAdf } from "./md-to-adf.ts"; const valid = (md: string) => { const adf = mdToAdf(md); const { valid, errors } = validateAdf(adf); if (!valid) throw new Error("invalid ADF:\n" + JSON.stringify(errors, null, 2)); return adf; }; describe("tables (GFM)", () => { test("header + body → table/tableRow/tableHeader/tableCell", () => { const adf = valid("| Name | Role |\n| --- | --- |\n| Ada | Eng |\n| Lin | PM |"); const table = adf.content[0]; expect(table.type).toBe("table"); expect(table.content).toHaveLength(3); // 1 header row + 2 body rows expect(table.content![0].content![0].type).toBe("tableHeader"); expect(table.content![1].content![0].type).toBe("tableCell"); // inline marks survive inside cells const adf2 = valid("| A | B |\n|---|---|\n| **bold** | `code` |"); const cell0 = adf2.content[0].content![1].content![0]; expect(cell0.content![0].content![0].marks?.[0].type).toBe("strong"); }); test("edge pipes optional + escaped pipe", () => { const adf = valid("a | b\n:--|--:\nx \\| y | z"); const row = adf.content[0].content![1]; expect(row.content![0].content![0].content![0].text).toBe("x | y"); }); test("a lone pipe in prose is NOT a table", () => { const adf = mdToAdf("use a | b pipe in a sentence"); expect(adf.content[0].type).toBe("paragraph"); }); }); describe("panels (GitHub alerts)", () => { test.each([ ["NOTE", "info"], ["TIP", "success"], ["IMPORTANT", "note"], ["WARNING", "warning"], ["CAUTION", "error"], ["INFO", "info"], ["SUCCESS", "success"], ["ERROR", "error"], ])("[!%s] → panelType %s", (kw, panelType) => { const adf = valid(`> [!${kw}]\n> body text`); expect(adf.content[0].type).toBe("panel"); expect(adf.content[0].attrs!.panelType).toBe(panelType); }); test("panel body re-parses as markdown (holds a list)", () => { const adf = valid("> [!WARNING]\n> - one\n> - two"); expect(adf.content[0].content![0].type).toBe("bulletList"); expect(adf.content[0].content![0].content).toHaveLength(2); }); test("plain blockquote stays a blockquote", () => { const adf = valid("> just a quote"); expect(adf.content[0].type).toBe("blockquote"); }); }); describe("nested lists", () => { test("indentation deepens a level", () => { const adf = valid("- a\n- b\n - b1\n - b2\n- c"); const list = adf.content[0]; expect(list.type).toBe("bulletList"); expect(list.content).toHaveLength(3); // a, b, c const b = list.content![1]; expect(b.content).toHaveLength(2); // paragraph + nested list expect(b.content![1].type).toBe("bulletList"); expect(b.content![1].content).toHaveLength(2); // b1, b2 }); test("ordered nested under bullet", () => { const adf = valid("- step\n 1. first\n 2. second"); const nested = adf.content[0].content![0].content![1]; expect(nested.type).toBe("orderedList"); }); }); describe("expand", () => { test("<details><summary> → expand with title", () => { const adf = valid("<details>\n<summary>More</summary>\n\nhidden **body**\n\n</details>"); expect(adf.content[0].type).toBe("expand"); expect(adf.content[0].attrs!.title).toBe("More"); expect(adf.content[0].content![0].type).toBe("paragraph"); }); }); describe("validator gate", () => { test("unknown panelType rejected", () => { const bad = { type: "doc", version: 1, content: [{ type: "panel", attrs: { panelType: "bogus" }, content: [{ type: "paragraph", content: [] }] }] }; expect(validateAdf(bad).valid).toBe(false); }); test("regression: existing subset still valid", () => { valid("# H1\n\npara with **b** and `c`\n\n- x\n- y\n\n```ts\nconst a = 1;\n```\n\n> quote\n\n---"); }); }); describe("emoji (Jira-native shortNames)", () => { test(":short_name: → emoji node, curated ones carry text fallback", () => { const adf = valid("status :white_check_mark: done, :x: failed"); const inline = adf.content[0].content!; const emojis = inline.filter((n) => n.type === "emoji"); expect(emojis).toHaveLength(2); expect(emojis[0].attrs).toEqual({ shortName: ":white_check_mark:", text: "✅" }); expect(emojis[1].attrs).toEqual({ shortName: ":x:", text: "❌" }); }); test("unknown shortName still converts (shortName-only, no text)", () => { const adf = valid("ship it :rocket:"); const emoji = adf.content[0].content!.find((n) => n.type === "emoji"); expect(emoji!.attrs).toEqual({ shortName: ":rocket:" }); }); test("a lone colon / time is NOT an emoji", () => { const adf = valid("meeting at 12:30 sharp"); const inline = adf.content[0].content!; expect(inline.some((n) => n.type === "emoji")).toBe(false); // text may split across nodes around the colon — join and check round-trip const joined = inline.map((n) => n.text ?? "").join(""); expect(joined).toBe("meeting at 12:30 sharp"); }); test("emoji survives inside bold", () => { const adf = valid("**done :white_check_mark:**"); const emoji = adf.content[0].content!.find((n) => n.type === "emoji"); expect(emoji).toBeDefined(); }); }); describe("status lozenge", () => { test("{status:color|TEXT} → status node", () => { const adf = valid("build {status:green|DONE} and {status:yellow|IN PROGRESS}"); const statuses = adf.content[0].content!.filter((n) => n.type === "status"); expect(statuses).toHaveLength(2); expect(statuses[0].attrs).toEqual({ text: "DONE", color: "green" }); expect(statuses[1].attrs).toEqual({ text: "IN PROGRESS", color: "yellow" }); }); test("invalid color is not matched (stays literal text)", () => { const adf = valid("{status:fuchsia|NOPE}"); expect(adf.content[0].content!.some((n) => n.type === "status")).toBe(false); }); test("validator rejects a hand-authored bad status color", () => { const bad = { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "status", attrs: { text: "X", color: "fuchsia" } }] }] }; expect(validateAdf(bad).valid).toBe(false); }); test("validator rejects emoji missing shortName", () => { const bad = { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "emoji", attrs: {} }] }] }; expect(validateAdf(bad).valid).toBe(false); }); }); describe("mention", () => { test("@[Name](accountId) → mention node", () => { const adf = valid("ping @[Ada Lovelace](557058:abc-123) please"); const mention = adf.content[0].content!.find((n) => n.type === "mention"); expect(mention!.attrs).toEqual({ id: "557058:abc-123", text: "@Ada Lovelace" }); }); test("a bare @name is NOT a mention (no accountId available)", () => { const adf = valid("cc @ada and @bob"); expect(adf.content[0].content!.some((n) => n.type === "mention")).toBe(false); expect(adf.content[0].content!.map((n) => n.text ?? "").join("")).toBe("cc @ada and @bob"); }); test("a normal [label](url) link is not mistaken for a mention", () => { const adf = valid("see [docs](https://x.dev)"); const link = adf.content[0].content!.find((n) => n.marks?.some((m) => m.type === "link")); expect(link).toBeDefined(); expect(adf.content[0].content!.some((n) => n.type === "mention")).toBe(false); }); test("validator rejects a mention missing id", () => { const bad = { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "mention", attrs: { text: "@x" } }] }] }; expect(validateAdf(bad).valid).toBe(false); }); }); -
md-to-adf.ts 29.6 KB
#!/usr/bin/env bun /** * Minimal Markdown → ADF (Atlassian Document Format) converter. * * Bundled with the acli skill so any caller can publish rich text to Jira * via the standard MD → ADF → acli (or REST) workflow. * * Covered Markdown subset: * - Headings: # / ## / ### / #### / ##### / ###### → ADF heading levels 1-6 * - Bullet lists: -, * (nested via indentation — 2+ spaces deepens a level) * - Ordered lists: 1. (nested via indentation; mixes with bullets per level) * - Tables: GFM pipe tables (| a | b | + |---|---| separator) → ADF table * - Panels: GitHub-alert blockquotes (> [!NOTE] / [!WARNING] / [!INFO] / * [!SUCCESS] / [!ERROR] / [!TIP] / [!IMPORTANT] / [!CAUTION]) → ADF panel * - Expand: <details><summary>Title</summary> … </details> → ADF expand * - Fenced code blocks: ```lang ... ``` (language tag preserved as attrs.language) * - Inline code: `code` * - Bold: **text** or __text__ * - Italic: *text* or _text_ (snake_case-safe — will not mangle identifiers) * - Strikethrough: ~~text~~ * - Emoji (Jira-native): :short_name: → ADF emoji node (Jira resolves the name) * - Status lozenge: {status:color|TEXT} → ADF status pill * (color: neutral | purple | blue | red | yellow | green) * - Mention: @[Display Name](accountId) → ADF mention node (accountId is the * opaque Atlassian id, resolved out-of-band — a bare @name cannot mention) * - Links: [label](url) * - Blockquotes: > line * - Horizontal rule: --- * - Paragraphs (default block) * * Out of scope (extend if your project needs them): * mentions, status macros, media / images, nestedExpand (expand inside a * table cell). * * Validation gate (zero-dependency): * Conversion output is validated against an embedded ADF allowlist BEFORE it * is written, so structural errors fail fast at author time instead of as an * opaque HTTP 400 from Jira at publish time. No external packages — the * @atlaskit/adf-* validators pull ProseMirror + Statsig and break this * converter's zero-dep contract, so the rules live inline here. * * Runtime: Bun ≥ 1.0. Uses Bun.file / Bun.stdin / Bun.write. * * CLI: * bun md-to-adf.ts <input.md> [output.json] # convert (validates by default) * bun md-to-adf.ts - # read MD from stdin * cat input.md | bun md-to-adf.ts - output.json * bun md-to-adf.ts <input.md> --no-validate # skip the validation gate * bun md-to-adf.ts --check <file.adf.json> # validate an existing ADF doc and exit * * Module: * import { mdToAdf, validateAdf } from "./md-to-adf.ts"; * const adf = mdToAdf(markdownString); // returns { type: "doc", version: 1, content: [...] } * const { valid, errors } = validateAdf(adf); // gate any ADF (converted, jq-assembled, REST body) */ type ADFNode = { type: string; attrs?: Record<string, unknown>; content?: ADFNode[]; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }>; }; // Curated Unicode fallback for the most useful Jira-native emoji shortNames. // Jira resolves the shortName on its own; the `text` fallback only helps where // the shortName is unknown to a renderer. Unlisted `:short_names:` still convert // (shortName-only) — this map exists so the common status marks carry a glyph. const EMOJI_TEXT: Record<string, string> = { ":white_check_mark:": "✅", ":heavy_check_mark:": "✔️", ":x:": "❌", ":warning:": "⚠️", ":hourglass_flowing_sand:": "⏳", ":white_circle:": "⚪", ":no_entry:": "⛔", ":information_source:": "ℹ️", }; // ---------- inline parser ---------- // // Walks the line left→right and emits text + nested marks. Order of checks // matters: code (backtick) wins over bold/italic, bold wins over italic, // links are matched before bare text so URL contents don't get re-parsed. function parseInline(input: string): ADFNode[] { const nodes: ADFNode[] = []; let i = 0; const pushText = (text: string, marks?: ADFNode["marks"]) => { if (!text) return; const node: ADFNode = { type: "text", text }; if (marks && marks.length) node.marks = marks; nodes.push(node); }; while (i < input.length) { // Inline code: `...` if (input[i] === "`") { const end = input.indexOf("`", i + 1); if (end > i) { pushText(input.slice(i + 1, end), [{ type: "code" }]); i = end + 1; continue; } } // Link: [text](url) if (input[i] === "[") { const closeBracket = input.indexOf("]", i + 1); if (closeBracket > i && input[closeBracket + 1] === "(") { const closeParen = input.indexOf(")", closeBracket + 2); if (closeParen > closeBracket) { const label = input.slice(i + 1, closeBracket); const href = input.slice(closeBracket + 2, closeParen); pushText(label, [{ type: "link", attrs: { href } }]); i = closeParen + 1; continue; } } } // Bold: **...** or __...__ if (input.startsWith("**", i) || input.startsWith("__", i)) { const delim = input.slice(i, i + 2); const end = input.indexOf(delim, i + 2); if (end > i + 1) { const inner = input.slice(i + 2, end); // recurse to allow inline code / italic inside bold const innerNodes = parseInline(inner); for (const n of innerNodes) { if (n.type === "text") { const existing = n.marks ?? []; // Jira ADF rejects (HTTP 400) any text node carrying both `code` and // `strong` marks. When they would co-occur, keep `code` and drop // `strong`: inline code is semantically dominant, and bolding an // inline-code span is rare and usually accidental authoring. if (existing.some((m) => m.type === "code")) { n.marks = existing; } else { const hasStrong = existing.some((m) => m.type === "strong"); n.marks = hasStrong ? existing : [...existing, { type: "strong" }]; } } nodes.push(n); } i = end + 2; continue; } } // Italic: *...* or _..._ (snake_case-safe) if (input[i] === "*" || input[i] === "_") { const delim = input[i]; // Avoid bold (already handled above) if (input[i + 1] === delim) { // Not italic — pass through as text } else { // Find matching delim; require it does NOT abut alphanumerics inside snake_case let end = -1; for (let j = i + 1; j < input.length; j++) { if (input[j] === delim && input[j + 1] !== delim) { // Underscore italic: refuse if both sides are word chars (snake_case) if (delim === "_") { const before = input[i - 1]; const after = input[j + 1]; if (/[\w]/.test(before ?? "") || /[\w]/.test(after ?? "")) { break; } } end = j; break; } } if (end > i) { const inner = input.slice(i + 1, end); const innerNodes = parseInline(inner); for (const n of innerNodes) { if (n.type === "text") { const existing = n.marks ?? []; // See bold branch above: Jira rejects `code` + `em` on the same // text node. Keep `code` and drop `em` — inline code is dominant. if (existing.some((m) => m.type === "code")) { n.marks = existing; } else { const hasEm = existing.some((m) => m.type === "em"); n.marks = hasEm ? existing : [...existing, { type: "em" }]; } } nodes.push(n); } i = end + 1; continue; } } } // Strikethrough: ~~...~~ if (input.startsWith("~~", i)) { const end = input.indexOf("~~", i + 2); if (end > i + 1) { const inner = input.slice(i + 2, end); const innerNodes = parseInline(inner); for (const n of innerNodes) { if (n.type === "text") { const existing = n.marks ?? []; n.marks = [...existing, { type: "strike" }]; } nodes.push(n); } i = end + 2; continue; } } // Jira mention: @[Display Name](accountId) → mention node. The accountId must // be supplied explicitly — a bare @name cannot mention (Jira needs the opaque // Atlassian accountId, resolved out-of-band; see references/adf-authoring-style.md). if (input[i] === "@" && input[i + 1] === "[") { const m = /^@\[([^\]]+)\]\(([^)]+)\)/.exec(input.slice(i)); if (m) { nodes.push({ type: "mention", attrs: { id: m[2].trim(), text: `@${m[1].trim()}` }, }); i += m[0].length; continue; } } // Jira-native emoji: :short_name: → emoji node (Jira resolves the shortName). // Pattern is the GitHub/Slack shortname shape; inline code is parsed earlier, // so a colon inside `code` never reaches here. if (input[i] === ":") { const m = /^:([a-z0-9][a-z0-9_+-]*):/.exec(input.slice(i)); if (m) { const shortName = `:${m[1]}:`; const attrs: Record<string, unknown> = { shortName }; if (EMOJI_TEXT[shortName]) attrs.text = EMOJI_TEXT[shortName]; nodes.push({ type: "emoji", attrs }); i += m[0].length; continue; } } // Jira status lozenge: {status:color|TEXT} → status node (the coloured pill). if (input[i] === "{") { const m = /^\{status:(neutral|purple|blue|red|yellow|green)\|([^}]+)\}/i.exec( input.slice(i), ); if (m) { nodes.push({ type: "status", attrs: { text: m[2].trim(), color: m[1].toLowerCase() }, }); i += m[0].length; continue; } } // Default: accumulate plain text until the next special char let chunkEnd = i; while (chunkEnd < input.length) { const c = input[chunkEnd]; if (c === "`" || c === "[" || c === "*" || c === "_" || c === "~" || c === ":" || c === "{" || c === "@") { break; } chunkEnd++; } if (chunkEnd === i) { pushText(input[i]); i++; } else { pushText(input.slice(i, chunkEnd)); i = chunkEnd; } } return nodes; } // ---------- block parser ---------- function mdToAdf(markdown: string): { type: "doc"; version: 1; content: ADFNode[]; } { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const blocks: ADFNode[] = []; let i = 0; // A list line at any indentation: capture (indent, marker, text). const LIST_LINE = /^(\s*)([-*]|\d+\.)\s+(.*)$/; // A GFM table separator row: |---|:--:|---| (pipes optional at the edges). const TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/; // GitHub-alert blockquote opener: > [!NOTE] etc. (alone on its own line). const PANEL_OPEN = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|INFO|SUCCESS|ERROR)\]\s*$/i; // GitHub alert keyword → ADF panelType (info | note | success | warning | error). const PANEL_TYPE: Record<string, string> = { note: "info", info: "info", tip: "success", success: "success", important: "note", warning: "warning", caution: "error", error: "error", }; // Split a table row on unescaped pipes, honouring `\|`, then trim the empty // cells produced by leading/trailing edge pipes. const splitRow = (row: string): string[] => { const cells: string[] = []; let cur = ""; for (let k = 0; k < row.length; k++) { if (row[k] === "\\" && row[k + 1] === "|") { cur += "|"; k++; continue; } if (row[k] === "|") { cells.push(cur); cur = ""; continue; } cur += row[k]; } cells.push(cur); if (cells.length && cells[0].trim() === "") cells.shift(); if (cells.length && cells[cells.length - 1].trim() === "") cells.pop(); return cells.map((c) => c.trim()); }; const consumeFencedCode = (): ADFNode | null => { const open = lines[i].match(/^```(\w*)\s*$/); if (!open) return null; const lang = open[1] || ""; const codeLines: string[] = []; i++; while (i < lines.length && !/^```\s*$/.test(lines[i])) { codeLines.push(lines[i]); i++; } if (i < lines.length) i++; // consume closing ``` const text = codeLines.join("\n"); return { type: "codeBlock", attrs: lang ? { language: lang } : {}, content: text ? [{ type: "text", text }] : [], }; }; // Nested-list parser. Indentation defines depth: a list line whose indent is // strictly greater than the current item's becomes a sublist attached inside // that item's listItem. List type (bullet vs ordered) is fixed per level by // the first marker seen at that indent. const parseListAtIndent = (indent: number): ADFNode => { const first = lines[i].match(LIST_LINE)!; const ordered = /\d+\./.test(first[2]); const items: ADFNode[] = []; while (i < lines.length) { const m = lines[i].match(LIST_LINE); if (!m) break; const curIndent = m[1].length; if (curIndent !== indent) break; // dedent or deeper indent → not our level const itemContent: ADFNode[] = [ { type: "paragraph", content: parseInline(m[3]) }, ]; i++; const next = i < lines.length ? lines[i].match(LIST_LINE) : null; if (next && next[1].length > indent) { itemContent.push(parseListAtIndent(next[1].length)); } items.push({ type: "listItem", content: itemContent }); } return { type: ordered ? "orderedList" : "bulletList", content: items }; }; const consumeList = (): ADFNode | null => { const m = lines[i].match(LIST_LINE); if (!m) return null; return parseListAtIndent(m[1].length); }; // GFM pipe table. Recognised only when the line contains a pipe AND the next // line is a separator row (|---|---|), which disambiguates it from prose that // happens to contain a pipe character. const consumeTable = (): ADFNode | null => { if (!lines[i].includes("|")) return null; if (i + 1 >= lines.length || !TABLE_SEP.test(lines[i + 1])) return null; const headerCells = splitRow(lines[i]); i += 2; // consume the header row + the separator row const rows: ADFNode[] = [ { type: "tableRow", content: headerCells.map((c) => ({ type: "tableHeader", content: [{ type: "paragraph", content: parseInline(c) }], })), }, ]; while (i < lines.length && lines[i].trim() !== "" && lines[i].includes("|")) { const cells = splitRow(lines[i]); rows.push({ type: "tableRow", content: cells.map((c) => ({ type: "tableCell", content: [{ type: "paragraph", content: parseInline(c) }], })), }); i++; } return { type: "table", attrs: { isNumberColumnEnabled: false, layout: "default" }, content: rows, }; }; // GitHub-alert blockquote → ADF panel. The body (every following `>` line) is // re-parsed as Markdown so panels can hold lists, code, paragraphs, etc. const consumePanel = (): ADFNode | null => { const m = lines[i].match(PANEL_OPEN); if (!m) return null; const panelType = PANEL_TYPE[m[1].toLowerCase()]; i++; const body: string[] = []; while (i < lines.length && /^>\s?/.test(lines[i])) { body.push(lines[i].replace(/^>\s?/, "")); i++; } const inner = mdToAdf(body.join("\n")).content; return { type: "panel", attrs: { panelType }, content: inner.length ? inner : [{ type: "paragraph", content: [] }], }; }; // <details><summary>Title</summary> … </details> → ADF expand. The body is // re-parsed as Markdown so expand blocks can hold any block content. const consumeExpand = (): ADFNode | null => { if (!/^<details>\s*$/.test(lines[i])) return null; i++; let title = ""; const sm = i < lines.length ? lines[i].match(/^<summary>(.*)<\/summary>\s*$/) : null; if (sm) { title = sm[1].trim(); i++; } const body: string[] = []; while (i < lines.length && !/^<\/details>\s*$/.test(lines[i])) { body.push(lines[i]); i++; } if (i < lines.length) i++; // consume the closing </details> const inner = mdToAdf(body.join("\n")).content; return { type: "expand", attrs: { title }, content: inner.length ? inner : [{ type: "paragraph", content: [] }], }; }; const consumeBlockquote = (): ADFNode | null => { if (!/^>\s?/.test(lines[i])) return null; const chunks: string[] = []; while (i < lines.length && /^>\s?/.test(lines[i])) { chunks.push(lines[i].replace(/^>\s?/, "")); i++; } return { type: "blockquote", content: [ { type: "paragraph", content: parseInline(chunks.join("\n")) }, ], }; }; const consumeHeading = (): ADFNode | null => { const m = lines[i].match(/^(#{1,6})\s+(.+)$/); if (!m) return null; const level = Math.min(m[1].length, 6); const text = m[2]; i++; return { type: "heading", attrs: { level }, content: parseInline(text), }; }; const consumeHorizontalRule = (): ADFNode | null => { if (!/^---+\s*$/.test(lines[i]) && !/^\*\*\*+\s*$/.test(lines[i])) { return null; } i++; return { type: "rule" }; }; const consumeParagraph = (): ADFNode => { const buf: string[] = []; while ( i < lines.length && lines[i].trim() !== "" && !/^#{1,6}\s+/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !/^```/.test(lines[i]) && !/^<details>\s*$/.test(lines[i]) && !/^---+\s*$/.test(lines[i]) && !(lines[i].includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) ) { buf.push(lines[i]); i++; } const text = buf.join("\n"); return { type: "paragraph", content: parseInline(text) }; }; while (i < lines.length) { if (lines[i].trim() === "") { i++; continue; } let block: ADFNode | null = null; block = consumeFencedCode(); if (block) { blocks.push(block); continue; } block = consumeExpand(); if (block) { blocks.push(block); continue; } block = consumeHeading(); if (block) { blocks.push(block); continue; } block = consumeHorizontalRule(); if (block) { blocks.push(block); continue; } block = consumeTable(); if (block) { blocks.push(block); continue; } block = consumePanel(); if (block) { blocks.push(block); continue; } block = consumeBlockquote(); if (block) { blocks.push(block); continue; } block = consumeList(); if (block) { blocks.push(block); continue; } blocks.push(consumeParagraph()); } return { type: "doc", version: 1, content: blocks }; } // ---------- ADF validator (zero-dependency gate) ---------- // // Validates an ADF document against an embedded allowlist of node types, mark // types, required attrs, mark co-occurrence rules, and parent→child containment. // This is intentionally NOT the @atlaskit/adf-utils validator: that package // transitively pulls @atlaskit/editor-prosemirror + @atlaskit/tmp-editor-statsig, // which breaks this converter's zero-dep contract. The ADF core node/mark set is // small and stable, so the rules are inlined and maintained here. // // Scope note: the JSON-schema-valid set is broader than what the Jira REST API // actually accepts ("marks and nodes in the schema may not be valid in this // implementation"). This validator encodes the subset this converter emits plus // the structural nodes a hand-extension or builder is likely to add (tables, // panels). It is a fail-fast gate, not a full schema; a round-trip GET remains // the only way to catch server-side coercion. export type AdfValidationError = { path: string; message: string }; // children: "block" = all children must be block nodes; "inline" = all children // must be inline nodes; "none" = node must not carry content; string[] = children // must be exactly one of the listed types. type ChildRule = "block" | "inline" | "none" | string[]; const NODE_RULES: Record< string, { kind: "block" | "inline"; children: ChildRule; requiredAttrs?: string[] } > = { doc: { kind: "block", children: "block" }, paragraph: { kind: "block", children: "inline" }, heading: { kind: "block", children: "inline", requiredAttrs: ["level"] }, blockquote: { kind: "block", children: "block" }, bulletList: { kind: "block", children: ["listItem"] }, orderedList: { kind: "block", children: ["listItem"] }, listItem: { kind: "block", children: "block" }, codeBlock: { kind: "block", children: ["text"] }, panel: { kind: "block", children: "block", requiredAttrs: ["panelType"] }, expand: { kind: "block", children: "block" }, rule: { kind: "block", children: "none" }, table: { kind: "block", children: ["tableRow"] }, tableRow: { kind: "block", children: ["tableCell", "tableHeader"] }, tableCell: { kind: "block", children: "block" }, tableHeader: { kind: "block", children: "block" }, mediaSingle: { kind: "block", children: ["media"] }, media: { kind: "block", children: "none", requiredAttrs: ["type", "id"] }, text: { kind: "inline", children: "none" }, hardBreak: { kind: "inline", children: "none" }, emoji: { kind: "inline", children: "none", requiredAttrs: ["shortName"] }, mention: { kind: "inline", children: "none", requiredAttrs: ["id"] }, date: { kind: "inline", children: "none" }, status: { kind: "inline", children: "none", requiredAttrs: ["text", "color"] }, inlineCard: { kind: "inline", children: "none" }, }; const VALID_PANEL_TYPES = new Set(["info", "note", "success", "warning", "error"]); const VALID_STATUS_COLORS = new Set(["neutral", "purple", "blue", "red", "yellow", "green"]); const VALID_MARKS = new Set([ "code", "em", "strong", "strike", "link", "subsup", "textColor", "underline", "border", "alignment", ]); // Jira rejects (HTTP 400) a text node carrying `code` alongside any of these // formatting marks. `code` is mutually exclusive with text decoration. const CODE_INCOMPATIBLE = new Set([ "strong", "em", "strike", "underline", "subsup", "textColor", ]); function checkChildAllowed( parentType: string, rule: ChildRule, child: ADFNode, childPath: string, errors: AdfValidationError[], ): void { const childRule = NODE_RULES[child?.type]; if (Array.isArray(rule)) { if (!rule.includes(child?.type)) { errors.push({ path: childPath, message: `node "${child?.type}" not allowed as child of "${parentType}" (expected one of: ${rule.join(", ")})`, }); } return; } // unknown child types are reported by validateNode; skip kind comparison if (!childRule) return; if (rule === "inline" && childRule.kind !== "inline") { errors.push({ path: childPath, message: `block node "${child.type}" not allowed inside "${parentType}" (expects inline content)`, }); } else if (rule === "block" && childRule.kind !== "block") { errors.push({ path: childPath, message: `inline node "${child.type}" not allowed directly inside "${parentType}" (expects block content)`, }); } } function validateNode( node: ADFNode, path: string, errors: AdfValidationError[], ): void { if (!node || typeof node !== "object" || typeof node.type !== "string") { errors.push({ path, message: "node is not an object with a string `type`" }); return; } const rule = NODE_RULES[node.type]; if (!rule) { errors.push({ path, message: `unknown node type "${node.type}" (not in supported ADF allowlist)`, }); return; } if (rule.requiredAttrs) { for (const attr of rule.requiredAttrs) { if (!node.attrs || node.attrs[attr] === undefined) { errors.push({ path, message: `node "${node.type}" missing required attr "${attr}"`, }); } } } if (node.type === "heading" && node.attrs && node.attrs.level !== undefined) { const level = node.attrs.level; if (typeof level !== "number" || !Number.isInteger(level) || level < 1 || level > 6) { errors.push({ path, message: `heading level must be an integer 1-6, got ${JSON.stringify(level)}`, }); } } if (node.type === "text" && (typeof node.text !== "string" || node.text.length === 0)) { errors.push({ path, message: "text node must have a non-empty string `text`" }); } if (node.type === "panel" && node.attrs && node.attrs.panelType !== undefined) { const pt = node.attrs.panelType; if (typeof pt !== "string" || !VALID_PANEL_TYPES.has(pt)) { errors.push({ path, message: `panel panelType must be one of ${Array.from(VALID_PANEL_TYPES).join(" | ")}, got ${JSON.stringify(pt)}`, }); } } if (node.type === "status" && node.attrs && node.attrs.color !== undefined) { const color = node.attrs.color; if (typeof color !== "string" || !VALID_STATUS_COLORS.has(color)) { errors.push({ path, message: `status color must be one of ${Array.from(VALID_STATUS_COLORS).join(" | ")}, got ${JSON.stringify(color)}`, }); } } if (node.marks) { const markTypes: string[] = []; node.marks.forEach((mark, m) => { if (!mark || typeof mark.type !== "string" || !VALID_MARKS.has(mark.type)) { errors.push({ path: `${path}.marks[${m}]`, message: `unknown or invalid mark type ${JSON.stringify(mark?.type)}`, }); return; } markTypes.push(mark.type); if ( mark.type === "link" && (!mark.attrs || typeof mark.attrs.href !== "string" || (mark.attrs.href as string).length === 0) ) { errors.push({ path: `${path}.marks[${m}]`, message: "link mark missing required attrs.href", }); } }); if (markTypes.includes("code")) { const bad = markTypes.filter((t) => CODE_INCOMPATIBLE.has(t)); if (bad.length) { errors.push({ path, message: `mark "code" cannot co-occur with ${bad.join(", ")} on the same text node (Jira rejects with HTTP 400)`, }); } } } if (rule.children === "none") { if (Array.isArray(node.content) && node.content.length > 0) { errors.push({ path, message: `node "${node.type}" must not have content` }); } return; } if (node.content !== undefined && !Array.isArray(node.content)) { errors.push({ path, message: `node "${node.type}" content must be an array` }); return; } const kids = Array.isArray(node.content) ? node.content : []; kids.forEach((child, c) => { const childPath = `${path}.content[${c}]`; checkChildAllowed(node.type, rule.children, child, childPath, errors); validateNode(child, childPath, errors); }); } function validateAdf(doc: unknown): { valid: boolean; errors: AdfValidationError[] } { const errors: AdfValidationError[] = []; if (!doc || typeof doc !== "object") { return { valid: false, errors: [{ path: "$", message: "document is not an object" }] }; } const root = doc as ADFNode & { version?: unknown }; if (root.type !== "doc") { errors.push({ path: "$", message: `root node type must be "doc", got ${JSON.stringify(root.type)}` }); } if (root.version !== 1) { errors.push({ path: "$", message: `root must have version: 1, got ${JSON.stringify(root.version)}` }); } validateNode(root, "$", errors); return { valid: errors.length === 0, errors }; } function formatErrors(errors: AdfValidationError[]): string { return errors.map((e) => ` ✗ ${e.path}: ${e.message}`).join("\n"); } // ---------- CLI entry ---------- export { mdToAdf, validateAdf }; if (import.meta.main) { const argv = process.argv.slice(2); // --check <file.adf.json>: validate an existing ADF document and exit. // Use this to gate jq-assembled create payloads or REST PUT bodies before send. const checkIdx = argv.indexOf("--check"); if (checkIdx !== -1) { const file = argv[checkIdx + 1]; if (!file) { console.error("usage: bun md-to-adf.ts --check <file.adf.json>"); process.exit(2); } let doc: unknown; try { doc = JSON.parse(await Bun.file(file).text()); } catch (e) { console.error(`✗ cannot read/parse ${file}: ${(e as Error).message}`); process.exit(2); } const { valid, errors } = validateAdf(doc); if (valid) { console.error(`✓ ${file}: valid ADF`); process.exit(0); } console.error( `✗ ${file}: invalid ADF (${errors.length} error${errors.length === 1 ? "" : "s"}):\n${formatErrors(errors)}`, ); process.exit(1); } const skipValidate = argv.includes("--no-validate"); const positional = argv.filter((a) => a !== "--no-validate"); if (positional.length === 0) { console.error("usage: bun md-to-adf.ts <input.md|-> [output.json] [--no-validate]"); console.error(" bun md-to-adf.ts --check <file.adf.json>"); process.exit(2); } const inputArg = positional[0]; const outputArg = positional[1]; let md: string; if (inputArg === "-") { md = await Bun.stdin.text(); } else { md = await Bun.file(inputArg).text(); } const adf = mdToAdf(md); if (!skipValidate) { const { valid, errors } = validateAdf(adf); if (!valid) { console.error( `✗ converted ADF failed validation (${errors.length} error${errors.length === 1 ? "" : "s"}) — NOT writing output:\n${formatErrors(errors)}`, ); console.error(" (pass --no-validate to bypass; see acli/SKILL.md §Publishing rich text)"); process.exit(1); } } const json = JSON.stringify(adf, null, 2); if (outputArg) { await Bun.write(outputArg, json); } else { process.stdout.write(json); } }
-
-
SKILL.md 40.3 KB
--- name: acli description: "Atlassian CLI (official `acli` binary, v1.3+ as of 2026) for Jira Cloud, Confluence Cloud, and org admin tasks from the terminal. Use whenever the user wants to create, view, edit, transition, assign, clone, archive, comment on, link, or bulk-operate on Jira work items; list or manage projects, boards, sprints, filters, dashboards, or custom-field definitions; create or update Confluence spaces, pages, or blog posts; activate/deactivate users at the org level; or authenticate to Atlassian from a shell or CI pipeline. Triggers on: `acli`, Atlassian CLI, Jira from the terminal, Confluence from the terminal, bulk Jira operations, scripting Jira, automate Jira tickets, transition a bunch of issues, create issues from a JSON/CSV file, CI pipeline that touches Jira, log in to Jira CLI, switch Atlassian sites, API-token auth for Jira. Use this skill even when the user does not say the word `acli` — if the task is CLI-driven Jira or Confluence work, this is the right tool. Do NOT use for: Atlassian MCP server work (that is a different integration), REST-API-only workflows where no CLI is involved, Bitbucket command-line needs (acli does not cover Bitbucket yet), or the legacy Appfire/Bob Swift `acli` tool (a different product that happens to share the binary name). The Atlassian MCP server is OPT-IN, documented in docs/mcp/." license: MIT compatibility: [claude-code, cursor, codex, opencode] allowed-tools: Bash(acli:*) complementary_categories: [issue-tracker] --- # Atlassian CLI (`acli`) `acli` is Atlassian's official command-line tool for Jira Cloud, Confluence Cloud, and org admin operations. It replaces terminal-based Jira automation that previously required raw REST calls, and unifies Jira + Confluence + admin actions behind one binary with one credential store per product. This skill teaches how to drive `acli` for any intent: one-off commands, batch mutations, scripted pipelines, and CI jobs. **Repo-specific integration** (how this skill plugs into the host repo's workflow, TMS modality, project conventions, anti-patterns) lives in the companion file `<repo-core>/references/acli-integration.md` — load it on demand. See "Navigation" below. ## Compact Rules - DO: pass `--paginate` (or an explicit `--limit`) on any search whose result is counted, iterated, or decided on. Pagination is opt-in and truncation is silent — there is no warning. - DO NOT: read exit 0 as proof a subcommand exists. An unknown subcommand falls back to the parent help and exits 0. Check that the help body actually changed, and never invent a flag — every multi-word flag is kebab-case. - DO: verify auth status before any bulk mutation. Auth is per-product (jira / confluence / admin / global are separate sessions) and a silent expiry leaves the batch half-applied with no clean rollback. - DO: pass the non-interactive confirmation flag on every mutating command in CI, or the command hangs waiting on stdin. - DO NOT: hand-author raw ADF JSON, and do not pass Markdown to a rich-text flag — the CLI never converts it and stores the literal characters. Author in Markdown, convert with `scripts/md-to-adf.ts`, pass the ADF. - DO: let the converter's validation gate run on every ADF document before publishing, and round-trip read the field after writing. The gate catches node-level errors; only the read-back catches Jira's silent server-side coercion. - DO NOT: assume `workitem edit` takes custom-field values. It hard-rejects every shape with exit 1; editing a custom field on an EXISTING item works only through the REST PUT path. - DO NOT: expect `workitem edit` to set an issue's COMPONENTS either. There is no flag and no `--from-json` key, so the edit succeeds while leaving components untouched and says nothing. Set them at create time, or change them through the same REST PUT path as custom fields. - DO NOT: copy an example out of the vendor's own `--help`. Several omit the subcommand the flags actually live on (`workitem comment --key …` instead of `workitem comment create --key …`) and fail with `unknown flag`. The forms in this skill's references are the tested ones. - DO NOT: hardcode a `customfield_NNNNN` id in a script or in generated output. Resolve it through the host project's slug catalog — ids differ per workspace, slugs travel. - DO NOT: read the Atlassian host from an environment variable. It lives in `.agents/project.yaml` under `issue_tracker.atlassian_url` and is resolved through the accessor; a stale inherited copy once pointed the sync scripts at a dead site. - WHEN creating an issue link: `--out` / `--in` are empirically INVERTED against Jira's semantics — `--out` takes the prerequisite, `--in` the dependent. Verify the direction by listing the link afterwards, and recreate with swapped flags if it landed backwards. - DO: capture and surface the trace id from any backend failure. It is the only debug signal, and Atlassian Support needs it. - WHEN the operation is a known blind spot (enumerate custom fields, edit custom-field values, manage workflows / issue types / versions / components, attachments, watchers, add an item to a sprint): route through REST or the opt-in Atlassian MCP rather than forcing the CLI. - DO: prefer API-token auth in scripted contexts, and pin the binary to an explicit version in production pipelines — tracking `latest` has caused same-day mass failures. **Read full SKILL.md when**: composing a specific command, publishing rich text, running the REST PUT workaround, or working any surface outside Jira work items. ## Why this skill exists `acli` has several traits that make it easy to misuse: 1. **Silent pagination truncation.** `workitem search` without `--paginate` returns the first page only — no warning. Scripts that count or iterate keys read the wrong number of items. 2. **Auth is per-product.** `acli jira auth login` does not authenticate `acli admin`, `acli confluence`, or `acli rovodev`. There is also a top-level `acli auth` for global OAuth (newer surface). Each scope has its own session. 3. **The "work item" vs "issue" split.** The CLI renamed commands (`jira issue` → `jira workitem`) but the JSON response still has a top-level `issues[]` array and CSV inputs still use `issueType`/`parentIssueId` spellings. Mixing old and new terminology in the same script works, but confuses readers. 4. **Unknown subcommands fail silently.** Typing `acli jira workflow --help` does NOT error — it falls back to `acli jira --help` with exit 0. So "no error" ≠ "command exists". Always verify by checking the help body actually changed. 5. **Hard limits the docs do not advertise.** `acli` cannot list custom fields, edit custom-field values on existing items, manage workflows, manage issue types, or touch project versions/components. See `references/gotchas.md`. The body below covers the core that applies to almost every session. The `references/` directory holds the deep material — load only the one you need. ## Composable Skills (auto-resolved at skill entry) `acli` is itself the canonical `issue-tracker` skill. The category typically has no T3 skills that overlap — `acli` is the tool surface, not a borrower of community skills. Steps for protocol consistency: 1. Read `complementary_categories` from this skill's frontmatter (`issue-tracker`). 2. Resolve via the host repo's skill-registry cache (`.agents/skills/REGISTRY.md`, built by `scripts/build-skill-registry.ts`). Fallback: scan the session-start `system-reminder` skill list. 3. Apply the threshold rule per the host repo's skill-composition strategy doc (T1 / T3 silent; T4 ASK). 4. The Atlassian MCP fallback documented below is OPT-IN, not a skill — enable manually via `docs/mcp/`. Expected matches: typically none. Repo-specific composability (which workflow skills load this) lives in `<repo-core>/references/acli-integration.md` §Composability. Skip step if the catalog is unavailable; log `skill_resolution: "fallback-inline"` plus `missing: [<categories>]` per the strategy doc's composability fallback contract. ## Fallback: Atlassian MCP > **Opt-in only**: this MCP is NOT enabled in the default boilerplate. To use it, copy the atlassian block from `docs/mcp/<agent>.template.*` into `.mcp.json` / `opencode.jsonc`, ensure `ATLASSIAN_*` in `.env` are set, and restart the agent. Behavior below applies only after opt-in. If `acli` is not installed or authenticated, fall back to the Atlassian MCP server (MCP tool namespace: `mcp__atlassian__*` or similar — check the MCP tool list for the exact prefix in the current environment). **When to prefer MCP over acli**: - `acli` binary is not installed in the environment. - `acli` auth fails and cannot be fixed in the current session. - The operation is one of the documented `acli` blind spots: enumerate custom fields, edit custom-field values on existing work items, manage workflows / issue types / priorities / resolutions / project versions / components, upload attachments, add watchers, add an item to a sprint. **When to prefer acli over MCP**: - Bulk operations (acli consumes far fewer tokens per call). - Scripting / CI pipelines. - Operations that return large result sets (MCP payloads inflate token usage). **Coverage parity**: MCP and `acli` overlap for issues, projects, boards, sprints, comments, and basic Confluence ops. For org-admin user lifecycle and Confluence space CRUD, `acli` is more direct. For schema/admin reads (field catalog, workflow definitions), MCP/REST is the only viable path. ## Command structure ``` acli <product> [<feature>] <action> [flags] ``` | Product | Purpose | | ------------- | ---------------------------------------------------------------- | | `jira` | Jira Cloud — work items, projects, boards, sprints, filters, dashboards, custom-field definitions | | `confluence` | Confluence Cloud — spaces (CRUD), blog posts, page view | | `admin` | Organization admin — API-key auth, user lifecycle | | `auth` | Global OAuth (cross-product, newer top-level surface) | | `rovodev` | Rovo Dev AI coding agent (separate beta product) | | `feedback` | Send feedback or a bug report to Atlassian | | `config` | Atlassian Government Cloud configuration (`gov-cloud`) | | `completion` | Generate shell-autocompletion script (bash / zsh / fish / powershell) | Every level has `--help`. Use it aggressively when unsure: ```bash acli --help acli jira --help acli jira workitem --help acli jira workitem create --help ``` ## Quick start ```bash # 1. Authenticate against a site using an API token (scriptable path) echo "$ATLASSIAN_API_TOKEN" | acli jira auth login \ --site "<your-site>.atlassian.net" \ --email "you@example.com" \ --token # 2. Verify acli jira auth status # 3. Create a work item acli jira workitem create --project "{{PROJECT_KEY}}" --type "Task" --summary "Draft the Q3 OKRs" # 4. Search with JQL — ALWAYS pass --paginate or --limit explicitly acli jira workitem search --jql "project = {{PROJECT_KEY}} AND status = 'To Do'" --paginate --json # 5. Transition one or many acli jira workitem transition --jql "project = {{PROJECT_KEY}} AND assignee = currentUser()" \ --status "In Progress" --yes --ignore-errors ``` > **Repo-specific quick start**: when the host repo defines its own workflow (status names, project keys, slug-resolved custom fields), see `<repo-core>/references/acli-integration.md` — it documents the project-flavored variant of the steps above. ## Top-level command map ### Jira (`acli jira`) | Subcommand | What it covers | | ------------ | --------------------------------------------------------- | | `auth` | login · logout · status · switch — API-token or OAuth | | `workitem` | archive · assign · attachment (list / delete) · clone · comment (create / delete / list / update / visibility) · create · create-bulk · delete · edit · link (create / delete / list / type) · search · transition · unarchive · view · watcher (list / remove) | | `project` | archive · create · delete · list · restore · update · view | | `board` | create · delete · get · list-projects · list-sprints · search | | `sprint` | create · delete · list-workitems · update · view | | `filter` | add-favourite · change-owner · get · get-columns · list · reset-columns · search · update | | `dashboard` | search | | `field` | cancel-delete · create · delete · update — **custom-field DEFINITIONS only**, NOT values, and **no listing** | ### Confluence (`acli confluence`) | Subcommand | What it covers | | ---------- | -------------------------------------------------------------------- | | `auth` | login · logout · status · switch — same model as `jira auth` | | `space` | archive · create · list · restore · update · view (full CRUD) | | `blog` | create · list · view | | `page` | view (read-only as of v1.3.18 — page CRUD not yet exposed) | ### Admin (`acli admin`) | Subcommand | What it covers | | ---------- | --------------------------------------------- | | `auth` | login · logout · status · switch — API key | | `user` | activate · deactivate · delete · cancel-delete | ## The selector pattern (the thing to internalize) Most mutating `workitem` commands (`edit`, `transition`, `assign`, `archive`, `clone`, `comment create`) accept **one of** these target selectors: | Selector | When to use | | ------------------- | -------------------------------------------------------------- | | `--key KEY-1,KEY-2` | You already know the exact keys | | `--jql "..."` | You want everything matching a JQL query | | `--filter 10001` | You want to reuse a saved Jira filter | | `--from-file f` | You have a file listing keys (`archive`/`unarchive`/`assign`) | When the selector matches many items, the command is **a batch operation**. Two flags almost always matter: - `-y, --yes` — skip the interactive confirmation prompt. Required in CI; if omitted the command hangs waiting on stdin. **Note:** this flag does NOT exist on `admin user delete` / `admin user cancel-delete` (use `--ignore-errors` there instead). - `--ignore-errors` — do not abort the batch when a single item fails. ## Output and piping All list/search/view commands support three shapes: - default table (human-readable) - `--json` (for `jq` / scripts) - `--csv` (spreadsheet-friendly) Example pipe patterns: ```bash # Count only acli jira workitem search --jql "project = {{PROJECT_KEY}}" --count # Save full result set to CSV acli jira workitem search --jql "project = {{PROJECT_KEY}}" --paginate --csv > team.csv # Extract a single field with jq acli jira workitem view {{PROJECT_KEY}}-123 --json | jq '.fields.summary' ``` The JSON shape from `workitem search` has a top-level `issues` array (not `workitems`) — the Jira REST v3 wire format shows through. ## Publishing rich text (the default workflow) Jira stores rich-text content (descriptions, comments, and any rich-text field) as **ADF — Atlassian Document Format**, a JSON tree of typed nodes (`heading`, `paragraph`, `bulletList`, `orderedList`, `codeBlock`, `blockquote`, `rule`, `table`, `panel`, `expand`) with inline marks (`strong`, `em`, `code`, `link`, `strike`). `acli` accepts ADF JSON in every rich-text input. `acli` **never** converts markdown — passing `# Heading` to `--description` or `--body` stores the literal string `# Heading` wrapped in a single ADF paragraph. > **⚠️ Asymmetry: `create` supports custom-field rich text, `edit` does NOT.** > `acli workitem create --from-json` accepts custom fields via `additionalAttributes` (ADF doc payloads work). `acli workitem edit --from-json` **hard-rejects every custom-field shape** (`additionalAttributes`, `fields`, flat `customfield_X`) with exit 1 + `unknown field` error. No silent drop, no escape hatch in the binary. To update or correct a rich-text custom field on an **existing** work item, you MUST use the REST PUT workaround documented below — `acli` cannot do it. To publish anything richer than plain prose, use this three-step workflow by default: ``` 1. Author the content in Markdown. 2. Convert MD → ADF JSON using scripts/md-to-adf.ts. 3. Pass the ADF JSON to the matching acli flag — or, for cases acli cannot cover, into a REST body. ``` ### The bundled converter Location: `.agents/skills/acli/scripts/md-to-adf.ts`. Runtime: Bun. CLI usage: ```bash bun .agents/skills/acli/scripts/md-to-adf.ts input.md output.adf.json # stdin form cat input.md | bun .agents/skills/acli/scripts/md-to-adf.ts - output.adf.json # stdout form (omit output arg) bun .agents/skills/acli/scripts/md-to-adf.ts input.md > output.adf.json ``` Programmatic usage (when batching across many fields or many work items in one script): ```typescript import { mdToAdf, validateAdf } from "./.agents/skills/acli/scripts/md-to-adf.ts"; const adf = mdToAdf(markdownString); // returns { type: "doc", version: 1, content: [...] } const { valid, errors } = validateAdf(adf); // gate ANY ADF before publishing ``` **Covered markdown subset**: headings 1–6, bullet lists, ordered lists, **nested lists** (indentation-based), **GFM tables** (`| a | b |` + `|---|---|` separator), **panels** (GitHub-alert blockquotes), **expand blocks** (`<details><summary>`), **Jira-native emoji** (`:short_name:`), **status lozenges** (`{status:color|TEXT}`), **mentions** (`@[Name](accountId)`), fenced code blocks (with optional language tag), inline code, bold, italic (snake_case-safe), strikethrough, links, blockquotes, horizontal rule, paragraphs. Rich-block syntax cheat-sheet: | Markdown you write | ADF node produced | |---|---| | `\| H1 \| H2 \|` then `\| --- \| --- \|` then body rows | `table` (header row → `tableHeader`, body → `tableCell`; inline marks work inside cells; `\|` escapes a literal pipe) | | `> [!NOTE]` / `[!INFO]` (blue) · `[!TIP]` / `[!SUCCESS]` (green) · `[!IMPORTANT]` (purple) · `[!WARNING]` (yellow) · `[!CAUTION]` / `[!ERROR]` (red), then `> body` lines | `panel` with `panelType` `info` / `success` / `note` / `warning` / `error`. Body re-parsed as Markdown (can hold lists, code, etc.) | | Two-space (or deeper) indentation under a list item | nested `bulletList` / `orderedList` inside that `listItem`; depth = indent width; bullet/ordered mix per level | | `<details>` / `<summary>Title</summary>` / body / `</details>` | `expand` with `attrs.title`; body re-parsed as Markdown | | `:white_check_mark:` `:x:` `:warning:` … any `:short_name:` | `emoji` node (Jira resolves the shortName; curated status marks also carry a Unicode `text` fallback). Inline code is parsed first, so a colon inside `` `code` `` is safe | | `{status:green\|DONE}` (colors: `neutral` `purple` `blue` `red` `yellow` `green`) | `status` node — the coloured lozenge/pill for transition states. `localId` not required (Jira injects none on publish) | | `@[Display Name](accountId)` | `mention` node. The `accountId` is supplied explicitly (resolve it via `/rest/api/3/user/search` — see `references/adf-authoring-style.md` §mentions); a bare `@name` is NOT converted | **Media (images / videos)** are NOT Markdown — `` does not work, because an ADF media node needs the opaque media-services UUID of an uploaded file. Use the bundled helper `scripts/jira-attach-media.ts` instead (upload → resolve UUID → emit/publish the `mediaSingle > media` node). Example: `bun scripts/jira-attach-media.ts BUG-123 ./repro.png --caption "Repro step 3" --publish`. Full recipe + when-to-use in `references/adf-authoring-style.md` §media. **Out of scope** (extend the converter if your project needs them): `nestedExpand` (expand inside a table cell). > **This section covers HOW Markdown becomes ADF. For WHEN to reach for a table vs a panel vs a nested list — i.e. how to make field content visually scannable instead of flat prose — see `references/adf-authoring-style.md`.** Workflow skills cite that file at each point they fill a Jira rich-text field. ### Validation gate (fail fast before Jira) The converter **validates its output by default** against an embedded ADF allowlist, then refuses to write and exits non-zero if the document is invalid. This turns an opaque Jira `HTTP 400 INVALID_INPUT` at publish time into a node-level diagnostic at author time. The gate is **zero-dependency** — it does NOT use `@atlaskit/adf-utils` (that package transitively pulls ProseMirror + Statsig and breaks the converter's zero-dep contract). The rules are inlined in `md-to-adf.ts`. What it catches: unknown node types, unknown / invalid marks, `code` co-occurring with `strong`/`em`/`strike`/`underline`/`subsup`/`textColor` (the HTTP 400 combined-marks bug), `heading` level outside 1–6, missing `link` `href`, empty `text` nodes, illegal containment (e.g. a `paragraph` directly under a `bulletList`), and a malformed root (`type` ≠ `doc` or `version` ≠ 1). ```bash # validate is on by default during conversion; bypass with --no-validate bun .agents/skills/acli/scripts/md-to-adf.ts input.md out.adf.json --no-validate # gate an ALREADY-assembled ADF doc (jq create payload field, or a REST PUT body) bun .agents/skills/acli/scripts/md-to-adf.ts --check field.adf.json # exit 0 valid, 1 invalid ``` **Recommended habit**: after splicing ADF into a `--from-json` create payload or a REST `PUT` body (where the wrapper is assembled outside the converter), run `--check` on each ADF field before sending. The gate is necessary but not sufficient — a round-trip `GET` of the field after write is still the only way to catch server-side coercion (Jira silently drops some invalid nodes). ### Recipe by Jira surface | Surface | How to publish ADF | Notes | |---|---|---| | `description` on `workitem create` | `--from-json` payload, `description` key holds an ADF doc | Custom-field values live in `additionalAttributes` of the same payload, same ADF shape | | `description` on `workitem edit` | `--description-file <file>` accepts a JSON file containing an ADF doc | `acli` auto-detects ADF vs plain text by file content | | Rich-text custom field on `workitem create` | `additionalAttributes.customfield_NNNNN` = ADF doc inside `--from-json` | Same shape as `description` | | Rich-text custom field on an existing item | **`acli` cannot do this — use REST PUT workaround.** `PUT /rest/api/3/issue/{KEY}` with `{"fields": {customfield_NNNNN: <ADF>}}` via `curl` | `acli workitem edit` hard-rejects `additionalAttributes`, `fields`, and flat `customfield_X` with `✗ Error: json: unknown field …`. Confirmed empirically. See gotcha #4 + dedicated workaround section below. | | Comment create | `comment create --body-file <file>` (alias `-F`) accepts ADF | The `--body` (plain) flag remains plain text only | | Comment update | `comment update --body-adf <file>` | Dedicated ADF flag | ### Worked end-to-end example ```bash # 1. Author each rich-text field as Markdown cat > /tmp/desc.md <<'MD' ## User Story - As a user - I want X - So that Y ## Context Some context paragraph with **bold** and `inline_code`. MD cat > /tmp/ac.md <<'MD' ## Scenario: happy path Given a valid input When the user submits Then the response is 200 OK MD # 2. Convert each MD file to ADF JSON bun .agents/skills/acli/scripts/md-to-adf.ts /tmp/desc.md /tmp/desc.adf.json bun .agents/skills/acli/scripts/md-to-adf.ts /tmp/ac.md /tmp/ac.adf.json # 3. Splice the ADF docs into the create-from-json payload jq -n \ --arg pk "{{PROJECT_KEY}}" \ --slurpfile desc /tmp/desc.adf.json \ --slurpfile ac /tmp/ac.adf.json \ '{ projectKey: $pk, type: "Story", summary: "Example summary", description: $desc[0], labels: ["example"], additionalAttributes: { customfield_NNNNN: $ac[0] } }' > /tmp/story.json # 4. Submit acli jira workitem create --from-json /tmp/story.json --json ``` ### Batch pattern (many work items, many rich fields) When the task is to populate N work items with M rich-text fields each, the converter scales linearly with negligible overhead. Recommended pattern: 1. Write one generator script (`generate.ts`) that holds the per-field Markdown content for every item as inline string literals. 2. The script imports `mdToAdf` and converts every field in-process — no shell hop per conversion. 3. The script writes one `create --from-json` payload per item (`/tmp/item-N.json`). 4. A shell loop runs `acli jira workitem create --from-json /tmp/item-N.json --json` per file, capturing the new key from stdout. 5. For comments, follow the same approach: write the comment Markdown inline, convert in-process, post with `acli jira workitem comment create -k <KEY> -F /tmp/comment-N.adf.json`. This pattern scales cleanly to dozens of items in one run. The bottleneck is authoring quality, not the conversion mechanic. ### WORKAROUND: Editing rich-text custom fields on existing work items (REST PUT) This is the **only** working path as of acli v1.3.18 — there is no acli-native channel for editing custom-field values on existing items. The recipe below is the turnkey workaround. **Prerequisites.** Two env vars must be exported in the current shell. They are loaded automatically by the project tooling (`bun claude`, `bun opencode`, or `direnv`) from `.env`: - `ATLASSIAN_EMAIL` — the API-token owner's email - `ATLASSIAN_API_TOKEN` — the API token paired with the email The site host is **not** an env var. It lives in `.agents/project.yaml` -> `issue_tracker.atlassian_url`, and the recipes below read it with `$(bun run --silent jira:url)`. It was pulled out of `.env` because a stale copy inherited from the parent shell silently shadowed the file and pointed the sync scripts at a dead Jira site. Never reintroduce `ATLASSIAN_URL` as a shell variable in a recipe — resolve the host, do not interpolate it. **Recipe.** ```bash # 1. Author the new value as Markdown cat > /tmp/new.md <<'MD' ## New content - with **bold**, `inline code`, and a [link](https://example.com) MD # 2. Convert MD → ADF bun .agents/skills/acli/scripts/md-to-adf.ts /tmp/new.md /tmp/new.adf.json # 3. Wrap the ADF doc in the REST `{ "fields": { ... } }` envelope # (NOTE: same ADF payload acli would consume; only the wrapper key changes) jq -n --slurpfile adf /tmp/new.adf.json \ '{fields: {customfield_NNNNN: $adf[0]}}' > /tmp/put.json # 4. PUT against the issue curl -sS -w "\nHTTP %{http_code}\n" \ -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -X PUT "$(bun run --silent jira:url)/rest/api/3/issue/{{PROJECT_KEY}}-123" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ --data-binary @/tmp/put.json # Expected: HTTP 204 (Jira returns no body on a successful PUT) ``` **Reference.** Official Atlassian REST v3 PUT endpoint: <https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-put> **Empirical proof this is the only path.** Three variants tested against `acli workitem edit --from-json` on a real workitem: | Payload shape sent to `acli edit` | Result | |---|---| | `{issues:[...], additionalAttributes:{customfield_X:<ADF>}}` | `✗ Error: json: unknown field "additionalAttributes"` · exit 1 | | `{issues:[...], fields:{customfield_X:<ADF>}}` | `✗ Error: json: unknown field "fields"` · exit 1 | | `{issues:[...], customfield_X:<ADF>}` | `✗ Error: json: unknown field "customfield_X"` · exit 1 | Same ADF doc through REST PUT: HTTP 204 OK. **Batch variant.** Loop the recipe per `--data-binary @/tmp/put-N.json` and capture HTTP codes: ```bash for KEY in {{PROJECT_KEY}}-1 {{PROJECT_KEY}}-2 {{PROJECT_KEY}}-3; do status=$(curl -sS -o /dev/null -w "%{http_code}" \ -u "$ATLASSIAN_EMAIL:$ATLASSIAN_API_TOKEN" \ -X PUT "$(bun run --silent jira:url)/rest/api/3/issue/$KEY" \ -H "Content-Type: application/json" \ --data-binary @/tmp/put-"$KEY".json) echo "$KEY -> HTTP $status" done ``` **When this becomes unnecessary.** If Atlassian adds an `additionalAttributes`-style channel to `acli workitem edit`, retire this workaround and update the recipe table. ### Why this is the default - Authoring in Markdown is fast, reviewable in pull requests, diffable. - The conversion is deterministic — the same Markdown always produces the same ADF tree. - One workflow covers every rich-text surface uniformly: descriptions, comments, custom fields, all the same three steps. - Identifier-heavy prose (snake_case, kebab-case) survives the conversion because the italic detection has word-boundary guards. ## Anti-patterns — NEVER do these (tool-level) These are tool-level anti-patterns intrinsic to the `acli` binary and its REST companion. They apply regardless of host repo. Gotchas describe *surprising behavior to remember*; anti-patterns describe *actions to refuse outright*. Both apply. - **T1.** NEVER hand-author raw ADF JSON for descriptions, comments, or rich-text custom fields. Use `scripts/md-to-adf.ts` — deterministic, diffable, snake_case-safe, and avoids the combined-marks bug (inline `code` co-occurring with `strong`/`em` causes HTTP 400). - **T2.** NEVER hardcode Jira `customfield_NNNNN` IDs in scripts or AI output that consumes `acli`. Resolve via the host project's slug catalog (see the host repo's `acli-integration.md`). IDs differ per workspace; slugs travel. - **T3.** NEVER assume `acli` accepts custom-field input on `workitem edit`. It hard-rejects every shape (`additionalAttributes`, `fields`, flat `customfield_X`) with exit 1. Use the REST `PUT /rest/api/3/issue/{KEY}` workaround documented above — there is no acli-native path. - **T4.** NEVER run a bulk `acli` mutation (transition, edit, comment, link, archive) without first verifying `acli jira auth status`. Silent auth expiry cascades into HTTP 401s mid-loop, leaving the batch half-applied with no clean rollback. > **Repo-specific anti-patterns** (workflow abstraction, project-key portability, TMS modality boundaries, prod-workspace safety, CI batching, version pinning, sync-script auth) live in `<repo-core>/references/acli-integration.md`. Load it whenever a session touches the host repo's Jira workflow. ## Seven gotchas to keep in mind always 1. **`--paginate` is opt-in.** Default limit is server-side (30–50 depending on command). No warning on truncation. If you are counting, iterating, or making decisions based on the result, pass `--paginate`. 2. **Custom fields on `workitem create` go through `additionalAttributes` in `--from-json`.** Numeric IDs only (`customfield_NNNN`), no name-addressing. Documented value shapes in the `create` template are: `{"value": "..."}` (single-select), bare number, bare string. **`workitem edit` actively REJECTS custom-field input — hard error, exit 1, not a silent drop** (empirically confirmed across `additionalAttributes`, `fields`, and flat `customfield_X` shapes). For editing custom-field values on existing items, the **only** working path is REST `PUT /rest/api/3/issue/{KEY}` via `curl` using the session env vars — see the "WORKAROUND" subsection in "Publishing rich text" above, plus `references/gotchas.md` §4 and `references/workitem.md`. 3. **`acli` cannot enumerate custom fields.** `acli jira field` only does create/update/delete/cancel-delete. To discover field IDs, use `workitem view --json | jq` against an item that has the field set, or call `GET /rest/api/3/field` directly. There is no in-CLI listing. Host repos typically cache the catalog under `.agents/` and resolve fields by slug — see `<repo-core>/references/acli-integration.md`. 4. **Transitions match by status name, not transition ID.** When two transitions lead to the same status with different validators, the CLI picks one and may fail. No `--transition-id` escape hatch exists — fall back to REST if this hits. 5. **Trace IDs are the only debug signal.** An `unexpected error, trace id: XXXXXXXX` line is all you get on backend failures. Capture and log the trace ID always; Atlassian Support needs it. 6. **`workitem link create` flag names are misleading — `--out` and `--in` are EMPIRICALLY INVERTED relative to Jira's outward/inward semantics.** Running `acli jira workitem link create --out X --in Y --type Dependencies` produces "**Y** depends on **X**" — NOT "X depends on Y" as the flag names suggest. Y becomes the outward party (the one that performs the outward verb, e.g. "depends on" / "blocks" / "causes"); X becomes the inward party. Confirmed empirically against Dependencies; the same inversion applies to ALL outward-asymmetric link types (Blocks, Blocking, Causes, Duplicate, Cloners, Defect, Test, Test Automation, Test Design, Test Execute). Symmetric types (Relates) are immune — direction is lost either way. **Reverse-mapping rule of thumb**: `--out` takes the PREREQUISITE (the inward partner in Jira's UI); `--in` takes the DEPENDENT (the outward partner in Jira's UI). **Mandatory verification after every link create**: run `acli jira workitem link list --key <expected-dependent> --json` and confirm the response shows `outwardIssueKey: <expected-prerequisite>`. If the direction is wrong, delete the link and recreate with swapped flags — **delete first**: Jira dedupes a link between the same pair and type regardless of direction, so adding the corrected link on top of the wrong one is a silent no-op. Deep recipe + per-type mapping table → `references/workitem.md`. 7. **The vendor's own `--help` examples are sometimes stale, and they fail exactly as a typo would.** `acli jira workitem comment create --help` prints its examples without the `create` subcommand (`acli jira workitem comment --key "KEY-1" --body "..."`), which exits non-zero with `unknown flag: --key` because the flags live on `create`. An agent copying the vendor example loses a round trip and, worse, may conclude the command does not exist. Trust the forms in `references/workitem.md` over the binary's examples; two other fields document narrower behaviour than they have (`parentIssueId` describes itself as sub-task-only and parents to an Epic fine). Also note what is NOT there: `workitem edit` has no components flag at all. ## Top-level utilities Quick-reference for the top-level surface that doesn't fit under a product. None of these need a separate reference file — they're documented here in full. ### `acli completion` — shell autocompletion ```bash acli completion bash > /etc/bash_completion.d/acli acli completion zsh > "${fpath[1]}/_acli" acli completion fish > ~/.config/fish/completions/acli.fish acli completion powershell > acli.ps1 ``` Each subcommand prints a shell script to stdout. Pipe to the location your shell expects (above are the conventional paths). ### `acli feedback` — report a problem to Atlassian ```bash acli feedback \ --summary "JSON shape on edit --generate-json is misleading" \ --details "The template doesn't include additionalAttributes for custom fields..." \ --email "you@example.com" \ --time "1h" \ --attachments error.log,trace.txt ``` Flags: `-s, --summary` (required-ish), `-d, --details` (required-ish), `-e, --email`, `-t, --time` (estimated timeframe like `1h`, `15m`), `-a, --attachments` (multiple files). ### `acli auth` — global OAuth (newer surface) A top-level OAuth login that authenticates across products in one step. Distinct from per-product `jira auth` / `admin auth` / `confluence auth`. Use the per-product login for token-based CI; use the global one for interactive multi-product browsing. ```bash acli auth login # interactive OAuth acli auth status acli auth switch acli auth logout ``` See `references/auth.md` for the full auth model. ### `acli config gov-cloud` — Atlassian Government Cloud ```bash acli config gov-cloud --enable acli config gov-cloud --status ``` Niche — only relevant if your org is on Atlassian Government Cloud. Not used in standard commercial Jira/Confluence. ## Navigation — when to load which reference Load the reference that matches the user's current need. Do not preload all of them. | If the user wants to… | Load | | -------------------------------------------------------------------- | ------------------------------------------- | | Log in, switch sites, handle tokens, authenticate in CI | `references/auth.md` | | Work with Jira tickets (create, edit, transition, search, bulk, comments, links, watchers, custom-field shapes) | `references/workitem.md` | | Manage projects, boards, sprints, filters, dashboards, custom-field definitions | `references/project-board-sprint.md` | | Work with Confluence spaces, blogs, pages | `references/confluence.md` | | Run org-level admin tasks (API key, user lifecycle) | `references/admin.md` | | Pipe output, produce JSON/CSV, dry-run, run on CI/CD | `references/output-and-automation.md` | | Diagnose surprising behavior, known bugs, REST fallback points | `references/gotchas.md` | | Publish rich text to descriptions, comments, or custom fields | Inline section "Publishing rich text" + `scripts/md-to-adf.ts` | | Make Jira field content visually excellent (when to use tables / panels / nested lists for readability) | `references/adf-authoring-style.md` | | Plug `acli` into the host repo's workflow (TMS modality, slug catalog, project conventions, anti-patterns specific to this repo) | `<repo-core>/references/acli-integration.md` | ## Working style - **Default to Markdown authoring for any rich-text field.** Never pass raw markdown to `--description`, `--body`, or any custom-field value — `acli` does not convert markdown. Use `scripts/md-to-adf.ts` to produce ADF, then pass the JSON. See "Publishing rich text" above. - **Prefer API-token auth in scripted contexts.** `--web` / OAuth is for humans at a terminal. - **Always pass `--yes` in CI** for any mutating command (where the flag exists). - **Always pass `--paginate`** when a downstream script consumes the result. - **Scaffold complex payloads with `--generate-json`** (create, edit, project create, project update, link create, create-bulk). Pipe to a file, edit, submit with `--from-json`. Note: `--generate-json` is **static** — it does NOT introspect the actual project schema, so for custom-field shapes you may need to view a real item. - **Capture the trace ID on any failure** and surface it when reporting to the user. - **Do not invent flags.** When unsure, run `acli <path> --help` — it is authoritative and version-pinned to the installed binary. Convention: every multi-word flag is **kebab-case** (`--from-json`, `--searcher-key`, `--filter-id`, `--order-by`). camelCase variants will fail. - **Verify subcommand existence before assuming.** Unknown subcommands silently fall back to parent help with exit 0 — they do NOT error. Read the help body, don't trust the exit code. - **Know what `acli` cannot do.** All of the following require REST or MCP — `acli` does not cover them as of v1.3.18: - Enumerate custom fields (`field` has no `list`). - Edit custom-field values on existing work items (`workitem edit` does not document custom-field input). - Manage workflows, workflow schemes, statuses, or transition definitions. - Manage issue types, priorities, resolutions, project versions, project components. - Add a work item to a sprint (`JRACLOUD-97107`). - Upload attachments, add watchers. - Retrieve the cached auth token for reuse in another tool. - Bitbucket operations (out of scope entirely). - Confluence page CRUD beyond `page view` (as of v1.3.18 — space and blog have full CRUD). See `references/gotchas.md` for the full list with REST recipes. ## Installation (reference only) Users usually already have `acli` installed. If not, point them at: - Official guide: https://developer.atlassian.com/cloud/acli/guides/install-acli/ - macOS: `brew tap atlassian/homebrew-acli && brew install acli` - Linux (Debian/Ubuntu): `apt install acli` (after adding the Atlassian apt repo) - Linux (RHEL/Fedora): `yum install acli` (after adding the Atlassian yum repo) - Windows: PowerShell `curl` install (no Chocolatey/MSI yet) - CI one-liner (Linux): `curl -LO "https://acli.atlassian.com/linux/1.3.18/acli_linux_amd64/acli" && chmod +x acli` Pin to a version URL in production pipelines — `latest/` has caused same-day mass failures. Each release is supported for six months. Run `acli --version` to check.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.