Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ When creating a scoped token, select the following [classic scopes](https://deve
| `write:confluence-file` | Uploading attachments (`attachments --upload`) |
| `write:confluence-space` | Managing spaces |

For **read-only** usage, select at minimum: `read:confluence-content.all`, `read:confluence-content.summary`, `read:confluence-space.summary`, and `search:confluence`.
For **read-only** usage, select at minimum the classic scopes `read:confluence-content.all`, `read:confluence-content.summary`, `read:confluence-space.summary`, and `search:confluence`. Listing folders with `children --type folders`/`all` additionally requires the granular scope `read:hierarchical-content:confluence`.

**On-premise / Data Center:** Use your Confluence username and password for basic authentication.

Expand Down Expand Up @@ -580,28 +580,36 @@ confluence spaces --limit 2000
confluence spaces --all
```

### List Child Pages
### List Children
```bash
# List direct child pages
confluence children 123456789

# List all descendants recursively
# List descendant pages recursively
confluence children 123456789 --recursive

# List child folders instead of pages (Confluence Cloud only)
confluence children 123456789 --type folders

# List both pages and folders; each item shows its content type
confluence children 123456789 --type all

# Display as tree structure
confluence children 123456789 --recursive --format tree

# Show page IDs and URLs
# Show child IDs and available URLs
confluence children 123456789 --show-id --show-url

# Limit recursion depth
# Limit page recursion depth
confluence children 123456789 --recursive --max-depth 3

# Output as JSON for scripting
confluence children 123456789 --recursive --json > children.json
```

`children --json` returns structured metadata for each page, including `id`, `title`, `type`, `status`, `spaceKey`, `parentId`, `version`, and `url`. Recursive output also includes `depth`, and when available, `ancestors`.
`children --json` returns structured metadata for each child, including `id`, `title`, `type`, `status`, `spaceKey`, `parentId`, `version`, and `url`. Recursive page entries also include `depth`, and when available, `ancestors`.

Use `--type` to choose what is listed: `pages` (default, unchanged behavior), `folders`, or `all`. The `type` field on each item distinguishes `page` from `folder`; when folders can appear, the human-readable list marks every item with `[page]` or `[folder]`, and tree output uses distinct page and folder icons. Folders are a Confluence Cloud concept; on Server/Data Center, `--type folders`/`all` prints a warning and skips folders instead of failing, while `all` still lists pages. When pages are included, `--recursive` lists their descendants as before, but folders remain limited to direct children of the requested page.

Example recursive JSON item:
```json
Expand Down Expand Up @@ -864,7 +872,7 @@ confluence stats
| `search <query>` | Search for pages | `--json`, `--limit <number>`, `--start <number>` |
| `spaces` | List available spaces | `--json`, `--limit <number>`, `--all` |
| `find <title>` | Find a page by its title | `--space <spaceKey>`, `--json` |
| `children <pageId>` | List child pages of a page | `--recursive`, `--max-depth <number>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` |
| `children <pageId>` | List child pages and folders of a page | `--recursive`, `--max-depth <number>`, `--type <pages\|folders\|all>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` |
| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` |
| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` |
| `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet`, `--json` |
Expand Down
93 changes: 68 additions & 25 deletions bin/confluence.js
Original file line number Diff line number Diff line change
Expand Up @@ -779,30 +779,51 @@ program
// List children command
program
.command('children <pageId>')
.description('List child pages of a Confluence page')
.option('-r, --recursive', 'List all descendants recursively', false)
.option('--max-depth <number>', 'Maximum depth for recursive listing', '10')
.description('List child pages and folders of a Confluence page')
.option('-r, --recursive', 'Recurse through descendant pages; folders remain direct children', false)
.option('--max-depth <number>', 'Maximum page recursion depth', '10')
.option('--type <type>', 'Content type to list: pages, folders, all (folders are Cloud-only)', 'pages')
.option('--format <format>', 'Output format (list, tree). "json" is deprecated — use --json', 'list')
.option('--show-url', 'Show page URLs', false)
.option('--show-id', 'Show page IDs', false)
.option('--show-url', 'Show available child URLs', false)
.option('--show-id', 'Show child IDs', false)
.action(withClient('children', async ({ client, config, analytics, wantsJson, emitJson }, pageId, options) => {
const format = (options.format || 'list').toLowerCase();
const jsonMode = wantsJson(options);

const type = (options.type || 'pages').toLowerCase();
if (!['pages', 'folders', 'all'].includes(type)) {
throw new Error(`Invalid --type "${options.type}". Valid values are: pages, folders, all.`);
}
const includePages = type === 'pages' || type === 'all';
const includeFolders = type === 'folders' || type === 'all';

// Extract page ID from URL if needed
const resolvedPageId = await client.extractPageId(pageId);

// Get children
let children;
if (options.recursive) {
const maxDepth = parseInt(options.maxDepth) || 10;
children = await client.getAllDescendantPages(
resolvedPageId,
maxDepth,
{ includeAncestors: jsonMode }
);
} else {
children = await client.getChildPages(resolvedPageId);
// Get child pages
let children = [];
if (includePages) {
if (options.recursive) {
const maxDepth = parseInt(options.maxDepth) || 10;
children = await client.getAllDescendantPages(
resolvedPageId,
maxDepth,
{ includeAncestors: jsonMode }
);
} else {
children = await client.getChildPages(resolvedPageId);
}
}

// Get child folders (Confluence Cloud only). On Server/DC there is no
// folder content type, so degrade gracefully instead of crashing.
if (includeFolders) {
if (client.isCloud()) {
const folders = await client.getChildFolders(resolvedPageId);
children = children.concat(folders);
} else {
console.error(chalk.yellow('Folders are only supported on Confluence Cloud; none were listed.'));
}
}

if (children.length === 0) {
Expand All @@ -813,7 +834,8 @@ program
children: []
});
} else {
console.log(chalk.yellow('No child pages found.'));
const emptyNoun = type === 'folders' ? 'child folders' : type === 'all' ? 'children' : 'child pages';
console.log(chalk.yellow(`No ${emptyNoun} found.`));
}
analytics.track('children', true);
return;
Expand Down Expand Up @@ -858,21 +880,26 @@ program
printTree(tree, client, config, options, 1);

console.log('');
console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
console.log(chalk.gray(`Total: ${children.length} ${totalLabel(type, children.length)}`));
} else {
// List format (default)
console.log(chalk.blue('Child pages:'));
console.log(chalk.blue(type === 'pages' ? 'Child pages:' : 'Children:'));
console.log('');

children.forEach((page, index) => {
let output = `${index + 1}. ${chalk.green(page.title)}`;

// Indicate the content type whenever folders may appear in the output.
if (includeFolders) {
output += ` ${chalk.cyan(`[${page.type === 'folder' ? 'folder' : 'page'}]`)}`;
}

if (options.showId) {
output += ` ${chalk.gray(`(ID: ${page.id})`)}`;
}

if (options.showUrl) {
const url = `${client.buildUrl(`${client.webUrlPrefix}/spaces/${page.space?.key}/pages/${page.id}`)}`;
const url = options.showUrl ? childUrl(page, client) : null;
if (url) {
output += `\n ${chalk.gray(url)}`;
}

Expand All @@ -884,12 +911,27 @@ program
});

console.log('');
console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
console.log(chalk.gray(`Total: ${children.length} ${totalLabel(type, children.length)}`));
}

analytics.track('children', true);
}));

