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 siteslists reachable sites).
[0.1.2] - 2026-10-24
atl skill status | install | updateandatl doctorwarnatl_not_runnablewhen anatlis on PATH butatl --versionfails (for example Node.js missing), instead of reporting it as healthy;nextreruns--versionto show the error.
[0.1.1] - 2026-10-24
- Install docs, the skill's Setup table and the
atl_not_on_pathhint name Node.js as a requirement next to Bun: theatlbin is a Node launcher, so a Bun-only machine failed withenv: node: No such file or directoryand no guidance.
[0.1.0] - 2026-10-24
- Initial
atlCLI 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 | callas 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,--planfor 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;
--fullreturns raw API objects. - No runtime dependencies;
bun run compileproduces a single bytecode-compiled binary (pluscompile: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(binatl; the unscopedatl-cliis refused by npm as too similar to existing names): a Bun-targeted bundledist/atl.jsbuilt bybun run build, shipped with the agent skill,plugin.json(plugin nameatl-cli; the schema allows no@or/) andLICENSE. - Node-compatible launcher
bin/atl.jsthat re-executes under Bun >= 1.4.4 (also the CI and release version) with--no-env-file(a stray.envcannot 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.tomlconfig. - 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_KEYdeploy key (the only bypass of themainruleset 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 verifychecksplugin.jsonand changelog versions. - MIT license.
- Write bodies can be passed inline with
--body <text>(and--comment <text>forissue link | log | move), or read from stdin with--file -/--comment-file -;--bodywith--fileisbody_conflictand a missing required body isbody_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.
--prettyand the stderr JSONL diagnostics are unchanged. --output PATHnow writes thebody(pages, comments) ordescription(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;--prettyindents) 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, hydratedgetfields) 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 storageand--fullare unchanged, and--outputwrites the original markdown, not the wrapped display lines. - GET/HEAD retries are bounded by a 20 s total sleep budget per request: a
Retry-Afterthat 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,resolutionlists,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;--planpreviews) andboard list | get | issues | sprintsthrough the GraphQL gateway. - Confluence reads:
page ancestors | diff | permissions list | restriction | operations | properties,page get --version Nand--format view,attachment list | get,space get | personal. - Confluence writes:
page permissions add | remove | set,page restrict,page move | copy | restore. graph cypherruns a raw Teamwork Graph Cypher query.HttpClient.requestRawreturns the rawResponsefor binary and redirecting downloads; the bearer token is still sent only toapi.atlassian.com.- Parity contract for MCP v2:
mcp/tools-v2.json(tool snapshot),mcp/operations-v2.json(in-scope operations) andmcp/gaps-v2.json(documented gaps with reason and evidence); every tool and operation is mapped by a command'smcpToolsor 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;--planworks without it. - A command that declares a
versionflag owns--version;-Vstays the global version flag.page restore --version Nselects 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--versionand--format md|storage|view.page getreturnsparent: {id, title}({id, type}for a non-page parent).- Every comment command takes
--kind footer|inline(comment listalso acceptsall) and replaces the former--inlineflag, which is removed.comment addhas no default kind (a reply takes its parent's);comment get | replies | versionstry footer then inline when--kindis omitted;comment get --version Nreads an earlier version;comment list --repliesnests to full depth (replies_truncatedhint).comment list <page> --kind alllists footer then inline comments with each item taggedkind,--limitapplies per kind and one compositenextCursorresumes both. searchwith no results writes anempty_searchhint to stderr whosenextholds the equivalentatl cqlandatl jqlcommands.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-idfilters remote links.issue linksreturns{items, remote?}: issue links plus the compact remote (web) links in one call, so the--remoteflag 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 --catalogemits themcp/operations-v2.jsonshape from the live v2 catalog.scripts/mcp-watch.tsalso watches the public supported-tools doc (docs.supportedToolsinmcp/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_emptyhint whenchildIssuesOf()JQL finds nothing.jqlaccepts a Jira issues URL,--group-bytakes any field name, andjql_invalidwarns 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_existsotherwise),issue convert --yes(required when the issue has children).issue move --commentis a separate comment POST. Transition names work in either language. page updaterefuses a body that would drop inline-comment anchors (inline_comment_anchors_dropped,droppedInlineCommentsin 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_utf8rejects non-UTF-8--file/stdin bodies.invalid_cursorapplies to every--cursor; every not-found is exit 2. - Output: invisible characters are escaped as
\uXXXX;--fieldsaccepts theitems.prefix and numeric indexes, keeps unmatched list items as{}and warnsfields_unmatched; a line may exceed the width budget only for one unsplittable string; storage bodies are line arrays;search_incompletewarns on short Confluence search pages. - Reads:
page treedefaults to depth 5 (tree_depth_uncheckedhint);page diffreportsrenamed; everypage versionsitem has atitle;page get --grepsplits ranges at 20 lines, excerpts long lines and always printsmatches;searchConfluence hits carryspaceKeyandparent;issue getaddsduedate,startdate,flaggedand--fullreturns all fields;issue worklogsaddsseconds/totalSeconds. - Content refs also accept
/display/SPACE/Title,viewpage.action?pageId=anddraftId=links,pageVersion=, wrapped refs (<url>,[x](url),[KAN-1]) and bare ids of blog posts; an ARI of another site issite_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
--fieldsmerge and a repeated--site/--outputisduplicate_flag;atl api --body-file -reads stdin; lowercase project keys work everywhere;issue log --startedtakes ISO 8601 only (invalid_started);--assigneevalidates like--set assignee=and acceptsnone/auto; conflict errors name kebab-case flags;comment add --select ''isempty_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 fromcomment addon a bare id hints--target-type blogpost. - MCP:
atl mcp toolsandatl mcp callretryinitialize,tools/listand read-onlytools/call(a tool withreadOnlyHint, and--catalog'sdiscoverlookups) on 429/502/503/504 and network errors with the REST policy (3 attempts, 20 s of sleep,Retry-Afterin seconds or as an HTTP date, aretryJSONL line on stderr per retry); aRetry-Afterpast the budget fails fast with a when-to-retry hint, and a call that may write is never retried. issue editis renamedissue update(no alias).issue create | update | move --setand--fields-fileaccept field display names (any language, case-insensitive; ids and common system fields are sent as typed,unknown_field/ambiguous_fieldlist candidates asjql --group-bydoes) 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 logcomputestimeSpentSecondslocally, so1.5h,1,5h,0,5dwork on sites whose locale rejects the other decimal separator;d/wread the site's working hours and days fromGET /rest/api/3/configurationonly when used, elseworking_time_unavailable(the token needs the scope that endpoint requires;h/m/snever need it).issue changelogshows status changes in the account's language, mapped from the change's status ids through oneGET /rest/api/3/statusper call (read only when a status change is listed);--fullis unchanged.page restrictandpage permissions add | set | removeon a bare blog post id (no--type) now target the blog post: one GET finds the collection that answers before the write (none when--typeis given).searchdrops an unpaired"from the text (a balanced pair stays a phrase) and a query of only quotes and blanks fails locally asempty_query;atl cqlreports the server's deterministic NullPointerException on such a query asinvalid_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 backlogit isinvalid_flags.board sprints <board> --summaryadds per sprintbyStatus(issue counts) andpoints(todo / inProgress / done / remaining / total) by status category,velocity(closed sprints: points finished and not carried on),unestimatedissues (sub-tasks excluded),carriedIn(also in an earlier sprint) andcarriedOut(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 sprintsalso reportstruncatedwhen the 1000-issue cap cut the read.issue getalso showscomponents,fixVersions,sprintand 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-fieldsonissue get,jqlandissue treeaccepts field display names (shown under that name), withunknown_field/ambiguous_fieldinstead of a silent empty result; ids and system fields are sent as typed with no lookup.--group-by statusCategorylabels groups with the category key (new,indeterminate,done) thatissue getshows asstatusCategory, 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 statusnext; a 403 naming the scope keeps the scope hint. searchandcqlattachment hits carrypageId, the page or blog post the file belongs to (read from the hit'spageId=url), so a file match no longer needsattachment getto find its page.- Errors that ask for a quoted CQL value (an unquoted non-ASCII label such as
label = pénzügyis HTTP 400 "Unrecognised clause") now say so in a hint, and an unrecognised Confluence reference such as a page title points atatl space pages <SPACE> --titleandatl cql 'title = "…"'(titles repeat across spaces). board issues --dataset backlogon 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 allissues, so NMS reads 15 backlog and 41 board issues (was 19 and 45). Kanban boards are unchanged (backlog stayssprint is EMPTY; epics stay listed).jql,cqlandsearchjoin 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 removelists the page commands instead of suggestingpage move).- Every
boardcommand 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'sjiraProjectByKey { boards }(experimentalJiraProjectopt-in), so a board you never opened is reachable withoutboard list. A project with several boards isambiguous_board(hint lists id, name and type), an unknown project or one without a board isboard_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, andpage updaterefuses a body that has fewer macros than the current page (macros_dropped,droppedMacrosin 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 isunknown_resolutionlisting the names,nullclears. Aissue move --set resolution=...on a transition whose screen has no resolution field fails withresolution_not_on_transition_screenand the exact follow-up (atl issue update KEY --set resolution=...), after which nothing was written.issue logaccepts compact durations (1h30m,2d4h,1H30M) like spaced ones.issue transitionsshows a transition's screen fields as[{id, name, required, allowed}](absent when the transition has no screen, as on NMS).issue comments/issue get --commentsmark a comment edited within the same second of posting withedited: true(a later edit still showsupdated).- The
atlagent 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 --repliesand the write commands (--set priority=High --set labels=a,b), one line naming the rarer commands, plus four rules (including: follownextCursoror--allbefore 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 (matchescount,description: {lines, ranges},comments: [{id, author, created, updated?, edited?, ranges}],commentsSearched; at most 20 ranges,moreMatchesfor the rest), likepage get --grep; every comment is searched unless--comments Nlimits it to the newest N. A 180-row table description comes back as one matching row instead of 15 KB.invalid_regexandinvalid_flag_combination(--contextwithout--grep,--grepwith--fullor--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-basedindexin the thread's oldest-first order,id,author,created, andupdatedplusedited: truewhen the comment was edited; it shares the matcher withissue get --grep, and--grepwith--limitor--cursorisconflicting_flags(--allis accepted: every comment is searched anyway).issue treeasks forsummary,status,issuetype(plusparent) by default instead of thejqllist fields, so default trees are smaller;--issue-fieldsstill selects any fields.board issues --issue-fields fselects the issue fields likejqldoes (Jira field names resolved, custom fields named); the default output is unchanged, and--countwith--issue-fieldsisconflicting_flags.--set priority=Highresolves the priority by name or id to{id}(an unknown name isunknown_prioritybefore anything is written) and--set labels=web,uisends a label array; JSON values (priority={"name":"High"},labels=["a"],null) pass through unchanged and--fields-filestays verbatim.- Error hints:
invalid_regexsays--grepalready matches case-insensitively and only a leading(?i)is accepted (other inline flags such as(?s)stayinvalid_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 listno longer suggestsspace list,issue listno longer suggestsissue link; the group's commands are listed instead), and keeps a typed verb that exists in the registry. --grepaccepts a leading(?i)(stripped; matching is already case-insensitive), and--grep -i <regex>drops the-iinstead of failing withinvalid_flag_value.--allon a command with no paging flag (no--limit,--cursoror--after, e.g.board sprints --summary,page get) is ignored instead ofunknown_flag. Commands with their own--allare unchanged, and commands that page without one (search,user find,compass components,graph context,issue tree,page ancestors) still reject--allwithunknown_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 whosestarteddate, 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-beforewith them areconflicting_flags) and returnstotalSecondsandtotal(39h 55m);totalis also printed next tototalSecondsotherwise. A bad date or unknown zone isinvalid_flag_value.page versionsshowstitleonly on the first row and where it changes, instead of on every row.issue get --comments N|--grepfetches the comments concurrently with the issue, andboard sprintsloads the board and the field catalog concurrently, one round trip less each.--issue-fields worklogonjql,issue getandissue treeshows{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 fortimespent, sototalis the issue's whole logged time even when the API embeds only 20 entries (morecounts the entries not shown;issue worklogslists them all); the separatetimespentvalue is not repeated. Iftimespentis missing, the sum of the shown entries is namedtotalOfShown/totalSecondsOfShown.--fullstays 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 areambiguous_title(input, exit 1; id, space key and title per candidate, at most 10, with anextcommand each), none isnot_found(exit 2, hintatl search "<title>", and a note when the title has non-breaking or zero-width characters).--type blogpostlooks 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 indist/atl.jsand compiled binaries).atl skill status | install | update | uninstallmanage copies in omp (~/.omp/agent/skills, honoursPI_CODING_AGENT_DIR), Claude Code (~/.claude/skills,CLAUDE_CONFIG_DIR),~/.agents/skills(Codex and other Agent Skills hosts) or--dir. A.atl-skill.jsonmanifest records the version and file hashes, so each copy reportscurrent,outdated,newer,modified,unmanaged,linkedormissing; 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 doctorlists installed copies and points toatl skill updatewhen one is outdated. atl skill status | install | updateandatl doctorcheck theatlthat agents will run from PATH: the result carriescli: {path, version}, and a missing or different-version CLI is a stderr warning (atl_not_on_path,atl_version_mismatch; indoctoramissingstep) whosenextinstalls or upgrades it. The skill's Setup section tells agents what to do oncommand 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 inassets/), replacing MCP-based research and Confluence editing skills. page update --expect-version Nfails withversion_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
_parentIdeditor parameter, which Confluence rewrites on save and madepage diffreport macro changes nobody made. page updatewith a storage body compares it with the current storage body:--planshowscheck: {bytes, removed, added}(macros, mentions, attachments, page links, links, dates, tasks, headings, layouts) and acontent_removedwarning lists what the new body drops.page update | createrefuse storage or ADF bodies with<script>,<style>,data:URIs orfile:URLs (body_hygiene,--forceoverrides; a warning under--plan).page diffaddsinventory: {removed, added}between the two versions.