Skip to content
Open
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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
- 🛠️ **Edit workflow** - Export page content for editing and re-import
- 🔀 **Profiles** - Manage multiple Confluence instances with named configuration profiles
- 🔒 **Read-only mode** - Profile-level write protection for safe AI agent usage
- 🌐 **Raw API requests** - Make arbitrary authenticated requests to any Confluence endpoint (like `gh api`)
- 🔄 **Format conversion** - Convert between Markdown, HTML, Storage, and text formats locally (no server required)
- 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup

Expand Down Expand Up @@ -529,6 +530,47 @@ When read-only mode is active, any write command (`create`, `create-child`, `upd

`confluence profile list` shows a `[read-only]` badge next to protected profiles.

### Raw API Requests

Make arbitrary authenticated requests to any Confluence REST endpoint, modeled after `gh api`. The CLI handles authentication so you don't need to manage tokens manually.

```bash
# List labels on a page
confluence api /rest/api/content/123456789/label

# Add a label to a page
confluence api /rest/api/content/123456789/label --input - <<< '[{"name":"reviewed"}]'

# Remove a label from a page
confluence api /rest/api/content/123456789/label/reviewed -X DELETE

# View page restrictions
confluence api /rest/api/content/123456789/restriction

# List groups
confluence api /rest/api/group

# Check long-running task status
confluence api /rest/api/longtask/123

# Query the v2 API (absolute path bypasses the configured API base)
confluence api /wiki/api/v2/pages -f spaceKey=DEV -f limit=10 -X GET

# Filter response with jq
confluence api /rest/api/group --jq '.results[].name'

# Include response status and headers
confluence api /rest/api/audit -i

# Read request body from a file
confluence api /rest/api/content/123456789/restriction --input ./restrictions.json

# Suppress output (useful in scripts that only care about exit code)
confluence api /rest/api/content/123456789/label --input - --silent <<< '[{"name":"approved"}]'
```

Read-only profiles block write methods (`POST`, `PUT`, `PATCH`, `DELETE`) while allowing `GET` and `HEAD`.

### View Usage Statistics
```bash
confluence stats
Expand Down Expand Up @@ -567,6 +609,7 @@ confluence stats
| `profile use <name>` | Set the active configuration profile | |
| `profile add <name>` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only` |
| `profile remove <name>` | Remove a configuration profile | |
| `api <endpoint>` | Make an authenticated API request | `-X, --method <method>`, `-f, --field <key=value>`, `-H, --header <key:value>`, `--input <file>`, `--jq <expression>`, `-i, --include`, `--silent` |
| `convert` | Convert between content formats locally (no server required) | `--input-file <path>`, `--output-file <path>`, `--input-format <markdown\|storage\|html>`, `--output-format <markdown\|storage\|html\|text>` |
| `stats` | View your usage statistics | |

Expand Down Expand Up @@ -611,6 +654,9 @@ confluence profile list
confluence profile use staging
confluence --profile staging spaces

# List labels on a page via the raw API
confluence api /rest/api/content/123456789/label --jq '.[].name'

# Convert markdown to Confluence storage format (no server required)
confluence convert --input-file doc.md --input-format markdown --output-format storage

Expand Down
135 changes: 135 additions & 0 deletions bin/confluence.js
Original file line number Diff line number Diff line change
Expand Up @@ -1952,6 +1952,141 @@ profileCmd
}
});

// API command (arbitrary authenticated requests, modeled after gh api)
program
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture nit. PR #186 split this file into per-domain modules under bin/commands/. To stay consistent with that direction, would you mind moving these ~135 lines into a new bin/commands/api.js and wiring it up the same way the other domain commands are? Right now this is the only new command added directly to bin/confluence.js since the refactor.

.command('api <endpoint>')
.description('Make an authenticated API request (like gh api)')
.option('-X, --method <method>', 'HTTP method (default: GET, auto-POST when body provided)')
.option('-f, --field <key=value>', 'Add a request field (repeatable)', (v, a) => { a.push(v); return a; }, [])
.option('-H, --header <key:value>', 'Add a request header (repeatable)', (v, a) => { a.push(v); return a; }, [])
.option('--input <file>', 'Read body from file (use - for stdin)')
.option('--jq <expression>', 'Filter response with jq')
.option('-i, --include', 'Include response status and headers')
.option('--silent', 'Suppress all output')
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the description says "Suppress all output", but the read-only block and the catch handler still write to stderr. Either tighten the implementation (--silent ⇒ also swallow stderr on errors) or relax the doc to "Suppress success output." Most CLIs go with the latter — script consumers usually still want to see errors.

.action(async (endpoint, options) => {
const analytics = new Analytics();
try {
const config = getConfig(getProfileName());
const client = new ConfluenceClient(config);

// Parse fields
const fields = {};
for (const raw of options.field) {
const idx = raw.indexOf('=');
if (idx === -1) {
console.error(chalk.red(`Error: Invalid field "${raw}". Must be key=value.`));
process.exit(1);
}
fields[raw.slice(0, idx)] = raw.slice(idx + 1);
}

// Parse headers
const extraHeaders = {};
for (const raw of options.header) {
const idx = raw.indexOf(':');
if (idx === -1) {
console.error(chalk.red(`Error: Invalid header "${raw}". Must be key:value.`));
process.exit(1);
}
extraHeaders[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
}

// Determine method
const hasFields = Object.keys(fields).length > 0;
let body = undefined;
if (options.input) {
const fs = require('fs');
let raw;
if (options.input === '-') {
raw = fs.readFileSync(process.stdin.fd, 'utf-8');
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #184 (fix(convert): read stdin as a stream and guard against TTY-only invocations) hit this exact pattern in the convert command — fs.readFileSync(process.stdin.fd, ...) can hang on a TTY and has issues with large inputs. Could you reuse the stream-based stdin helper from that fix to stay consistent?

} else {
raw = fs.readFileSync(options.input, 'utf-8');
}
try {
body = JSON.parse(raw);
} catch {
body = raw;
}
}

const hasBody = hasFields || body !== undefined;
const method = (options.method || (hasBody ? 'POST' : 'GET')).toUpperCase();

// Read-only enforcement
const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (config.readOnly && WRITE_METHODS.includes(method)) {
console.error(chalk.red('Error: This profile is in read-only mode. Write operations are not allowed.'));
console.error(chalk.yellow('Tip: Use "confluence profile add <name>" without --read-only, or set readOnly to false in config.'));
process.exit(1);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

analytics.track('api', false) is missing here, and on the field/header parse error paths above (lines 1978, 1989). The bottom catch tracks failures, but these early process.exit(1) branches don't, so the metric will under-count failures.

}

// Build request options
const reqOpts = { headers: extraHeaders };
if (method === 'GET' || method === 'HEAD') {
if (hasFields) {
reqOpts.params = fields;
}
} else {
if (body !== undefined && hasFields) {
reqOpts.data = typeof body === 'object' && body !== null ? { ...body, ...fields } : fields;
} else if (hasFields) {
reqOpts.data = fields;
} else if (body !== undefined) {
reqOpts.data = body;
}
}

const result = await client.rawRequest(method, endpoint, reqOpts);

if (options.silent) {
analytics.track('api', true);
return;
}

let output = '';
if (options.include) {
output += `HTTP ${result.status}\n`;
for (const [key, value] of Object.entries(result.headers)) {
output += `${key}: ${value}\n`;
}
output += '\n';
}

const bodyStr = typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);

if (options.jq) {
const { execSync } = require('child_process');
try {
const filtered = execSync(`jq ${JSON.stringify(options.jq)}`, {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell quoting / minor injection surface.

JSON.stringify wraps the expression in double quotes, but the shell still interprets $, backticks, and \ inside double quotes. For example --jq '.[] | select(.name == "$HOME")' will have $HOME expanded by the shell before jq ever sees it. The blast radius is the user's own machine, but it's also trivial to sidestep:

const { spawnSync } = require('child_process');
const r = spawnSync('jq', [options.jq], {
  input: typeof result.data === 'string' ? result.data : JSON.stringify(result.data),
  encoding: 'utf-8',
});

Args go straight to the binary, no shell involved.

input: typeof result.data === 'string' ? result.data : JSON.stringify(result.data),
encoding: 'utf-8',
});
output += filtered;
} catch {
process.exit(2);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When jq isn't installed (ENOENT) or the expression is invalid, this swallows the cause and exits with code 2 with no message. A one-line console.error('jq failed:', err.message) (or specifically detecting ENOENT and saying "jq is not installed") would save users a debugging round-trip.

}
} else {
output += bodyStr;
}

process.stdout.write(output);
if (!output.endsWith('\n')) {
process.stdout.write('\n');
}
analytics.track('api', true);
} catch (error) {
analytics.track('api', false);
if (error.response) {
const errBody = error.response.data;
const errStr = typeof errBody === 'string' ? errBody : JSON.stringify(errBody, null, 2);
process.stderr.write(errStr + '\n');
} else {
console.error(chalk.red('Error:'), error.message);
}
process.exit(1);
}
});

// Convert command (local format conversion, no server connection required)
const VALID_INPUT_FORMATS = ['markdown', 'storage', 'html'];
const VALID_OUTPUT_FORMATS = ['markdown', 'storage', 'html', 'text'];
Expand Down
27 changes: 27 additions & 0 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2102,6 +2102,33 @@ class ConfluenceClient {
}
return parsed;
}

async rawRequest(method, endpoint, options = {}) {
const { headers = {}, params, data } = options;

let url;
if (/^https?:\/\//i.test(endpoint)) {
url = endpoint;
} else if (endpoint.startsWith('/')) {
url = `${this.protocol}://${this.domain}${endpoint}`;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloud-vs-Server path mismatch — probably the most important comment.

On Confluence Cloud, apiPath is typically /wiki/rest/api. By treating any /-prefixed endpoint as bypassing apiPath, the README's marquee example

confluence api /rest/api/content/123456789/label

resolves to https://{domain}/rest/api/content/... (404 on Cloud) instead of https://{domain}/wiki/rest/api/content/.... So the documented examples only work on Server/DC, while Cloud users have to know to write /wiki/rest/api/....

A couple of options:

  1. Document explicitly that absolute paths bypass apiPath, and update the examples to use /wiki/rest/api/... for Cloud.
  2. Introduce a third tier: treat rest/api/... (no leading slash) as "relative to apiPath" (which axios already does via baseURL), /... as "relative to host but auto-prepend apiPath when it looks like a REST endpoint", and https://... as absolute.

Option 1 is cheaper and matches the gh api mental model. Either is fine — but the current behavior + current README will silently break for Cloud users.

} else {
url = endpoint;
}

const response = await this.client.request({
method: method.toUpperCase(),
url,
headers,
params,
data,
});

return {
status: response.status,
headers: response.headers,
data: response.data,
};
}
}

ConfluenceClient.createLocalConverter = function () {
Expand Down
Loading