// Pluralized noun for the "Total: N ..." summary line. Keeps the historical
// "child page(s)" wording for the default (pages) type for byte-compatibility.
function totalLabel(type, count) {
const noun = type === 'pages' ? 'child page' : 'item';
return `${noun}${count === 1 ? '' : 's'}`;
}

function childUrl(child, client) {
if (child.type === 'folder') {
return child.url || null;
}

return client.buildUrl(`${client.webUrlPrefix}/spaces/${child.space?.key}/pages/${child.id}`);
}

// Helper function to build tree structure
function buildTree(pages, rootId) {
const tree = [];
Expand Down Expand Up @@ -925,14 +967,15 @@ function printTree(nodes, client, config, options, depth = 1) {
const indent = ' '.repeat(depth - 1);
const prefix = isLast ? '└── ' : '├── ';

let output = `${indent}${prefix}📄 ${chalk.green(node.title)}`;
const icon = node.type === 'folder' ? '📁' : '📄';
let output = `${indent}${prefix}${icon} ${chalk.green(node.title)}`;

if (options.showId) {
output += ` ${chalk.gray(`(ID: ${node.id})`)}`;
}

if (options.showUrl) {
const url = `${client.buildUrl(`${client.webUrlPrefix}/spaces/${node.space?.key}/pages/${node.id}`)}`;
const url = options.showUrl ? childUrl(node, client) : null;
if (url) {
output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`;
}

Expand Down
72 changes: 71 additions & 1 deletion lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class ConfluenceClient {
if (this.isScopedToken()) {
hints.push(
'You are using a scoped API token (api.atlassian.com). Please verify:',
' - Your token has the required scopes (e.g., read:confluence-content.all, read:confluence-content.summary, read:confluence-space.summary)',
' - Your token has the required scopes (e.g., read:confluence-content.all, read:confluence-content.summary, read:confluence-space.summary, read:hierarchical-content:confluence)',
' - Your Cloud ID in the API path is correct',
' - Your email matches the account that created the token',
'See: https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/'
Expand Down Expand Up @@ -1766,6 +1766,64 @@ class ConfluenceClient {
}));
}

/**
* Base URL for the Confluence Cloud v2 REST API.
*
* Folders are a Cloud-only content type and are not exposed by the v1
* `/content/{id}/child/*` endpoints, so folder listing is served by v2.
* The v2 API lives alongside v1: `/wiki/rest/api` -> `/wiki/api/v2`.
*/
v2BaseUrl() {
const v2Path = /\/rest\/api$/.test(this.apiPath)
? this.apiPath.replace(/\/rest\/api$/, '/api/v2')
: `${this.apiPath.replace(/\/+$/, '')}/api/v2`;
return `${this.protocol}://${this.domain}${v2Path}`;
}

/**
* Get direct child folders of a page (Confluence Cloud only).
*
* Uses the v2 `/pages/{id}/direct-children` endpoint, which returns children
* of every content type, and keeps only folders. Passing an absolute URL to
* the shared axios instance reuses its auth headers and TLS configuration
* while bypassing the v1 baseURL.
*/
async getChildFolders(pageId, limit = 250) {
const url = `${this.v2BaseUrl()}/pages/${pageId}/direct-children`;
const results = [];
let cursor = null;

do {
const params = cursor ? { limit, cursor } : { limit };
const response = await this.client.get(url, { params });
results.push(...(response.data?.results || []));
cursor = this.parseNextCursor(response.data?._links?.next);
} while (cursor);

return results
.filter(item => item.type === 'folder')
.map(item => this.normalizeFolder(item, pageId));
}

/**
* Normalize a v2 folder record into the same shape used for child pages so
* the two can be listed together. Folders have no storage body, space key,
* version, or web URL exposed by the direct-children endpoint.
*/
normalizeFolder(raw, parentId) {
const id = raw?.id !== undefined && raw?.id !== null ? String(raw.id) : null;
return {
id,
title: raw?.title || '',
type: 'folder',
status: raw?.status || null,
spaceKey: null,
parentId: parentId !== undefined && parentId !== null ? String(parentId) : null,
version: null,
url: null
};
}

/**
* Get all descendant pages recursively
*/
Expand Down Expand Up @@ -2193,6 +2251,18 @@ class ConfluenceClient {
return Number.isNaN(value) ? null : value;
}

parseNextCursor(nextLink) {
if (!nextLink) {
return null;
}

try {
return new URL(nextLink, this.v2BaseUrl()).searchParams.get('cursor');
} catch {
return null;
}
}

parsePositiveInt(value, fallback) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
Expand Down
27 changes: 17 additions & 10 deletions plugins/confluence/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Required classic scopes for scoped tokens:
- Write: add `write:confluence-content`, `write:confluence-file`, `write:confluence-space`
- Attachments: `readonly:content.attachment:confluence` (download), `write:confluence-file` (upload)

Folder listing with `children --type folders`/`all` also requires the granular scope `read:hierarchical-content:confluence`.

**Read-only mode (recommended for AI agents):**

Prevents all write operations (create, update, delete, move, etc.) at the profile level. Useful when giving an AI agent access to Confluence for reading only.
Expand Down Expand Up @@ -239,26 +241,31 @@ confluence spaces

### `children <pageId>`

List child pages of a page.
List child pages and Confluence Cloud folders of a page.

```sh
confluence children <pageId> [--recursive] [--max-depth <number>] [--format list|tree|json] [--show-id] [--show-url]
confluence children <pageId> [--recursive] [--max-depth <number>] [--type pages|folders|all] [--format list|tree] [--json] [--show-id] [--show-url]
```

| Option | Default | Description |
|---|---|---|
| `--recursive` | false | List all descendants recursively |
| `--max-depth` | `10` | Maximum depth for recursive listing |
| `--format` | `list` | Output format: `list`, `tree`, or `json` |
| `--show-id` | false | Show page IDs |
| `--show-url` | false | Show page URLs |
| `--recursive` | false | Recurse through descendant pages; folders remain limited to direct children |
| `--max-depth` | `10` | Maximum page recursion depth |
| `--type` | `pages` | Content type to list: `pages`, `folders`, or `all` (folders are Confluence Cloud only) |
| `--format` | `list` | Human-readable output format: `list` or `tree` |
| `--json` | false | Emit structured JSON |
| `--show-id` | false | Show child IDs |
| `--show-url` | false | Show available child URLs |

```sh
confluence children 123456789
confluence children 123456789 --recursive --format json
confluence children 123456789 --recursive --json
confluence children 123456789 --recursive --format tree --show-id
confluence children 123456789 --type all
```

The default `pages` mode preserves the existing output. In `folders` and `all` modes, list output tags every item as `[page]` or `[folder]`, and tree output uses distinct page and folder icons. On Server/Data Center, folder modes warn instead of failing; `folders` produces an empty result, while `all` still lists pages.

---

### `create <title> <spaceKey>`
Expand Down Expand Up @@ -779,7 +786,7 @@ confluence export 123456789 --format markdown --dest ./local-docs
### Process children as JSON

```sh
confluence children 123456789 --recursive --format json | jq '.[].id'
confluence children 123456789 --recursive --json | jq '.children[].id'
```

### Search and process results
Expand All @@ -794,7 +801,7 @@ confluence search --cql 'siteSearch ~ "release notes" and space = "MYSPACE"' --l

- **Always use `--yes`** on destructive commands (`delete`, `comment-delete`, `attachment-delete`) to avoid interactive prompts blocking the agent.
- **Prefer `--format markdown`** when creating or updating content from agent-generated text — it's the most natural format and the API converts it automatically.
- **Use `--format json`** on `children` and `comments` for machine-parseable output.
- **Use `--json`** on `children` and `comments` for machine-parseable output.
- **ANSI color codes**: stdout may contain ANSI escape sequences. Pipe through `| cat` or use `NO_COLOR=1` if your downstream tool doesn't handle them.
- **Page ID vs URL**: when you have a Confluence URL, extract `?pageId=<number>` and pass the number. Do not pass pretty/display URLs — they are not supported.
- **Cross-space moves**: `confluence move` only works within the same space. Moving across spaces is not supported.
Expand Down
Loading