import process from "node:process"; import { pathToFileURL } from "node:url"; import { Command } from "commander"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; import type { FetchLike, OutputFormat, Writer } from "./types.js"; type CliContext = { cwd?: string; env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; stdout?: Writer; stderr?: Writer; }; function resolveFormat(format: string | undefined): OutputFormat { return format === "text" ? "text" : "json"; } function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; const stdout = context.stdout ?? process.stdout; const stderr = context.stderr ?? process.stderr; let configCache: ReturnType | undefined; let jiraCache: ReturnType | undefined; let confluenceCache: ReturnType | undefined; function getConfig() { configCache ??= loadConfig(env, { cwd }); return configCache; } function getJiraClient() { jiraCache ??= createJiraClient({ config: getConfig(), fetchImpl: context.fetchImpl, }); return jiraCache; } function getConfluenceClient() { confluenceCache ??= createConfluenceClient({ config: getConfig(), fetchImpl: context.fetchImpl, }); return confluenceCache; } async function readBodyFile(filePath: string | undefined) { if (!filePath) { return undefined; } return readWorkspaceFile(filePath, cwd); } return { cwd, stdout, stderr, readBodyFile, getConfig, getJiraClient, getConfluenceClient, fetchImpl: context.fetchImpl, }; } export function buildProgram(context: CliContext = {}) { const runtime = createRuntime(context); const program = new Command() .name("atlassian") .description("Portable Atlassian CLI for multi-agent skills") .version("0.1.0"); program .command("health") .description("Validate configuration and Atlassian connectivity") .option("--format ", "Output format", "json") .action(async (options) => { const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); writeOutput( runtime.stdout, payload, resolveFormat(options.format), ); }); program .command("conf-search") .requiredOption("--query ", "CQL search query") .option("--max-results ", "Maximum results to return", "50") .option("--start-at ", "Result offset", "0") .option("--format ", "Output format", "json") .action(async (options) => { const payload = await runtime.getConfluenceClient().searchPages({ query: options.query, maxResults: Number(options.maxResults), startAt: Number(options.startAt), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("conf-get") .requiredOption("--page ", "Confluence page ID") .option("--format ", "Output format", "json") .action(async (options) => { const payload = await runtime.getConfluenceClient().getPage(options.page); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("conf-create") .requiredOption("--title ", "Confluence page title") .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") .option("--space <space>", "Confluence space ID") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getConfluenceClient().createPage({ space: options.space, title: options.title, body: (await runtime.readBodyFile(options.bodyFile)) as string, dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("conf-update") .requiredOption("--page <page>", "Confluence page ID") .requiredOption("--title <title>", "Confluence page title") .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getConfluenceClient().updatePage({ pageId: options.page, title: options.title, body: (await runtime.readBodyFile(options.bodyFile)) as string, dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("conf-comment") .requiredOption("--page <page>", "Confluence page ID") .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getConfluenceClient().commentPage({ pageId: options.page, body: (await runtime.readBodyFile(options.bodyFile)) as string, dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("conf-children") .requiredOption("--page <page>", "Confluence page ID") .option("--max-results <number>", "Maximum results to return", "50") .option("--start-at <number>", "Cursor/start token", "0") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getConfluenceClient().listChildren( options.page, Number(options.maxResults), Number(options.startAt), ); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("raw") .requiredOption("--product <product>", "jira or confluence") .requiredOption("--method <method>", "GET, POST, or PUT") .requiredOption("--path <path>", "Validated API path") .option("--body-file <path>", "Workspace-relative JSON file") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runRawCommand(runtime.getConfig(), runtime.fetchImpl, { product: options.product, method: String(options.method).toUpperCase(), path: options.path, bodyFile: options.bodyFile, cwd: runtime.cwd, dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-search") .requiredOption("--jql <jql>", "JQL expression to execute") .option("--max-results <number>", "Maximum results to return", "50") .option("--start-at <number>", "Result offset", "0") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().searchIssues({ jql: options.jql, maxResults: Number(options.maxResults), startAt: Number(options.startAt), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-get") .requiredOption("--issue <issue>", "Issue key") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().getIssue(options.issue); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-create") .requiredOption("--type <type>", "Issue type name") .requiredOption("--summary <summary>", "Issue summary") .option("--project <project>", "Project key") .option("--description-file <path>", "Workspace-relative markdown/text file") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().createIssue({ project: options.project, type: options.type, summary: options.summary, description: await runtime.readBodyFile(options.descriptionFile), dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-update") .requiredOption("--issue <issue>", "Issue key") .option("--summary <summary>", "Updated summary") .option("--description-file <path>", "Workspace-relative markdown/text file") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().updateIssue({ issue: options.issue, summary: options.summary, description: await runtime.readBodyFile(options.descriptionFile), dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-comment") .requiredOption("--issue <issue>", "Issue key") .requiredOption("--body-file <path>", "Workspace-relative markdown/text file") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().commentIssue({ issue: options.issue, body: (await runtime.readBodyFile(options.bodyFile)) as string, dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-transitions") .requiredOption("--issue <issue>", "Issue key") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().getTransitions(options.issue); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); program .command("jira-transition") .requiredOption("--issue <issue>", "Issue key") .requiredOption("--transition <transition>", "Transition ID") .option("--dry-run", "Print the request without sending it") .option("--format <format>", "Output format", "json") .action(async (options) => { const payload = await runtime.getJiraClient().transitionIssue({ issue: options.issue, transition: options.transition, dryRun: Boolean(options.dryRun), }); writeOutput(runtime.stdout, payload, resolveFormat(options.format)); }); return program; } export async function runCli(argv = process.argv, context: CliContext = {}) { const program = buildProgram(context); await program.parseAsync(argv); } const isDirectExecution = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; if (isDirectExecution) { runCli().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; }); }