🏭 Factory, not a class — createJiraClient(options) returns a plain object of methods; no new, no inheritance, nothing to extend.
🔌 ~45 methods, one client — issues, comments, worklogs, watchers, links, projects, agile boards/sprints/epics, people search, and instance metadata (fields, priorities, statuses, labels, filters, dashboards, server info, permissions) all come off the same object.
🧼 Zero environment coupling — no process.env reads, no process.exit. Every credential and setting comes in through the options object, so the same client works identically in a CLI, an MCP server, a TUI, or a test.
🧪 Injectable fetch — defaults to globalThis.fetch, but a caller can pass a fake for tests or a wrapped one for logging/retries.
📝 ADF ⇄ Markdown, both directions — adfToMarkdown and markdownToAdf convert Jira's Atlassian Document Format to and from plain Markdown, without ever touching ANSI or HTML.
📎 Attachment origin tracking — locateAttachments maps each attachment back to where it was actually referenced (issue body, description, or a specific comment), something Jira's API never says directly.
⚠️ Typed API errors — jiraApiError / isJiraApiError give callers a narrowed error shape instead of a bare thrown string.
📦 Zero runtime dependencies — pure fetch-based client using only Node/Web platform APIs (Buffer, fetch).
baseUrl accepts a bare host (myorg.atlassian.net) as readily as a full URL — normalizeBaseUrl fills in https:// when it's missing, since a bare host is the common shape a config value or env var arrives in.
Builds a typed JiraApiError (Error with name: "JiraApiError", status, method, url, body). Used internally on every non-2xx response.
isJiraApiError(e)
Type guard narrowing an unknown catch value to JiraApiError.
errorMessagesOf(e)
Jira's own words for what went wrong — errorMessages[] and errors{} from the response body — falling back to the error's message. What a person can act on; message is the URL-prefixed envelope.
import { isJiraApiError } from "@kud/jira"try { await jira.getIssue("PROJ-999")} catch (e) { if (isJiraApiError(e) && e.status === 404) { console.error("no such issue") } else { throw e }}
isContainerType(type) says whether an issue type heads children — Jira Cloud's hierarchyLevel > 0 when present (so a renamed Epic or an Initiative still counts), the name Epic otherwise. Use it rather than comparing names.
Converts an Atlassian Document Format node tree to Markdown. Handles headings, lists, tables, code blocks, panels, mentions, emoji, and media references.
markdownToAdf(text)
Converts Markdown back to ADF — paragraphs, fenced code, headings, lists, links, and code spans. Deliberately partial: richer formatting is better authored in Jira directly.
Both stop at Markdown rather than emitting ANSI or HTML — rendering is the caller's job, so a TUI, a pager, and a --json consumer all get the same text.
Maps an issue's attachments back to where they were referenced — the issue body, the description, or a specific comment — since Jira's API never states this directly.
isTextual(attachment)
Mime/extension sniffing for attachments that are safe to render as text.
downloadAttachment(client, id, fetchImpl?)
Downloads attachment bytes, handling Jira's redirect-to-media-host flow without forwarding the Authorization header cross-origin.
Resolves the instance URL, email and token the way every @kud Jira surface does, so a host can build a client without re-implementing the lookup. The URL and email come from ATLASSIAN_BASE_URL / ATLASSIAN_USER_EMAIL or, failing those, from $XDG_CONFIG_HOME/jira/config.json (JIRA_CONFIG_FILE overrides the path); the token comes from ATLASSIAN_API_TOKEN only, and a token found in the file is an error. Returns { config } ready for createJiraClient, or { missing: string[] } naming every absent variable at once. readFileConfig(path?) and configPath() are exported for hosts that need the halves. The file may also carry defaultProject, defaultBoard, customFields, sprintField, and tabs — hand-written board tabs ({ label, statuses: string[] }[], statuses by id or name) for a TUI to use over a board's own column config.
Consumed today by @kud/jira-cli, which extracted this package's logic from its own src/api/ layer so a second surface — an MCP server, a TUI — could consume the same client without going through the CLI.