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
44 changes: 42 additions & 2 deletions .claude/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
name: confluence
description: Use confluence-cli to read, search, create, update, move, and delete Confluence pages and attachments from the terminal.
description: Use confluence-cli to read, search, create, update, move, delete, and convert Confluence pages and attachments from the terminal.
---

# confluence-cli Skill

A CLI tool for Atlassian Confluence. Lets you read, search, create, update, move, and delete pages and attachments from the terminal or from an agent.
A CLI tool for Atlassian Confluence. Lets you read, search, create, update, move, delete, and convert pages and attachments from the terminal or from an agent.

## Installation

Expand Down Expand Up @@ -627,6 +627,36 @@ confluence stats

---

### `convert`

Convert between content formats locally without a Confluence server connection.

```sh
confluence convert [--input-file <path>] [--output-file <path>] --input-format <format> --output-format <format>
```

| Option | Default | Description |
|---|---|---|
| `--input-file`, `-i` | stdin | Input file path |
| `--output-file`, `-o` | stdout | Output file path |
| `--input-format` | — | Input format: `markdown`, `storage`, `html` (required) |
| `--output-format` | — | Output format: `markdown`, `storage`, `html`, `text` (required) |

Supported conversions: markdown→storage, markdown→html, markdown→text, html→storage, html→text, html→markdown, storage→markdown, storage→html, storage→text.

```sh
# Markdown to Confluence storage format
confluence convert -i doc.md -o doc.xml --input-format markdown --output-format storage

# Pipe via stdin/stdout
echo "# Hello" | confluence convert --input-format markdown --output-format storage

# Storage format back to markdown
confluence convert -i page.xml --input-format storage --output-format markdown
```

---

### `install-skill`

Copy the Claude Code skill documentation into your project's `.claude/skills/` directory so Claude Code can learn confluence-cli automatically.
Expand Down Expand Up @@ -682,6 +712,16 @@ confluence copy-tree 123456789 987654321 --dry-run
confluence copy-tree 123456789 987654321 "Backup Copy"
```

### Offline format conversion

```sh
# Convert markdown to Confluence storage format (no server needed)
confluence convert -i doc.md -o doc.xml --input-format markdown --output-format storage

# Convert storage format to markdown for editing
confluence convert -i page.xml -o page.md --input-format storage --output-format markdown
```

### Export a page for local editing

```sh
Expand Down
11 changes: 11 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
- 🔄 **Format conversion** - Convert between Markdown, HTML, Storage, and text formats locally (no server required)
- 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup

## Installation
Expand Down Expand Up @@ -555,6 +556,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 | |
| `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 | |

**Global option:** `--profile <name>` — Use a specific profile for any command (overrides `CONFLUENCE_PROFILE` env var and active profile).
Expand Down Expand Up @@ -597,6 +599,15 @@ confluence stats
confluence profile list
confluence profile use staging
confluence --profile staging spaces

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

# Pipe conversion via stdin/stdout
echo "# Hello" | confluence convert --input-format markdown --output-format storage

# Convert storage format back to markdown
confluence convert -i page.xml -o page.md --input-format storage --output-format markdown
```

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

// Convert command (local format conversion, no server connection required)
const VALID_INPUT_FORMATS = ['markdown', 'storage', 'html'];
const VALID_OUTPUT_FORMATS = ['markdown', 'storage', 'html', 'text'];

program
.command('convert')
.description('Convert between content formats locally (no server connection required)')
.option('-i, --input-file <file>', 'Input file path (reads from stdin if omitted)')
.option('-o, --output-file <file>', 'Output file path (writes to stdout if omitted)')
.option('--input-format <format>', `Input format (${VALID_INPUT_FORMATS.join(', ')})`)
.option('--output-format <format>', `Output format (${VALID_OUTPUT_FORMATS.join(', ')})`)
.action(async (options) => {
const analytics = new Analytics();
try {
if (!options.inputFormat) {
console.error(chalk.red('Error: --input-format is required.'));
process.exit(1);
}
if (!options.outputFormat) {
console.error(chalk.red('Error: --output-format is required.'));
process.exit(1);
}
if (!VALID_INPUT_FORMATS.includes(options.inputFormat)) {
console.error(chalk.red(`Error: Invalid input format "${options.inputFormat}". Valid: ${VALID_INPUT_FORMATS.join(', ')}`));
process.exit(1);
}
if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
console.error(chalk.red(`Error: Invalid output format "${options.outputFormat}". Valid: ${VALID_OUTPUT_FORMATS.join(', ')}`));
process.exit(1);
}
if (options.inputFormat === options.outputFormat) {
console.error(chalk.red('Error: Input and output formats must be different.'));
process.exit(1);
}

const fs = require('fs');
let input;
if (options.inputFile) {
input = fs.readFileSync(options.inputFile, 'utf-8');
} else {
input = fs.readFileSync(process.stdin.fd, 'utf-8');
}

const converter = ConfluenceClient.createLocalConverter();
let output;

if (options.inputFormat === 'markdown' && options.outputFormat === 'storage') {
output = converter.markdownToStorage(input);
} else if (options.inputFormat === 'markdown' && options.outputFormat === 'html') {
output = converter.markdown.render(input);
} else if (options.inputFormat === 'html' && options.outputFormat === 'storage') {
output = converter.htmlToConfluenceStorage(input);
} else if (options.inputFormat === 'storage' && options.outputFormat === 'markdown') {
output = converter.storageToMarkdown(input);
} else if (options.inputFormat === 'storage' && options.outputFormat === 'text') {
const { convert: htmlToText } = require('html-to-text');
output = htmlToText(input, { wordwrap: 130 });
} else if (options.inputFormat === 'storage' && options.outputFormat === 'html') {
output = input; // storage format is already HTML-based
} else if (options.inputFormat === 'html' && options.outputFormat === 'text') {
const { convert: htmlToText } = require('html-to-text');
output = htmlToText(input, { wordwrap: 130 });
} else if (options.inputFormat === 'html' && options.outputFormat === 'markdown') {
output = converter.storageToMarkdown(input);
} else if (options.inputFormat === 'markdown' && options.outputFormat === 'text') {
const html = converter.markdown.render(input);
const { convert: htmlToText } = require('html-to-text');
output = htmlToText(html, { wordwrap: 130 });
} else {
console.error(chalk.red(`Error: Conversion from "${options.inputFormat}" to "${options.outputFormat}" is not supported.`));
process.exit(1);
}

if (options.outputFile) {
fs.writeFileSync(options.outputFile, output, 'utf-8');
console.error(chalk.green(`Converted ${options.inputFormat} → ${options.outputFormat}: ${options.outputFile}`));
} else {
process.stdout.write(output);
}
analytics.track('convert', true);
} catch (error) {
analytics.track('convert', false);
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});

