/** * Confluence — Fetch Confluence pages and store as Markdown * * Tools: * - confluence_search — Search pages by CQL or text query * - confluence_fetch — Fetch a page by ID and save as .md * - confluence_spaces — List available spaces * * Configuration: * /confluence-config — TUI overlay to set credentials * ~/.pi/agent/confluence.json or .pi/confluence.json * * Config format: * { * "baseUrl": "https://your-domain.atlassian.net/wiki", * "email": "you@company.com", * "apiToken": "your-api-token", * "outputDir": "./confluence-docs" * } * * Usage: pi -e extensions/confluence.ts */ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { Type, type Static } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { Text, Key, matchesKey, CURSOR_MARKER, truncateToWidth, visibleWidth, type Focusable, } from "@mariozechner/pi-tui"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; // ═══════════════════════════════════════════════════════════════════ // Configuration // ═══════════════════════════════════════════════════════════════════ interface ConfluenceConfig { baseUrl: string; email: string; apiToken: string; outputDir: string; } function getConfigPaths(cwd: string): string[] { return [ join(cwd, ".pi", "confluence.json"), // project-local (highest priority) join(getAgentDir(), "confluence.json"), // global ]; } function loadConfig(cwd: string): ConfluenceConfig | undefined { const paths = getConfigPaths(cwd); for (const p of paths) { if (existsSync(p)) { try { const raw = JSON.parse(readFileSync(p, "utf-8")); const cfg: ConfluenceConfig = { baseUrl: resolveValue(raw.baseUrl ?? ""), email: resolveValue(raw.email ?? ""), apiToken: resolveValue(raw.apiToken ?? ""), outputDir: raw.outputDir ?? "./confluence-docs", }; const hasPlaceholder = cfg.baseUrl.includes("YOUR_") || cfg.email.includes("YOUR_") || cfg.apiToken.includes("YOUR_"); if (cfg.baseUrl && cfg.email && cfg.apiToken && !hasPlaceholder) return cfg; } catch { /* ignore bad JSON */ } } } // Fallback: environment variables const baseUrl = process.env.CONFLUENCE_BASE_URL; const email = process.env.CONFLUENCE_EMAIL; const apiToken = process.env.CONFLUENCE_API_TOKEN; if (baseUrl && email && apiToken) { return { baseUrl, email, apiToken, outputDir: "./confluence-docs" }; } return undefined; } function saveConfig(cwd: string, scope: "project" | "global", data: Record): string { const dir = scope === "project" ? join(cwd, ".pi") : getAgentDir(); mkdirSync(dir, { recursive: true }); const filePath = join(dir, "confluence.json"); writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); return filePath; } /** Scaffold a dummy settings file so the user knows what to fill in. * Only called when loadConfig() returned undefined (no valid config anywhere). */ function scaffoldConfig(cwd: string): string { const projectPath = join(cwd, ".pi", "confluence.json"); const globalPath = join(getAgentDir(), "confluence.json"); // If a file already exists (but was invalid), don't overwrite — return it if (existsSync(projectPath)) return projectPath; if (existsSync(globalPath)) return globalPath; // Create project-local dummy config const dir = join(cwd, ".pi"); mkdirSync(dir, { recursive: true }); const template = { "$comment": "Fill in your Confluence credentials below. Get an API token at https://id.atlassian.com/manage-profile/security/api-tokens", baseUrl: "https://YOUR_DOMAIN.atlassian.net/wiki", email: "YOUR_EMAIL@company.com", apiToken: "YOUR_API_TOKEN_HERE", outputDir: "./confluence-docs", }; writeFileSync(projectPath, JSON.stringify(template, null, 2) + "\n", "utf-8"); return projectPath; } /** Resolve ENV:VAR_NAME pattern or return as-is */ function resolveValue(val: string): string { if (val.startsWith("ENV:")) { return process.env[val.slice(4)] ?? ""; } return val; } // ═══════════════════════════════════════════════════════════════════ // Confluence API Client // ═══════════════════════════════════════════════════════════════════ function authHeader(config: ConfluenceConfig): string { return "Basic " + Buffer.from(`${config.email}:${config.apiToken}`).toString("base64"); } async function confluenceGet(config: ConfluenceConfig, path: string, signal?: AbortSignal): Promise { const url = `${config.baseUrl.replace(/\/+$/, "")}/rest/api${path}`; const res = await fetch(url, { headers: { "Authorization": authHeader(config), "Accept": "application/json", }, signal, }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`Confluence API ${res.status} ${res.statusText}: ${body.slice(0, 500)}`); } return res.json(); } // ═══════════════════════════════════════════════════════════════════ // HTML → Markdown converter (lightweight, no dependencies) // ═══════════════════════════════════════════════════════════════════ function htmlToMarkdown(html: string): string { let md = html; // Remove style/script tags entirely md = md.replace(/<(style|script)[^>]*>[\s\S]*?<\/\1>/gi, ""); // Confluence-specific: structured macros → code blocks md = md.replace(/]*ac:name="code"[^>]*>[\s\S]*?\s*\s*<\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/gi, "\n```\n$1\n```\n"); // Confluence macros: info/note/warning panels md = md.replace(/]*ac:name="(info|note|warning|tip)"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_m, type, content) => { const inner = htmlToMarkdown(content.replace(//gi, "").replace(/<\/ac:rich-text-body>/gi, "")); return `\n> **${type.toUpperCase()}:** ${inner.trim()}\n`; }); // Remove remaining Confluence macros md = md.replace(/]*>/gi, ""); md = md.replace(/<\/ac:[^>]*>/gi, ""); // Headings md = md.replace(/]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n"); md = md.replace(/]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n"); md = md.replace(/]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n"); md = md.replace(/]*>([\s\S]*?)<\/h4>/gi, "\n#### $1\n"); md = md.replace(/]*>([\s\S]*?)<\/h5>/gi, "\n##### $1\n"); md = md.replace(/]*>([\s\S]*?)<\/h6>/gi, "\n###### $1\n"); // Bold / italic / strikethrough / code md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**"); md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*"); md = md.replace(/<(del|s|strike)>([\s\S]*?)<\/\1>/gi, "~~$2~~"); md = md.replace(/([\s\S]*?)<\/code>/gi, "`$1`"); // Pre-formatted / code blocks md = md.replace(/]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n"); // Links md = md.replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)"); // Images md = md.replace(/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, "![$2]($1)"); md = md.replace(/]*src="([^"]*)"[^>]*\/?>/gi, "![]($1)"); // Lists md = md.replace(/]*>([\s\S]*?)<\/ul>/gi, (_, inner) => { return inner.replace(/]*>([\s\S]*?)<\/li>/gi, "- $1\n"); }); md = md.replace(/]*>([\s\S]*?)<\/ol>/gi, (_, inner) => { let i = 0; return inner.replace(/]*>([\s\S]*?)<\/li>/gi, (_m: string, text: string) => `${++i}. ${text}\n`); }); // Table → markdown table md = md.replace(/]*>([\s\S]*?)<\/table>/gi, (_match, tableInner) => { const rows: string[][] = []; const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; let rowMatch; while ((rowMatch = rowRegex.exec(tableInner)) !== null) { const cells: string[] = []; const cellRegex = /]*>([\s\S]*?)<\/t[hd]>/gi; let cellMatch; while ((cellMatch = cellRegex.exec(rowMatch[1])) !== null) { cells.push(cellMatch[1].replace(/<[^>]+>/g, "").trim()); } if (cells.length) rows.push(cells); } if (rows.length === 0) return ""; const maxCols = Math.max(...rows.map(r => r.length)); const normalized = rows.map(r => { while (r.length < maxCols) r.push(""); return r; }); let result = "\n| " + normalized[0].join(" | ") + " |\n"; result += "| " + normalized[0].map(() => "---").join(" | ") + " |\n"; for (let i = 1; i < normalized.length; i++) { result += "| " + normalized[i].join(" | ") + " |\n"; } return result + "\n"; }); // Blockquotes md = md.replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_m, inner) => { return inner.split("\n").map((l: string) => `> ${l}`).join("\n") + "\n"; }); // Paragraphs and breaks md = md.replace(//gi, "\n"); md = md.replace(/]*>([\s\S]*?)<\/p>/gi, "\n$1\n"); md = md.replace(//gi, "\n---\n"); // Divs → newlines md = md.replace(/]*>([\s\S]*?)<\/div>/gi, "\n$1\n"); // Strip remaining HTML tags md = md.replace(/<[^>]+>/g, ""); // Decode HTML entities md = md.replace(/&/g, "&"); md = md.replace(/</g, "<"); md = md.replace(/>/g, ">"); md = md.replace(/"/g, '"'); md = md.replace(/'/g, "'"); md = md.replace(/ /g, " "); // Clean up excessive newlines md = md.replace(/\n{3,}/g, "\n\n"); return md.trim(); } // ═══════════════════════════════════════════════════════════════════ // TUI: Credentials Configuration Overlay // ═══════════════════════════════════════════════════════════════════ interface ConfigField { id: string; label: string; description: string; masked: boolean; value: string; } class ConfluenceConfigEditor implements Focusable { focused = false; private fields: ConfigField[]; private selectedIndex = 0; private editingIndex = -1; private cursorPos = 0; private cachedLines?: string[]; private cachedWidth?: number; private saveScope: "project" | "global" = "global"; constructor( private theme: Theme, private cwd: string, private done: (saved: boolean) => void, ) { const existing = loadConfig(cwd); // Re-read raw JSON for display (don't show resolved env vars) const raw = this.loadRawConfig(); this.fields = [ { id: "baseUrl", label: "Base URL", description: "e.g. https://your-domain.atlassian.net/wiki", masked: false, value: raw.baseUrl ?? existing?.baseUrl ?? "", }, { id: "email", label: "Email", description: "Atlassian account email", masked: false, value: raw.email ?? existing?.email ?? "", }, { id: "apiToken", label: "API Token", description: "From https://id.atlassian.com/manage-profile/security/api-tokens", masked: true, value: raw.apiToken ?? existing?.apiToken ?? "", }, { id: "outputDir", label: "Output Dir", description: "Where to save markdown files (default: ./confluence-docs)", masked: false, value: raw.outputDir ?? existing?.outputDir ?? "./confluence-docs", }, ]; } private loadRawConfig(): Record { const paths = getConfigPaths(this.cwd); for (const p of paths) { if (existsSync(p)) { try { return JSON.parse(readFileSync(p, "utf-8")); } catch { /* ignore */ } } } return {}; } handleInput(data: string): void { // ── Global keys ── if (matchesKey(data, Key.escape)) { if (this.editingIndex >= 0) { this.editingIndex = -1; } else { this.done(false); } this.invalidate(); return; } // ── Editing mode ── if (this.editingIndex >= 0) { const field = this.fields[this.editingIndex]!; if (matchesKey(data, Key.enter)) { this.editingIndex = -1; this.invalidate(); return; } if (matchesKey(data, Key.backspace)) { if (this.cursorPos > 0) { field.value = field.value.slice(0, this.cursorPos - 1) + field.value.slice(this.cursorPos); this.cursorPos--; } } else if (matchesKey(data, Key.delete)) { if (this.cursorPos < field.value.length) { field.value = field.value.slice(0, this.cursorPos) + field.value.slice(this.cursorPos + 1); } } else if (matchesKey(data, Key.left)) { this.cursorPos = Math.max(0, this.cursorPos - 1); } else if (matchesKey(data, Key.right)) { this.cursorPos = Math.min(field.value.length, this.cursorPos + 1); } else if (matchesKey(data, Key.home)) { this.cursorPos = 0; } else if (matchesKey(data, Key.end)) { this.cursorPos = field.value.length; } else if (matchesKey(data, Key.ctrl("u"))) { field.value = ""; this.cursorPos = 0; } else if (data.length === 1 && data.charCodeAt(0) >= 32) { field.value = field.value.slice(0, this.cursorPos) + data + field.value.slice(this.cursorPos); this.cursorPos++; } this.invalidate(); return; } // ── Navigation mode ── // Total items: fields.length + 1 (scope toggle) + 1 (save button) const totalItems = this.fields.length + 2; if (matchesKey(data, Key.up)) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } else if (matchesKey(data, Key.down)) { this.selectedIndex = Math.min(totalItems - 1, this.selectedIndex + 1); } else if (matchesKey(data, Key.enter)) { if (this.selectedIndex < this.fields.length) { // Edit a field this.editingIndex = this.selectedIndex; this.cursorPos = this.fields[this.selectedIndex]!.value.length; } else if (this.selectedIndex === this.fields.length) { // Toggle scope this.saveScope = this.saveScope === "global" ? "project" : "global"; } else { // Save this.save(); this.done(true); return; } } else if (matchesKey(data, Key.ctrl("s"))) { this.save(); this.done(true); return; } else if (matchesKey(data, Key.tab)) { this.selectedIndex = (this.selectedIndex + 1) % totalItems; } this.invalidate(); } private save(): void { const data: Record = {}; for (const f of this.fields) { if (f.value) data[f.id] = f.value; } saveConfig(this.cwd, this.saveScope, data); } render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const th = this.theme; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); add(th.fg("accent", "─".repeat(width))); add(""); add(th.fg("accent", th.bold(" 🔗 Confluence Configuration"))); add(th.fg("dim", " Configure your Atlassian Confluence connection")); add(""); // ── Fields ── for (let i = 0; i < this.fields.length; i++) { const field = this.fields[i]!; const isSelected = i === this.selectedIndex; const isEditing = i === this.editingIndex; const prefix = isSelected ? th.fg("accent", " ▸ ") : " "; const label = isSelected ? th.fg("accent", th.bold(field.label)) : th.fg("text", field.label); add(`${prefix}${label} ${th.fg("dim", field.description)}`); if (isEditing) { const raw = field.value; const displayText = field.masked ? "•".repeat(raw.length) : raw; const before = displayText.slice(0, this.cursorPos); const cursorChar = this.cursorPos < displayText.length ? displayText[this.cursorPos]! : " "; const after = displayText.slice(this.cursorPos + 1); const marker = this.focused ? CURSOR_MARKER : ""; const inputLine = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`; add(` ${th.fg("border", "[")} ${inputLine} ${th.fg("border", "]")}`); } else { const display = field.value ? (field.masked ? "•".repeat(Math.min(field.value.length, 32)) : field.value) : th.fg("muted", "(empty)"); const bracket = isSelected ? th.fg("borderAccent", "[") : th.fg("border", "["); const bracketR = isSelected ? th.fg("borderAccent", "]") : th.fg("border", "]"); add(` ${bracket} ${display} ${bracketR}`); } add(""); } // ── Scope toggle ── const scopeIdx = this.fields.length; const isScopeSelected = this.selectedIndex === scopeIdx; const scopePrefix = isScopeSelected ? th.fg("accent", " ▸ ") : " "; const scopeLabel = this.saveScope === "global" ? `Global ${th.fg("dim", "(~/.pi/agent/confluence.json)")}` : `Project ${th.fg("dim", "(.pi/confluence.json)")}`; const scopeText = isScopeSelected ? th.fg("accent", th.bold("Save to: ")) + th.fg("success", scopeLabel) : th.fg("text", "Save to: ") + th.fg("dim", scopeLabel); add(`${scopePrefix}${scopeText}`); add(""); // ── Save button ── const isSaveSelected = this.selectedIndex === this.fields.length + 1; if (isSaveSelected) { add(th.fg("accent", th.bold(" ✓ Save Configuration"))); } else { add(th.fg("muted", " ✓ Save Configuration")); } add(""); // ── Help bar ── if (this.editingIndex >= 0) { add(th.fg("dim", " Type to edit • Ctrl+U clear • Enter confirm • Esc stop editing")); } else { add(th.fg("dim", " ↑↓ navigate • Tab next • Enter edit/toggle/save • Ctrl+S quick save • Esc close")); } add(th.fg("accent", "─".repeat(width))); this.cachedLines = lines; this.cachedWidth = width; return lines; } invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; } } // ═══════════════════════════════════════════════════════════════════ // Tool Schemas // ═══════════════════════════════════════════════════════════════════ const SearchParams = Type.Object({ query: Type.String({ description: "Search text or CQL query (e.g. 'type=page AND space=DEV AND title~\"architecture\"')" }), limit: Type.Optional(Type.Number({ description: "Max results to return (default: 10, max: 50)" })), space: Type.Optional(Type.String({ description: "Filter by space key (e.g. 'DEV', 'TEAM')" })), }); const FetchParams = Type.Object({ pageId: Type.String({ description: "Confluence page ID to fetch" }), filename: Type.Optional(Type.String({ description: "Custom output filename (default: page title slugified)" })), includeChildren: Type.Optional(Type.Boolean({ description: "Also fetch child pages (default: false)" })), }); const SpacesParams = Type.Object({ limit: Type.Optional(Type.Number({ description: "Max spaces to return (default: 25)" })), }); // ═══════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════ function slugify(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80); } function ensureDir(dir: string): void { mkdirSync(dir, { recursive: true }); } function buildFrontmatter(page: any, baseUrl: string): string { const lines = [ "---", `title: "${(page.title || "").replace(/"/g, '\\"')}"`, `confluence_id: "${page.id}"`, `space: "${page.space?.key || ""}"`, `url: "${baseUrl.replace(/\/+$/, "")}${page._links?.webui || ""}"`, `last_modified: "${page.version?.when || ""}"`, `author: "${page.version?.by?.displayName || ""}"`, `fetched_at: "${new Date().toISOString()}"`, "---", ]; return lines.join("\n"); } // ═══════════════════════════════════════════════════════════════════ // Extension Entry Point // ═══════════════════════════════════════════════════════════════════ export default function (pi: ExtensionAPI) { let config: ConfluenceConfig | undefined; // ── Load config on session start — scaffold a dummy file if nothing exists ── pi.on("session_start", async (_event, ctx) => { config = loadConfig(ctx.cwd); if (config) { ctx.ui.notify("Confluence connected ✓", "info"); ctx.ui.setStatus("confluence", "🔗 Confluence"); } else { const settingsPath = scaffoldConfig(ctx.cwd); ctx.ui.notify( `Confluence: settings file created at ${settingsPath} — fill in your credentials, then run /confluence-config or restart.`, "warning", ); } }); // Reload config if the user re-enters a session pi.on("session_switch", async (_event, ctx) => { config = loadConfig(ctx.cwd); }); // ── /confluence-config command → TUI overlay ── pi.registerCommand("confluence-config", { description: "Configure Confluence credentials (base URL, email, API token)", handler: async (_args, ctx) => { const saved = await ctx.ui.custom( (_tui, theme, _kb, done) => new ConfluenceConfigEditor(theme, ctx.cwd, done), { overlay: true, overlayOptions: { anchor: "center", width: "75%", minWidth: 60, maxHeight: "80%", }, }, ); if (saved) { config = loadConfig(ctx.cwd); if (config) { ctx.ui.notify("✓ Confluence configured successfully", "info"); ctx.ui.setStatus("confluence", "🔗 Confluence"); } else { ctx.ui.notify("⚠ Config saved but credentials incomplete — check values", "warning"); } } }, }); // ── Tool: confluence_search ── pi.registerTool({ name: "confluence_search", label: "Confluence Search", description: "Search Confluence pages by text query or CQL. Returns page IDs, titles, spaces, and excerpts. " + "Use confluence_fetch to download the full page content as markdown.", promptSnippet: "Search Confluence wiki pages by query or CQL", promptGuidelines: [ "Use confluence_search to find pages, then confluence_fetch to get full content.", "For CQL queries: type=page AND space=KEY AND title~\"term\" AND text~\"term\"", "Always present search results to the user before fetching all pages.", ], parameters: SearchParams, async execute(_id, params, signal, onUpdate, ctx) { if (!config) throw new Error("Confluence not configured. Run /confluence-config first."); const limit = Math.min(params.limit ?? 10, 50); let cql: string; // If query looks like CQL (contains operators), use directly if (/\b(AND|OR|type\s*=|space\s*=|title\s*~|text\s*~)\b/i.test(params.query)) { cql = params.query; } else { // Build a simple text search CQL const escaped = params.query.replace(/"/g, '\\"'); cql = `type=page AND (title~"${escaped}" OR text~"${escaped}")`; if (params.space) { cql += ` AND space="${params.space}"`; } } onUpdate?.({ content: [{ type: "text", text: `Searching: ${cql}` }], details: { phase: "searching", query: cql }, }); const data = await confluenceGet( config, `/content/search?cql=${encodeURIComponent(cql)}&limit=${limit}&expand=space,version`, signal, ); const results = (data.results || []).map((page: any) => ({ id: page.id, title: page.title, space: page.space?.key || "?", spaceName: page.space?.name || "", lastModified: page.version?.when || "", author: page.version?.by?.displayName || "", url: `${config!.baseUrl.replace(/\/+$/, "")}${page._links?.webui || ""}`, })); let text = `Found ${results.length} page(s) for: ${params.query}\n\n`; for (const r of results) { text += `- **${r.title}** (ID: ${r.id})\n`; text += ` Space: ${r.space} (${r.spaceName}) | Author: ${r.author}\n`; text += ` Modified: ${r.lastModified}\n`; text += ` ${r.url}\n\n`; } return { content: [{ type: "text", text }], details: { resultCount: results.length, results }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("confluence ")); content += theme.fg("accent", "search "); content += theme.fg("muted", args.query ?? ""); if (args.space) content += theme.fg("dim", ` [${args.space}]`); text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) { return new Text(theme.fg("warning", "⏳ Searching Confluence..."), 0, 0); } const details = result.details as any; const count = details?.resultCount ?? 0; const color = count > 0 ? "success" : "warning"; return new Text( theme.fg(color, `${count} page(s) found`) + theme.fg("dim", " — use confluence_fetch to download"), 0, 0, ); }, }); // ── Tool: confluence_fetch ── pi.registerTool({ name: "confluence_fetch", label: "Confluence Fetch", description: "Fetch a Confluence page by ID and save it as a Markdown file. " + "Returns the markdown content and the file path. " + "Optionally fetches child pages recursively.", promptSnippet: "Fetch a Confluence page by ID and save as markdown (.md) file", promptGuidelines: [ "Use page IDs from confluence_search results.", "Set includeChildren=true to recursively fetch sub-pages.", "Files are saved to the configured outputDir (default: ./confluence-docs/).", ], parameters: FetchParams, async execute(_id, params, signal, onUpdate, ctx) { if (!config) throw new Error("Confluence not configured. Run /confluence-config first."); const outputDir = resolve(ctx.cwd, config.outputDir); ensureDir(outputDir); const fetchPage = async (pageId: string, depth: number): Promise<{ path: string; title: string }[]> => { if (signal?.aborted) throw new Error("Cancelled"); onUpdate?.({ content: [{ type: "text", text: `Fetching page ${pageId}...` }], details: { phase: "fetching", pageId, depth }, }); const page = await confluenceGet( config!, `/content/${pageId}?expand=body.storage,space,version,children.page`, signal, ); const title = page.title || `page-${pageId}`; const html = page.body?.storage?.value || ""; const markdown = htmlToMarkdown(html); const frontmatter = buildFrontmatter(page, config!.baseUrl); const slug = params.filename && depth === 0 ? params.filename.replace(/\.md$/, "") : slugify(title); const filePath = join(outputDir, `${slug}.md`); const content = `${frontmatter}\n\n# ${title}\n\n${markdown}\n`; writeFileSync(filePath, content, "utf-8"); const results = [{ path: filePath, title }]; // Fetch children if requested if (params.includeChildren && page.children?.page?.results?.length) { for (const child of page.children.page.results) { const childResults = await fetchPage(child.id, depth + 1); results.push(...childResults); } } return results; }; const fetched = await fetchPage(params.pageId, 0); let text = `Fetched ${fetched.length} page(s):\n\n`; for (const f of fetched) { text += `- **${f.title}** → \`${f.path}\`\n`; } return { content: [{ type: "text", text }], details: { pageCount: fetched.length, files: fetched }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("confluence ")); content += theme.fg("accent", "fetch "); content += theme.fg("muted", `page:${args.pageId ?? "?"}`); if (args.includeChildren) content += theme.fg("dim", " +children"); text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) { return new Text(theme.fg("warning", "⏳ Fetching page..."), 0, 0); } const details = result.details as any; const count = details?.pageCount ?? 0; return new Text( theme.fg("success", `✓ ${count} page(s) saved as markdown`), 0, 0, ); }, }); // ── Tool: confluence_spaces ── pi.registerTool({ name: "confluence_spaces", label: "Confluence Spaces", description: "List available Confluence spaces. Shows space key, name, and type. " + "Use space keys with confluence_search to filter results.", promptSnippet: "List all available Confluence spaces", parameters: SpacesParams, async execute(_id, params, signal) { if (!config) throw new Error("Confluence not configured. Run /confluence-config first."); const limit = Math.min(params.limit ?? 25, 100); const data = await confluenceGet(config, `/space?limit=${limit}&expand=description.plain`, signal); const spaces = (data.results || []).map((s: any) => ({ key: s.key, name: s.name, type: s.type, description: s.description?.plain?.value?.slice(0, 120) || "", })); let text = `Found ${spaces.length} space(s):\n\n`; for (const s of spaces) { text += `- **${s.name}** (\`${s.key}\`) — ${s.type}`; if (s.description) text += `\n ${s.description}`; text += "\n"; } return { content: [{ type: "text", text }], details: { spaceCount: spaces.length, spaces }, }; }, renderCall(_args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); text.setText(theme.fg("toolTitle", theme.bold("confluence ")) + theme.fg("accent", "spaces")); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "⏳ Loading spaces..."), 0, 0); const details = result.details as any; return new Text(theme.fg("success", `${details?.spaceCount ?? 0} space(s)`), 0, 0); }, }); }