atlasctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # atlasctl
2
+
3
+ A Bun-based CLI for Atlassian workflows.
4
+
5
+ Initial scope:
6
+ - Fetch a Confluence page
7
+ - Include all comments and nested replies
8
+ - Include inline comment metadata when available
9
+
10
+ ## Requirements
11
+
12
+ - Bun 1.3+
13
+
14
+ ## Install
15
+
16
+ ### Local development
17
+
18
+ ```bash
19
+ bun install
20
+ ```
21
+
22
+ Run directly:
23
+
24
+ ```bash
25
+ bun run src/cli.ts --help
26
+ ```
27
+
28
+ ### Global (from npm)
29
+
30
+ ```bash
31
+ npm install -g atlasctl
32
+ ```
33
+
34
+ The CLI entrypoint uses Bun (`#!/usr/bin/env bun`), so Bun must be installed on the target machine.
35
+
36
+ ## Configuration
37
+
38
+ Config file path:
39
+
40
+ ```text
41
+ ~/.atlasctl.json
42
+ ```
43
+
44
+ Set required values:
45
+
46
+ ```bash
47
+ atlasctl config set site your-domain.atlassian.net
48
+ atlasctl config set email you@company.com
49
+ atlasctl config set apikey <atlassian-api-token>
50
+ ```
51
+
52
+ Or run guided setup for all required fields:
53
+
54
+ ```bash
55
+ atlasctl config set
56
+ ```
57
+
58
+ Guided setup requires an interactive terminal.
59
+
60
+ Read values:
61
+
62
+ ```bash
63
+ atlasctl config get site
64
+ atlasctl config get email
65
+ atlasctl config get apikey
66
+ ```
67
+
68
+ Notes:
69
+ - `apikey` is always redacted when read (`***hidden***`).
70
+ - `config show` also redacts `apikey`.
71
+
72
+ ## Commands
73
+
74
+ ```text
75
+ atlasctl config set
76
+ atlasctl config set <site|email|apikey> <value>
77
+ atlasctl config get <site|email|apikey>
78
+ atlasctl config show
79
+ atlasctl confluence page fetch <id-or-url> [--output <file>] [--pretty]
80
+ atlasctl --help
81
+ atlasctl --help-llm
82
+ atlasctl --version
83
+ ```
84
+
85
+ ## Fetch a Confluence page
86
+
87
+ By page ID:
88
+
89
+ ```bash
90
+ atlasctl confluence page fetch 22982787097 --pretty
91
+ ```
92
+
93
+ By URL:
94
+
95
+ ```bash
96
+ atlasctl confluence page fetch "https://your-domain.atlassian.net/wiki/spaces/ENG/pages/22982787097/Page+Title"
97
+ ```
98
+
99
+ Or write output to disk:
100
+
101
+ ```bash
102
+ atlasctl confluence page fetch 22982787097 --output page.json --pretty
103
+ ```
104
+
105
+ ## URL and site matching
106
+
107
+ When using a URL input, the URL host must match configured `site`.
108
+
109
+ Example mismatch error:
110
+ - URL host: `foo.atlassian.net`
111
+ - Config site: `bar.atlassian.net`
112
+
113
+ The command will fail fast to avoid calling the wrong tenant.
114
+
115
+ ## Output shape
116
+
117
+ `confluence page fetch` returns JSON with:
118
+ - `page`: core page metadata and body HTML
119
+ - `comments`: tree of comments and replies
120
+ - `meta`: fetch timestamp and total comment count
121
+
122
+ Inline comments include:
123
+ - `inlineContext.textSelection`
124
+ - `inlineContext.markerRef`
125
+ - `inlineContext.resolved`
126
+
127
+ ## Development
128
+
129
+ Run tests:
130
+
131
+ ```bash
132
+ bun test
133
+ ```
134
+
135
+ Optional bundle build:
136
+
137
+ ```bash
138
+ bun run build
139
+ ```
140
+
141
+ ## Publish to npm
142
+
143
+ ```bash
144
+ bun test
145
+ npm login
146
+ npm publish --access public
147
+ ```
148
+
149
+ If `atlasctl` is already taken on npm, switch to a scoped package name (for example `@your-scope/atlasctl`) while keeping the bin name as `atlasctl`.
package/bin/atlasctl ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { run } from "../src/cli.ts";
4
+
5
+ run().catch((error) => {
6
+ const message = error instanceof Error ? error.message : String(error);
7
+ console.error(message);
8
+ process.exit(1);
9
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "atlasctl",
3
+ "version": "0.1.0",
4
+ "description": "Atlassian CLI for Confluence page exports with comments",
5
+ "type": "module",
6
+ "packageManager": "bun@1.3.8",
7
+ "engines": {
8
+ "bun": ">=1.3.0"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "bin": {
16
+ "atlasctl": "bin/atlasctl"
17
+ },
18
+ "scripts": {
19
+ "dev": "bun run src/cli.ts",
20
+ "build": "rm -rf dist && bun build src/cli.ts --target bun --outfile dist/cli.js",
21
+ "test": "bun test"
22
+ },
23
+ "dependencies": {
24
+ "commander": "^14.0.3"
25
+ }
26
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { writeFile } from "node:fs/promises";
4
+ import { Command, InvalidArgumentError } from "commander";
5
+ import {
6
+ CONFIG_KEYS,
7
+ type ConfigKey,
8
+ maskConfig,
9
+ normalizeConfigValue,
10
+ readConfig,
11
+ requireFetchConfig,
12
+ setConfigValue,
13
+ writeConfig,
14
+ } from "./config";
15
+ import { fetchConfluencePage } from "./confluence";
16
+ import { getLlmHelpText } from "./llm-help";
17
+
18
+ const VERSION = "0.1.0";
19
+
20
+ function parseConfigKey(value: string): ConfigKey {
21
+ if (!CONFIG_KEYS.includes(value as ConfigKey)) {
22
+ throw new InvalidArgumentError(
23
+ `Invalid config key \"${value}\". Use one of: ${CONFIG_KEYS.join(", ")}`,
24
+ );
25
+ }
26
+
27
+ return value as ConfigKey;
28
+ }
29
+
30
+ async function handleConfigSet(key: ConfigKey, value: string): Promise<void> {
31
+ const configPath = await setConfigValue(key, value);
32
+ console.log(`Saved ${key} in ${configPath}`);
33
+ }
34
+
35
+ function configPromptLabel(key: ConfigKey): string {
36
+ if (key === "site") return "Atlassian site (for example: your-domain.atlassian.net)";
37
+ if (key === "email") return "Atlassian account email";
38
+ return "Atlassian API key";
39
+ }
40
+
41
+ function displayCurrentConfigValue(key: ConfigKey, value?: string): string {
42
+ if (!value) {
43
+ return "not set";
44
+ }
45
+
46
+ return key === "apikey" ? "***hidden***" : value;
47
+ }
48
+
49
+ async function handleConfigSetGuided(): Promise<void> {
50
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
51
+ throw new Error(
52
+ "Guided setup requires an interactive terminal. Use: atlasctl config set <site|email|apikey> <value>",
53
+ );
54
+ }
55
+
56
+ const config = await readConfig();
57
+ const updates: Partial<Record<ConfigKey, string>> = {};
58
+
59
+ for (const key of CONFIG_KEYS) {
60
+ while (true) {
61
+ const current = updates[key] ?? config[key];
62
+ const promptText = `${configPromptLabel(key)} [${displayCurrentConfigValue(key, current)}]: `;
63
+ const input = prompt(promptText)?.trim() ?? "";
64
+ const candidate = input || current;
65
+
66
+ if (!candidate) {
67
+ console.error(`${key} is required.`);
68
+ continue;
69
+ }
70
+
71
+ try {
72
+ updates[key] = normalizeConfigValue(key, candidate);
73
+ break;
74
+ } catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ console.error(message);
77
+ }
78
+ }
79
+ }
80
+
81
+ const configPath = await writeConfig({
82
+ ...config,
83
+ ...updates,
84
+ });
85
+ console.log(`Saved site, email, apikey in ${configPath}`);
86
+ }
87
+
88
+ async function handleConfigGet(key: ConfigKey): Promise<void> {
89
+ const config = await readConfig();
90
+ const value = config[key];
91
+
92
+ if (!value) {
93
+ throw new Error(
94
+ `Config key \"${key}\" is not set. Use: atlasctl config set ${key} <value>`,
95
+ );
96
+ }
97
+
98
+ if (key === "apikey") {
99
+ console.log("***hidden***");
100
+ return;
101
+ }
102
+
103
+ console.log(value);
104
+ }
105
+
106
+ async function handleConfigShow(): Promise<void> {
107
+ const config = await readConfig();
108
+ console.log(JSON.stringify(maskConfig(config), null, 2));
109
+ }
110
+
111
+ async function handlePageFetch(
112
+ idOrUrl: string,
113
+ options: { output?: string; pretty?: boolean },
114
+ ): Promise<void> {
115
+ const config = requireFetchConfig(await readConfig());
116
+ const payload = await fetchConfluencePage(config, idOrUrl);
117
+
118
+ const pretty = options.pretty ?? false;
119
+ const json = pretty
120
+ ? `${JSON.stringify(payload, null, 2)}\n`
121
+ : `${JSON.stringify(payload)}\n`;
122
+
123
+ if (options.output) {
124
+ await writeFile(options.output, json, "utf8");
125
+ console.log(`Wrote ${payload.meta.totalComments} comments to ${options.output}`);
126
+ return;
127
+ }
128
+
129
+ process.stdout.write(json);
130
+ }
131
+
132
+ async function handleConfigSetCommand(key?: string, value?: string): Promise<void> {
133
+ if (!key && !value) {
134
+ await handleConfigSetGuided();
135
+ return;
136
+ }
137
+
138
+ if (!key || !value) {
139
+ throw new Error(
140
+ "Invalid config set usage. Use either: atlasctl config set <site|email|apikey> <value> or run atlasctl config set for guided setup.",
141
+ );
142
+ }
143
+
144
+ await handleConfigSet(parseConfigKey(key), value);
145
+ }
146
+
147
+ export function buildProgram(): Command {
148
+ const program = new Command();
149
+
150
+ program
151
+ .name("atlasctl")
152
+ .description("Atlassian CLI for Confluence page exports")
153
+ .version(VERSION)
154
+ .option("--help-llm", "print concise LLM-focused usage guidance")
155
+ .showHelpAfterError();
156
+
157
+ const configCommand = program.command("config").description("Manage local CLI configuration");
158
+
159
+ configCommand
160
+ .command("set")
161
+ .description("Set one config value, or run guided setup with no arguments")
162
+ .argument("[key]", "config key: site, email, apikey")
163
+ .argument("[value]", "config value")
164
+ .action(async (key?: string, value?: string) => {
165
+ await handleConfigSetCommand(key, value);
166
+ });
167
+
168
+ configCommand
169
+ .command("get")
170
+ .description("Get a config value")
171
+ .argument("<key>", "config key", parseConfigKey)
172
+ .action(async (key: ConfigKey) => {
173
+ await handleConfigGet(key);
174
+ });
175
+
176
+ configCommand
177
+ .command("show")
178
+ .description("Show current config (API key is always redacted)")
179
+ .action(async () => {
180
+ await handleConfigShow();
181
+ });
182
+
183
+ const confluenceCommand = program.command("confluence").description("Confluence operations");
184
+ const pageCommand = confluenceCommand.command("page").description("Confluence page operations");
185
+
186
+ pageCommand
187
+ .command("fetch")
188
+ .description("Fetch a Confluence page and all comments")
189
+ .argument("<id-or-url>", "numeric page ID or full Confluence page URL")
190
+ .option("--output <file>", "write JSON result to file")
191
+ .option("--pretty", "pretty-print JSON output")
192
+ .action(async (idOrUrl: string, options: { output?: string; pretty?: boolean }) => {
193
+ await handlePageFetch(idOrUrl, options);
194
+ });
195
+
196
+ return program;
197
+ }
198
+
199
+ export async function run(argv = process.argv): Promise<void> {
200
+ if (argv.includes("--help-llm")) {
201
+ process.stdout.write(`${getLlmHelpText()}\n`);
202
+ return;
203
+ }
204
+
205
+ const program = buildProgram();
206
+ await program.parseAsync(argv);
207
+ }
208
+
209
+ if (import.meta.main) {
210
+ run().catch((error) => {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ console.error(message);
213
+ process.exit(1);
214
+ });
215
+ }
package/src/config.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { chmod, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import type { AtlasCtlConfig, RequiredConfig } from "./types";
5
+
6
+ export const CONFIG_FILENAME = ".atlasctl.json";
7
+ export const CONFIG_KEYS = ["site", "email", "apikey"] as const;
8
+
9
+ export type ConfigKey = (typeof CONFIG_KEYS)[number];
10
+
11
+ interface ConfigPathOptions {
12
+ configPath?: string;
13
+ homeDir?: string;
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ export function resolveConfigPath(options: ConfigPathOptions = {}): string {
21
+ if (options.configPath) {
22
+ return options.configPath;
23
+ }
24
+
25
+ const home = options.homeDir ?? homedir();
26
+ return path.join(home, CONFIG_FILENAME);
27
+ }
28
+
29
+ export function normalizeSite(input: string): string {
30
+ const value = input.trim();
31
+ if (!value) {
32
+ throw new Error("Site cannot be empty");
33
+ }
34
+
35
+ try {
36
+ const url = value.includes("://")
37
+ ? new URL(value)
38
+ : new URL(`https://${value}`);
39
+
40
+ if (!url.hostname) {
41
+ throw new Error();
42
+ }
43
+
44
+ return url.host.toLowerCase();
45
+ } catch {
46
+ throw new Error("Invalid site. Use a hostname like your-domain.atlassian.net");
47
+ }
48
+ }
49
+
50
+ function normalizeEmail(input: string): string {
51
+ const value = input.trim();
52
+ const isEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
53
+ if (!isEmail) {
54
+ throw new Error("Invalid email address");
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function normalizeApiKey(input: string): string {
60
+ const value = input.trim();
61
+ if (!value) {
62
+ throw new Error("API key cannot be empty");
63
+ }
64
+ return value;
65
+ }
66
+
67
+ export function normalizeConfigValue(key: ConfigKey, rawValue: string): string {
68
+ if (key === "site") {
69
+ return normalizeSite(rawValue);
70
+ }
71
+
72
+ if (key === "email") {
73
+ return normalizeEmail(rawValue);
74
+ }
75
+
76
+ return normalizeApiKey(rawValue);
77
+ }
78
+
79
+ function normalizeConfig(raw: unknown): AtlasCtlConfig {
80
+ if (!isRecord(raw)) {
81
+ throw new Error("Config must be a JSON object");
82
+ }
83
+
84
+ const config: AtlasCtlConfig = {};
85
+
86
+ if (typeof raw.site === "string" && raw.site.trim()) {
87
+ config.site = normalizeSite(raw.site);
88
+ }
89
+
90
+ if (typeof raw.email === "string" && raw.email.trim()) {
91
+ config.email = normalizeEmail(raw.email);
92
+ }
93
+
94
+ if (typeof raw.apikey === "string" && raw.apikey.trim()) {
95
+ config.apikey = normalizeApiKey(raw.apikey);
96
+ }
97
+
98
+ return config;
99
+ }
100
+
101
+ export async function readConfig(
102
+ options: ConfigPathOptions = {},
103
+ ): Promise<AtlasCtlConfig> {
104
+ const configPath = resolveConfigPath(options);
105
+
106
+ let raw: string;
107
+ try {
108
+ raw = await readFile(configPath, "utf8");
109
+ } catch (error) {
110
+ const fileError = error as NodeJS.ErrnoException;
111
+ if (fileError.code === "ENOENT") {
112
+ return {};
113
+ }
114
+ throw new Error(`Unable to read config at ${configPath}: ${fileError.message}`);
115
+ }
116
+
117
+ try {
118
+ const parsed = JSON.parse(raw);
119
+ return normalizeConfig(parsed);
120
+ } catch (error) {
121
+ throw new Error(
122
+ `Invalid config at ${configPath}. Ensure it is valid JSON with site/email/apikey strings.`,
123
+ );
124
+ }
125
+ }
126
+
127
+ export async function writeConfig(
128
+ config: AtlasCtlConfig,
129
+ options: ConfigPathOptions = {},
130
+ ): Promise<string> {
131
+ const configPath = resolveConfigPath(options);
132
+ const normalized = normalizeConfig(config);
133
+ const content = `${JSON.stringify(normalized, null, 2)}\n`;
134
+
135
+ await writeFile(configPath, content, { mode: 0o600 });
136
+
137
+ try {
138
+ await chmod(configPath, 0o600);
139
+ } catch {
140
+ // Best effort permission hardening.
141
+ }
142
+
143
+ return configPath;
144
+ }
145
+
146
+ export async function setConfigValue(
147
+ key: ConfigKey,
148
+ rawValue: string,
149
+ options: ConfigPathOptions = {},
150
+ ): Promise<string> {
151
+ const config = await readConfig(options);
152
+ config[key] = normalizeConfigValue(key, rawValue);
153
+
154
+ return writeConfig(config, options);
155
+ }
156
+
157
+ export function maskConfig(config: AtlasCtlConfig): AtlasCtlConfig {
158
+ if (!config.apikey) {
159
+ return { ...config };
160
+ }
161
+
162
+ return {
163
+ ...config,
164
+ apikey: "***hidden***",
165
+ };
166
+ }
167
+
168
+ export function requireFetchConfig(config: AtlasCtlConfig): RequiredConfig {
169
+ const missing: ConfigKey[] = [];
170
+
171
+ for (const key of CONFIG_KEYS) {
172
+ if (!config[key]) {
173
+ missing.push(key);
174
+ }
175
+ }
176
+
177
+ if (missing.length > 0) {
178
+ throw new Error(
179
+ `Missing required config values: ${missing.join(", ")}. Set each with: atlasctl config set <site|email|apikey> <value>`,
180
+ );
181
+ }
182
+
183
+ return config as RequiredConfig;
184
+ }
@@ -0,0 +1,206 @@
1
+ import type { Comment, PageExport, RequiredConfig } from "./types";
2
+
3
+ interface ApiClient {
4
+ baseUrl: string;
5
+ apiGet: (pathOrUrl: string) => Promise<any>;
6
+ fetchAllPages: (path: string) => Promise<any[]>;
7
+ }
8
+
9
+ interface ParsedPageInput {
10
+ pageId: string;
11
+ hostFromUrl?: string;
12
+ }
13
+
14
+ export function parsePageInput(idOrUrl: string): ParsedPageInput {
15
+ const value = idOrUrl.trim();
16
+ if (/^\d+$/.test(value)) {
17
+ return { pageId: value };
18
+ }
19
+
20
+ let url: URL;
21
+ try {
22
+ url = new URL(value);
23
+ } catch {
24
+ throw new Error(
25
+ "Invalid page identifier. Provide a numeric page ID or a full Confluence page URL.",
26
+ );
27
+ }
28
+
29
+ const fromPath = url.pathname.match(/\/pages\/(\d+)(?:\/|$)/)?.[1];
30
+ const fromQuery = url.searchParams.get("pageId") ?? undefined;
31
+ const pageId = fromPath ?? fromQuery;
32
+
33
+ if (!pageId || !/^\d+$/.test(pageId)) {
34
+ throw new Error("Could not extract a numeric page ID from the provided URL.");
35
+ }
36
+
37
+ return {
38
+ pageId,
39
+ hostFromUrl: url.host.toLowerCase(),
40
+ };
41
+ }
42
+
43
+ export function resolvePageIdForSite(
44
+ idOrUrl: string,
45
+ configuredSite: string,
46
+ ): string {
47
+ const parsed = parsePageInput(idOrUrl);
48
+
49
+ if (parsed.hostFromUrl && parsed.hostFromUrl !== configuredSite.toLowerCase()) {
50
+ throw new Error(
51
+ `URL host mismatch: URL uses ${parsed.hostFromUrl} but config site is ${configuredSite}.`,
52
+ );
53
+ }
54
+
55
+ return parsed.pageId;
56
+ }
57
+
58
+ function countComments(comments: Comment[]): number {
59
+ return comments.reduce((total, comment) => {
60
+ return total + 1 + countComments(comment.children);
61
+ }, 0);
62
+ }
63
+
64
+ function parseComment(raw: any): Comment {
65
+ const ext = raw.extensions ?? {};
66
+ let inlineContext: Comment["inlineContext"];
67
+
68
+ if (ext.inlineProperties) {
69
+ inlineContext = {
70
+ textSelection: ext.inlineProperties.originalSelection ?? "",
71
+ markerRef: ext.inlineProperties.markerRef ?? "",
72
+ resolved: ext.resolution?.status === "resolved",
73
+ };
74
+ }
75
+
76
+ return {
77
+ id: raw.id,
78
+ title: raw.title ?? "",
79
+ author:
80
+ raw.version?.by?.displayName ??
81
+ raw.history?.createdBy?.displayName ??
82
+ "unknown",
83
+ created: raw.version?.when ?? raw.history?.createdDate ?? "",
84
+ updated: raw.version?.when ?? "",
85
+ bodyHtml: raw.body?.storage?.value ?? raw.body?.view?.value ?? "",
86
+ inlineContext,
87
+ children: [],
88
+ };
89
+ }
90
+
91
+ function normalizePaginationLink(next: string): string {
92
+ if (next.startsWith("http://") || next.startsWith("https://")) {
93
+ return next;
94
+ }
95
+
96
+ if (next.startsWith("/wiki/")) {
97
+ return next.slice("/wiki".length);
98
+ }
99
+
100
+ return next;
101
+ }
102
+
103
+ function createClient(config: RequiredConfig): ApiClient {
104
+ const baseUrl = `https://${config.site}/wiki`;
105
+ const auth = Buffer.from(`${config.email}:${config.apikey}`).toString("base64");
106
+
107
+ const headers = {
108
+ Authorization: `Basic ${auth}`,
109
+ Accept: "application/json",
110
+ };
111
+
112
+ async function apiGet(pathOrUrl: string): Promise<any> {
113
+ const url =
114
+ pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")
115
+ ? pathOrUrl
116
+ : `${baseUrl}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`;
117
+
118
+ const response = await fetch(url, { headers });
119
+
120
+ if (!response.ok) {
121
+ throw new Error(`Confluence API error ${response.status} ${response.statusText}: GET ${url}`);
122
+ }
123
+
124
+ return response.json();
125
+ }
126
+
127
+ async function fetchAllPages(path: string): Promise<any[]> {
128
+ const results: any[] = [];
129
+ let next: string | null = path;
130
+
131
+ while (next) {
132
+ const page = await apiGet(next);
133
+ if (Array.isArray(page.results)) {
134
+ results.push(...page.results);
135
+ }
136
+
137
+ next = page._links?.next ? normalizePaginationLink(page._links.next) : null;
138
+ }
139
+
140
+ return results;
141
+ }
142
+
143
+ return {
144
+ baseUrl,
145
+ apiGet,
146
+ fetchAllPages,
147
+ };
148
+ }
149
+
150
+ async function fetchReplies(client: ApiClient, commentId: string): Promise<Comment[]> {
151
+ const rawReplies = await client.fetchAllPages(
152
+ `/rest/api/content/${commentId}/child/comment?expand=body.storage,version,extensions.inlineProperties,extensions.resolution&limit=100`,
153
+ );
154
+
155
+ const replies: Comment[] = [];
156
+ for (const raw of rawReplies) {
157
+ const reply = parseComment(raw);
158
+ reply.children = await fetchReplies(client, raw.id);
159
+ replies.push(reply);
160
+ }
161
+
162
+ return replies;
163
+ }
164
+
165
+ export async function fetchConfluencePage(
166
+ config: RequiredConfig,
167
+ idOrUrl: string,
168
+ ): Promise<PageExport> {
169
+ const pageId = resolvePageIdForSite(idOrUrl, config.site);
170
+ const client = createClient(config);
171
+
172
+ const page = await client.apiGet(
173
+ `/rest/api/content/${pageId}?expand=body.storage,version,history,space,metadata.labels`,
174
+ );
175
+
176
+ const rawComments = await client.fetchAllPages(
177
+ `/rest/api/content/${pageId}/child/comment?expand=body.storage,version,extensions.inlineProperties,extensions.resolution&limit=100`,
178
+ );
179
+
180
+ const comments: Comment[] = [];
181
+ for (const raw of rawComments) {
182
+ const comment = parseComment(raw);
183
+ comment.children = await fetchReplies(client, raw.id);
184
+ comments.push(comment);
185
+ }
186
+
187
+ return {
188
+ page: {
189
+ id: page.id,
190
+ title: page.title,
191
+ space: page.space?.key ?? "",
192
+ url: `${client.baseUrl}${page._links?.webui ?? ""}`,
193
+ author: page.version?.by?.displayName ?? "unknown",
194
+ created: page.history?.createdDate ?? page.version?.when ?? "",
195
+ lastUpdated: page.version?.when ?? "",
196
+ version: page.version?.number ?? 1,
197
+ labels: (page.metadata?.labels?.results ?? []).map((label: any) => label.name),
198
+ bodyHtml: page.body?.storage?.value ?? "",
199
+ },
200
+ comments,
201
+ meta: {
202
+ fetchedAt: new Date().toISOString(),
203
+ totalComments: countComments(comments),
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,42 @@
1
+ export function getLlmHelpText(): string {
2
+ return `atlasctl (LLM Quick Guide)
3
+
4
+ Purpose
5
+ - Fetch a Confluence page plus full comment tree (including inline comments) as JSON.
6
+
7
+ Required config
8
+ - File: ~/.atlasctl.json
9
+ - Keys: site, email, apikey
10
+
11
+ Fast path
12
+ - atlasctl config set
13
+ - atlasctl confluence page fetch <page-id-or-url> --pretty
14
+
15
+ Non-interactive setup
16
+ - atlasctl config set site <your-domain.atlassian.net>
17
+ - atlasctl config set email <you@example.com>
18
+ - atlasctl config set apikey <token>
19
+
20
+ Read config
21
+ - atlasctl config show
22
+ - atlasctl config get site
23
+ - atlasctl config get email
24
+ - atlasctl config get apikey (always prints ***hidden***)
25
+
26
+ Fetch
27
+ - atlasctl confluence page fetch <id-or-url> [--output <file>] [--pretty]
28
+
29
+ Input rules
30
+ - id-or-url can be numeric page ID, /pages/<id>/ URL, or ?pageId=<id> URL.
31
+ - URL host must match configured site.
32
+
33
+ Output
34
+ - page, comments (recursive), meta.
35
+ - inline comments map to comment.inlineContext: textSelection, markerRef, resolved.
36
+
37
+ Common failures
38
+ - Missing config keys: set site/email/apikey.
39
+ - Invalid URL or no page ID in URL.
40
+ - URL host mismatch with configured site.
41
+ - Guided config in non-TTY: use explicit config set key/value commands.`;
42
+ }
package/src/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface AtlasCtlConfig {
2
+ site?: string;
3
+ email?: string;
4
+ apikey?: string;
5
+ }
6
+
7
+ export interface RequiredConfig {
8
+ site: string;
9
+ email: string;
10
+ apikey: string;
11
+ }
12
+
13
+ export interface Comment {
14
+ id: string;
15
+ title: string;
16
+ author: string;
17
+ created: string;
18
+ updated: string;
19
+ bodyHtml: string;
20
+ inlineContext?: {
21
+ textSelection: string;
22
+ markerRef: string;
23
+ resolved: boolean;
24
+ };
25
+ children: Comment[];
26
+ }
27
+
28
+ export interface PageExport {
29
+ page: {
30
+ id: string;
31
+ title: string;
32
+ space: string;
33
+ url: string;
34
+ author: string;
35
+ created: string;
36
+ lastUpdated: string;
37
+ version: number;
38
+ labels: string[];
39
+ bodyHtml: string;
40
+ };
41
+ comments: Comment[];
42
+ meta: {
43
+ fetchedAt: string;
44
+ totalComments: number;
45
+ };
46
+ }