// Exported for testing
module.exports = {
program,
Expand Down
7 changes: 7 additions & 0 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2104,5 +2104,12 @@ class ConfluenceClient {
}
}

ConfluenceClient.createLocalConverter = function () {
const instance = Object.create(ConfluenceClient.prototype);
instance.markdown = new MarkdownIt();
instance.setupConfluenceMarkdownExtensions();
return instance;
};

module.exports = ConfluenceClient;
module.exports.NAMED_ENTITIES = NAMED_ENTITIES;
112 changes: 112 additions & 0 deletions tests/convert.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ConfluenceClient = require('../lib/confluence-client');

const CLI = path.resolve(__dirname, '../bin/index.js');

function run(args, input) {
return execFileSync(process.execPath, [CLI, ...args], {
encoding: 'utf8',
input,
timeout: 10000,
});
}

describe('createLocalConverter', () => {
test('creates instance without server config', () => {
const converter = ConfluenceClient.createLocalConverter();
expect(converter).toBeInstanceOf(ConfluenceClient);
expect(converter.markdown).toBeDefined();
});

test('converts markdown to storage format', () => {
const converter = ConfluenceClient.createLocalConverter();
const result = converter.markdownToStorage('# Hello');
expect(result).toContain('<h1>');
expect(result).toContain('Hello');
});

test('converts storage to markdown', () => {
const converter = ConfluenceClient.createLocalConverter();
const result = converter.storageToMarkdown('<h1>Hello</h1><p>World</p>');
expect(result).toContain('# Hello');
expect(result).toContain('World');
});
});

describe('convert command', () => {
let tmpDir;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-convert-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function writeInput(name, content) {
const p = path.join(tmpDir, name);
fs.writeFileSync(p, content);
return p;
}

test('markdown to storage via stdout', () => {
const inputFile = writeInput('input.md', '# Hello\n\nWorld\n');
const output = run(['convert', '--input-file', inputFile, '--input-format', 'markdown', '--output-format', 'storage']);
expect(output).toContain('<h1>');
expect(output).toContain('Hello');
expect(output).toContain('World');
});

test('markdown to storage via files', () => {
const inputFile = writeInput('input.md', '# Test\n\nParagraph\n');
const outputFile = path.join(tmpDir, 'output.xml');
run(['convert', '--input-file', inputFile, '--output-file', outputFile, '--input-format', 'markdown', '--output-format', 'storage']);
const output = fs.readFileSync(outputFile, 'utf-8');
expect(output).toContain('<h1>');
expect(output).toContain('Test');
});

test('storage to markdown', () => {
const inputFile = writeInput('input.xml', '<h1>Title</h1><p>Content</p>');
const output = run(['convert', '--input-file', inputFile, '--input-format', 'storage', '--output-format', 'markdown']);
expect(output).toContain('# Title');
expect(output).toContain('Content');
});

test('markdown to html', () => {
const inputFile = writeInput('input.md', '**bold**');
const output = run(['convert', '--input-file', inputFile, '--input-format', 'markdown', '--output-format', 'html']);
expect(output).toContain('<strong>bold</strong>');
});

test('storage to text', () => {
const inputFile = writeInput('input.xml', '<h1>Title</h1><p>Content</p>');
const output = run(['convert', '--input-file', inputFile, '--input-format', 'storage', '--output-format', 'text']);
expect(output.toLowerCase()).toContain('title');
expect(output).toContain('Content');
});

test('errors on missing --input-format', () => {
const inputFile = writeInput('input.md', '');
expect(() => run(['convert', '--input-file', inputFile, '--output-format', 'storage'])).toThrow();
});

test('errors on missing --output-format', () => {
const inputFile = writeInput('input.md', '');
expect(() => run(['convert', '--input-file', inputFile, '--input-format', 'markdown'])).toThrow();
});

test('errors on same input and output format', () => {
const inputFile = writeInput('input.md', '');
expect(() => run(['convert', '--input-file', inputFile, '--input-format', 'markdown', '--output-format', 'markdown'])).toThrow();
});

test('errors on invalid format', () => {
const inputFile = writeInput('input.md', '');
expect(() => run(['convert', '--input-file', inputFile, '--input-format', 'xml', '--output-format', 'storage'])).toThrow();
});
});
Loading