Skip to content

clssck/atl-cli

v0.1.3MIT

Agent-oriented JSON CLI for Confluence and Jira Cloud over REST, with a companion agent skill.

Changelog

[Unreleased]

[0.1.3] - 2026-10-25

  • The agent skill follows the agent-plugins payload grammar: <instruction> routing, <directives> rules, task-named <procedure> workflows and closing <critical> recaps; commands, flags and guards are unchanged.
  • The agent skill handles site_not_accessible: report the item as blocked and have the user log in with an account that reaches the site (atl sites lists reachable sites).

[0.1.2] - 2026-10-24

  • atl skill status | install | update and atl doctor warn atl_not_runnable when an atl is on PATH but atl --version fails (for example Node.js missing), instead of reporting it as healthy; next reruns --version to show the error.

[0.1.1] - 2026-10-24

  • Install docs, the skill's Setup table and the atl_not_on_path hint name Node.js as a requirement next to Bun: the atl bin is a Node launcher, so a Bun-only machine failed with env: node: No such file or directory and no guidance.

[0.1.0] - 2026-10-24

  • Initial atl CLI covering every Atlassian MCP v1 tool on direct REST/GraphQL, without calling the MCP server:
    • Confluence pages, spaces, comments (including comment editing) and CQL.
    • Jira issues, transitions, links, worklogs, projects and JQL.
    • search (CQL + JQL text search), user find (groupuserpicker), Teamwork Graph (graph object | context | add), Compass (GraphQL).
    • mcp tools | call as parity tooling and an explicit escape hatch.
  • OAuth login through the Atlassian MCP authorization server, using the CLI's own registered client and locked refresh-token rotation.
  • Local markdown ⇄ ADF conversion on Bun.markdown, --plan for every write, and a JSON-only stdout contract.
  • Token-efficient compact output: minimal list shapes, names for people, short timestamps, and a central prune of null/empty values; --full returns raw API objects.
  • No runtime dependencies; bun run compile produces a single bytecode-compiled binary (plus compile:linux, compile:windows).
  • Strict type-aware linting and a CI check.
  • Daily MCP upstream watcher that opens an issue when the Atlassian MCP changes.
  • npm package @clssck/atl-cli (bin atl; the unscoped atl-cli is refused by npm as too similar to existing names): a Bun-targeted bundle dist/atl.js built by bun run build, shipped with the agent skill, plugin.json (plugin name atl-cli; the schema allows no @ or /) and LICENSE.
  • Node-compatible launcher bin/atl.js that re-executes under Bun >= 1.4.4 (also the CI and release version) with --no-env-file (a stray .env cannot redirect settings or tokens) and reports a JSON error when Bun is missing or too old.
  • Native binary scripts renamed to compile, compile:linux, compile:windows.
  • Community files: CONTRIBUTING.md, CODE_OF_CONDUCT.md (Contributor Covenant 2.1), SECURITY.md (private vulnerability reporting), issue forms, a pull request template, .gitattributes (LF line endings) and a .gitleaks.toml config.
  • Release workflow: manual patch/minor/major dispatch prepares one candidate, smoke-tests it on Linux, macOS and Windows, publishes to npm with provenance via trusted publishing, pushes the version commit and tag atomically over SSH with the RELEASE_DEPLOY_KEY deploy key (the only bypass of the main ruleset that requires a pull request and green CI), and creates the GitHub Release.
  • CI runs build, tests and an installed-package smoke (bun run test:platform); bun run verify checks plugin.json and changelog versions.
  • MIT license.
  • Write bodies can be passed inline with --body <text> (and --comment <text> for issue link | log | move), or read from stdin with --file - / --comment-file -; --body with --file is body_conflict and a missing required body is body_required.
  • Compact stdout is width-bounded: results over 700 bytes put one list item or object property per line (still one JSON value, no indentation, short results unchanged) so agent harnesses that cut long stdout lines no longer drop the tail. --pretty and the stderr JSONL diagnostics are unchanged.
  • --output PATH now writes the body (pages, comments) or description (issues) text to the file and prints the rest of the result with that field replaced by {path, bytes, lines}; results without such text still write JSON (same width-bounded layout as stdout, one item per line for large results; --pretty indents) and print {path, bytes}.
  • Markdown bodies (issue descriptions, issue and Confluence comments including replies, issue get --comments, page bodies, space pages --body, jql --issue-fields description, hydrated get fields) print as an array of lines instead of one string, so agent harnesses that cut each stdout line no longer drop the tail of bodies over about 700 bytes. A prose line over 600 JSON bytes is soft-wrapped at spaces; code fences, table rows and headings are not wrapped (an over-long one is hard-split). --format adf, --format storage and --full are unchanged, and --output writes the original markdown, not the wrapped display lines.
  • GET/HEAD retries are bounded by a 20 s total sleep budget per request: a Retry-After that would exceed the remainder fails immediately with the transient error (exit 3) and a hint saying when to retry, instead of sleeping up to 30 s per retry past agent-harness foreground limits. Each retry writes one {type:"retry", status, attempt, waitMs, path} JSONL line to stderr.
  • Jira reads: issue changelog | worklogs | editmeta | watchers | permissions | attachments, status, component, version list | get | related-work list, filter, dashboard, property, field, priority, resolution lists, jql-check, jql-suggest, user get.
  • Jira writes: issue delete | comment-delete, issue attachment add | download | delete, issue remote-link add, issue watch | unwatch | notify | convert, version related-work set.
  • Bulk and boards: bulk edit | transition | move | delete | status (async bulk tasks polled to a per-issue result; --plan previews) and board list | get | issues | sprints through the GraphQL gateway.
  • Confluence reads: page ancestors | diff | permissions list | restriction | operations | properties, page get --version N and --format view, attachment list | get, space get | personal.
  • Confluence writes: page permissions add | remove | set, page restrict, page move | copy | restore.
  • graph cypher runs a raw Teamwork Graph Cypher query.
  • HttpClient.requestRaw returns the raw Response for binary and redirecting downloads; the bearer token is still sent only to api.atlassian.com.
  • Parity contract for MCP v2: mcp/tools-v2.json (tool snapshot), mcp/operations-v2.json (in-scope operations) and mcp/gaps-v2.json (documented gaps with reason and evidence); every tool and operation is mapped by a command's mcpTools or listed as a gap, never both.
  • README "Coverage and gaps" section: v1 fully covered, v2 covered directly where the token allows, known token limits.
  • Destructive commands (issue delete, issue comment-delete, issue attachment delete, bulk delete, page restrict, page permissions remove | set) now require --yes; --plan works without it.
  • A command that declares a version flag owns --version; -V stays the global version flag. page restore --version N selects the version to restore.
  • page get <page> --grep <regex> [--context N] returns only the matching lines of the body as {id, title, parent, version, url, lines, matches:[{from, to?, hits?, text}], moreMatches?} (case-insensitive, 1-based line numbers in the rendered body, at most 20 ranges); works with --version and --format md|storage|view.
  • page get returns parent: {id, title} ({id, type} for a non-page parent).
  • Every comment command takes --kind footer|inline (comment list also accepts all) and replaces the former --inline flag, which is removed. comment add has no default kind (a reply takes its parent's); comment get | replies | versions try footer then inline when --kind is omitted; comment get --version N reads an earlier version; comment list --replies nests to full depth (replies_truncated hint). comment list <page> --kind all lists footer then inline comments with each item tagged kind, --limit applies per kind and one composite nextCursor resumes both.
  • search with no results writes an empty_search hint to stderr whose next holds the equivalent atl cql and atl jql commands.
  • issue links <issue> --chain "<relation>" [--max N] walks links of one relation and returns {chain:[{key, summary, status, branches?, cycleTo?}], stoppedBy: end|cycle|max, relations?}; --global-id filters remote links. issue links returns {items, remote?}: issue links plus the compact remote (web) links in one call, so the --remote flag is removed.
  • jql "<JQL>" --group-by status|assignee|type|priority|statusCategory|project|<fieldId> returns {total, groups:{value:count}, capped?} from up to 1000 issues.
  • atl mcp tools --catalog emits the mcp/operations-v2.json shape from the live v2 catalog.
  • scripts/mcp-watch.ts also watches the public supported-tools doc (docs.supportedTools in mcp/upstream.json).
  • issue tree <issue> [--depth N] [--group-by <field>] [--issue-fields ...] lists everything under an epic or issue, sub-tasks included, or counts it per field; child_issues_of_empty hint when childIssuesOf() JQL finds nothing. jql accepts a Jira issues URL, --group-by takes any field name, and jql_invalid warns when an empty result comes with JQL errors.
  • New commands and flags: issue remote-link delete, page property set | delete, page update --force, issue attachment download --force (file_exists otherwise), issue convert --yes (required when the issue has children). issue move --comment is a separate comment POST. Transition names work in either language.
  • page update refuses a body that would drop inline-comment anchors (inline_comment_anchors_dropped, droppedInlineComments in the plan) unless --force.
  • A write that times out or loses its connection exits 3 with write_outcome_unknown: read the target back before retrying. body_not_utf8 rejects non-UTF-8 --file/stdin bodies. invalid_cursor applies to every --cursor; every not-found is exit 2.
  • Output: invisible characters are escaped as \uXXXX; --fields accepts the items. prefix and numeric indexes, keeps unmatched list items as {} and warns fields_unmatched; a line may exceed the width budget only for one unsplittable string; storage bodies are line arrays; search_incomplete warns on short Confluence search pages.
  • Reads: page tree defaults to depth 5 (tree_depth_unchecked hint); page diff reports renamed; every page versions item has a title; page get --grep splits ranges at 20 lines, excerpts long lines and always prints matches; search Confluence hits carry spaceKey and parent; issue get adds duedate, startdate, flagged and --full returns all fields; issue worklogs adds seconds/totalSeconds.
  • Content refs also accept /display/SPACE/Title, viewpage.action?pageId= and draftId= links, pageVersion=, wrapped refs (<url>, [x](url), [KAN-1]) and bare ids of blog posts; an ARI of another site is site_not_accessible.
  • Markdown: <u>, <sub>, <sup>; a table or task list inside a quote or list item is placed after its container; tabs in fenced code are kept; merged table cells repeat their text.
  • Edge cases: repeated --fields merge and a repeated --site/--output is duplicate_flag; atl api --body-file - reads stdin; lowercase project keys work everywhere; issue log --started takes ISO 8601 only (invalid_started); --assignee validates like --set assignee= and accepts none/auto; conflict errors name kebab-case flags; comment add --select '' is empty_selection (it hung); page writes on a bare blog post id go to the blog post; search titles are unescaped and a trailing backslash no longer breaks the query; a 404 from comment add on a bare id hints --target-type blogpost.
  • MCP: atl mcp tools and atl mcp call retry initialize, tools/list and read-only tools/call (a tool with readOnlyHint, and --catalog's discover lookups) on 429/502/503/504 and network errors with the REST policy (3 attempts, 20 s of sleep, Retry-After in seconds or as an HTTP date, a retry JSONL line on stderr per retry); a Retry-After past the budget fails fast with a when-to-retry hint, and a call that may write is never retried.
  • issue edit is renamed issue update (no alias). issue create | update | move --set and --fields-file accept field display names (any language, case-insensitive; ids and common system fields are sent as typed, unknown_field / ambiguous_field list candidates as jql --group-by does) and shape text for a field found by name: numbers (. or , decimals), {value} for options and multi-options, account ids for user fields, a numeric sprint id for the Sprint field (invalid_field_value, invalid_sprint); explicit JSON values pass through.
  • issue log computes timeSpentSeconds locally, so 1.5h, 1,5h, 0,5d work on sites whose locale rejects the other decimal separator; d/w read the site's working hours and days from GET /rest/api/3/configuration only when used, else working_time_unavailable (the token needs the scope that endpoint requires; h/m/s never need it).
  • issue changelog shows status changes in the account's language, mapped from the change's status ids through one GET /rest/api/3/status per call (read only when a status change is listed); --full is unchanged.
  • page restrict and page permissions add | set | remove on a bare blog post id (no --type) now target the blog post: one GET finds the collection that answers before the write (none when --type is given).
  • search drops an unpaired " from the text (a balanced pair stays a phrase) and a query of only quotes and blanks fails locally as empty_query; atl cql reports the server's deterministic NullPointerException on such a query as invalid_cql (input, exit 1) instead of a transient 500.
  • Markdown round trip: a bold, italic or strike mark touching a word or punctuation (a~~b~~c, ** then _i_1, Name**:**) is written as <strong>, <em> or <del> where **, *, ~~ would not re-parse, and those tags are read back as marks; a leading or trailing no-break space in a paragraph is kept.
  • board issues <board> --sprint <id|name|active|future|closed> filters to a sprint (sprint = 4, sprint = "name", sprint in openSprints() and the other states); combined with --dataset backlog it is invalid_flags.
  • board sprints <board> --summary adds per sprint byStatus (issue counts) and points (todo / inProgress / done / remaining / total) by status category, velocity (closed sprints: points finished and not carried on), unestimated issues (sub-tasks excluded), carriedIn (also in an earlier sprint) and carriedOut (also in a later sprint), answering "what is left in the sprint", "what carried over" and "velocity" in one call; points come from the Jira Software story-point field (jsw-story-points), not a look-alike "Story Points" field. board sprints also reports truncated when the 1000-issue cap cut the read.
  • issue get also shows components, fixVersions, sprint and every story-point number field (under its display name, so a "Story point estimate" 8 beside a legacy "Story Points" 13 is visible) when set; a sprint value is {id, name, state} everywhere it is shown (the active one stands out from closed history). --issue-fields on issue get, jql and issue tree accepts field display names (shown under that name), with unknown_field / ambiguous_field instead of a silent empty result; ids and system fields are sent as typed with no lookup.
  • --group-by statusCategory labels groups with the category key (new, indeterminate, done) that issue get shows as statusCategory, not the localized category name.
  • A 403 that does not mention the token scope now hints that the account lacks the project or space permission the action needs (for example Delete Issues), with no auth status next; a 403 naming the scope keeps the scope hint.
  • search and cql attachment hits carry pageId, the page or blog post the file belongs to (read from the hit's pageId= url), so a file match no longer needs attachment get to find its page.
  • Errors that ask for a quoted CQL value (an unquoted non-ASCII label such as label = pénzügy is HTTP 400 "Unrecognised clause") now say so in a hint, and an unrecognised Confluence reference such as a page title points at atl space pages <SPACE> --title and atl cql 'title = "…"' (titles repeat across spaces).
  • board issues --dataset backlog on a scrum board now matches Jira's backlog: issues in no open or future sprint (sprint is EMPTY OR sprint not in (openSprints(), futureSprints())), not Done, epics left out (hierarchyLevel != 1); a company-managed scrum board (/software/c/ board URL) also omits epics from its board and --dataset all issues, so NMS reads 15 backlog and 41 board issues (was 19 and 45). Kanban boards are unchanged (backlog stays sprint is EMPTY; epics stay listed).
  • jql, cql and search join unquoted words into one query (atl cql label = policy AND space = NMCORP). An unknown command maps common verbs to the registry's (issue edit → issue update, issue transition → issue move, ls → list) and never offers a near spelling with another intent (page remove lists the page commands instead of suggesting page move).
  • Every board command takes a project key (NMS, any case) or a project/browse URL in place of the board id: the board is found through the gateway's jiraProjectByKey { boards } (experimental JiraProject opt-in), so a board you never opened is reachable without board list. A project with several boards is ambiguous_board (hint lists id, name and type), an unknown project or one without a board is board_not_found, and the invalid-board and board-not-found hints now mention project keys.
  • page get (markdown) keeps the content-bearing parameters of macros: <!-- macro:excerpt-include page="Title" name="escalation" -->, <!-- macro:jira jqlQuery="..." maximumIssues="50" --> (ids, layout, style and secret-like parameters skipped, values cut at 160 characters), so an agent can see which page an include points at and which JQL a Jira macro runs. Markdown cannot carry macros: <!-- macro:... --> comments are now dropped when markdown is converted to ADF instead of being saved as visible text, and page update refuses a body that has fewer macros than the current page (macros_dropped, droppedMacros in the plan) unless --force; ADF and storage bodies that keep the macros pass.
  • --set resolution=<name> (or {"name": ...}) resolves the localized or English resolution name to its id (Másolat, Duplicate, Won't Do; Jira rejects some localized names itself), an unknown name is unknown_resolution listing the names, null clears. A issue move --set resolution=... on a transition whose screen has no resolution field fails with resolution_not_on_transition_screen and the exact follow-up (atl issue update KEY --set resolution=...), after which nothing was written. issue log accepts compact durations (1h30m, 2d4h, 1H30M) like spaced ones. issue transitions shows a transition's screen fields as [{id, name, required, allowed}] (absent when the transition has no screen, as on NMS). issue comments / issue get --comments mark a comment edited within the same second of posting with edited: true (a later edit still shows updated).
  • The atl agent skill is a ~790-token command table (was ~1.45k tokens): atl get, search/JQL/CQL with their exclusive flags, issue get|comments --grep, issue tree, issue links --chain, board sprints --summary, board issues --dataset --issue-fields, page get "<title>", page ancestors|children|tree, page versions|diff|properties, comment list --kind all --replies and the write commands (--set priority=High --set labels=a,b), one line naming the rarer commands, plus four rules (including: follow nextCursor or --all before answering from a list); prose and examples that error hints already teach were removed.
  • issue get <key> --grep <regex> [--context N] returns the compact header plus only the matching lines of the description and comments (matches count, description: {lines, ranges}, comments: [{id, author, created, updated?, edited?, ranges}], commentsSearched; at most 20 ranges, moreMatches for the rest), like page get --grep; every comment is searched unless --comments N limits it to the newest N. A 180-row table description comes back as one matching row instead of 15 KB. invalid_regex and invalid_flag_combination (--context without --grep, --grep with --full or --format adf) are input errors.
  • issue comments <key> --grep <regex> [--context N] searches every comment of the thread and returns only the matching lines, each hit tagged with its 1-based index in the thread's oldest-first order, id, author, created, and updated plus edited: true when the comment was edited; it shares the matcher with issue get --grep, and --grep with --limit or --cursor is conflicting_flags (--all is accepted: every comment is searched anyway).
  • issue tree asks for summary,status,issuetype (plus parent) by default instead of the jql list fields, so default trees are smaller; --issue-fields still selects any fields.
  • board issues --issue-fields f selects the issue fields like jql does (Jira field names resolved, custom fields named); the default output is unchanged, and --count with --issue-fields is conflicting_flags.
  • --set priority=High resolves the priority by name or id to {id} (an unknown name is unknown_priority before anything is written) and --set labels=web,ui sends a label array; JSON values (priority={"name":"High"}, labels=["a"], null) pass through unchanged and --fields-file stays verbatim.
  • Error hints: invalid_regex says --grep already matches case-insensitively and only a leading (?i) is accepted (other inline flags such as (?s) stay invalid_regex). An unknown command suggests an exact registry command found at the end of the typed words (page comment list -> atl comment list), never suggests a command from another group when the first word is a group (page list no longer suggests space list, issue list no longer suggests issue link; the group's commands are listed instead), and keeps a typed verb that exists in the registry.
  • --grep accepts a leading (?i) (stripped; matching is already case-insensitive), and --grep -i <regex> drops the -i instead of failing with invalid_flag_value.
  • --all on a command with no paging flag (no --limit, --cursor or --after, e.g. board sprints --summary, page get) is ignored instead of unknown_flag. Commands with their own --all are unchanged, and commands that page without one (search, user find, compass components, graph context, issue tree, page ancestors) still reject --all with unknown_flag, so nothing is silently cut to the default page.
  • issue worklogs <key> --since YYYY-MM-DD --until YYYY-MM-DD [--tz <IANA zone>] keeps worklogs whose started date, in that zone (default UTC), is in the inclusive range. It asks the server for that range widened by a day on each side, reads every page of it (--limit, --cursor, --all, --started-after, --started-before with them are conflicting_flags) and returns totalSeconds and total (39h 55m); total is also printed next to totalSeconds otherwise. A bad date or unknown zone is invalid_flag_value.
  • page versions shows title only on the first row and where it changes, instead of on every row.
  • issue get --comments N|--grep fetches the comments concurrently with the issue, and board sprints loads the board and the field catalog concurrently, one round trip less each.
  • --issue-fields worklog on jql, issue get and issue tree shows {total: "3h 30m", totalSeconds, entries: [{author, started, timeSpent}], more?} instead of the raw API object (comments, ids and avatar URLs dropped). The request also asks for timespent, so total is the issue's whole logged time even when the API embeds only 20 entries (more counts the entries not shown; issue worklogs lists them all); the separate timespent value is not repeated. If timespent is missing, the sum of the shown entries is named totalOfShown / totalSecondsOfShown. --full stays raw.
  • page get "<title>" [--space KEY] and the other page reads (tree, children, versions, labels, diff, ancestors, properties, permissions list, restriction, operations, comment list) take an exact page title where an id or URL goes, so a page no longer needs a lookup call first. Exact-case title first, then case-insensitive; one match resolves, several are ambiguous_title (input, exit 1; id, space key and title per candidate, at most 10, with a next command each), none is not_found (exit 2, hint atl search "<title>", and a note when the title has non-breaking or zero-width characters). --type blogpost looks among blog posts; ids, URLs, tiny links, ARIs and /display/ links resolve as before (/display/ links now share the same lookup).
  • The agent skill ships inside the CLI (every file under skills/atl/ is embedded in dist/atl.js and compiled binaries). atl skill status | install | update | uninstall manage copies in omp (~/.omp/agent/skills, honours PI_CODING_AGENT_DIR), Claude Code (~/.claude/skills, CLAUDE_CONFIG_DIR), ~/.agents/skills (Codex and other Agent Skills hosts) or --dir. A .atl-skill.json manifest records the version and file hashes, so each copy reports current, outdated, newer, modified, unmanaged, linked or missing; local edits, unmanaged copies and newer versions are kept unless --force, symlinks are never touched, and replacement is atomic. OS junk files (.DS_Store, Thumbs.db, desktop.ini) are neither shipped nor counted as local edits. atl doctor lists installed copies and points to atl skill update when one is outdated.
  • atl skill status | install | update and atl doctor check the atl that agents will run from PATH: the result carries cli: {path, version}, and a missing or different-version CLI is a stderr warning (atl_not_on_path, atl_version_mismatch; in doctor a missing step) whose next installs or upgrades it. The skill's Setup section tells agents what to do on command not found, a missing login or a version mismatch.
  • The skill now covers company-knowledge research (references/research.md: routing, CQL/JQL gotchas, citations, access errors) and page editing (references/editing.md, references/storage-patterns.md, storage-format templates in assets/), replacing MCP-based research and Confluence editing skills.
  • page update --expect-version N fails with version_conflict (exit 3) before writing when the page is no longer at version N, so an edit made on an older read cannot overwrite a concurrent change.
  • Markdown macro comments no longer show the _parentId editor parameter, which Confluence rewrites on save and made page diff report macro changes nobody made.
  • page update with a storage body compares it with the current storage body: --plan shows check: {bytes, removed, added} (macros, mentions, attachments, page links, links, dates, tasks, headings, layouts) and a content_removed warning lists what the new body drops. page update | create refuse storage or ADF bodies with <script>, <style>, data: URIs or file: URLs (body_hygiene, --force overrides; a warning under --plan). page diff adds inventory: {removed, added} between the two versions.