confluence-cli 1.25.1 → 1.27.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.
@@ -25,6 +25,18 @@ confluence --version # verify install
25
25
  | `CONFLUENCE_AUTH_TYPE` | `basic` or `bearer` | `basic` |
26
26
  | `CONFLUENCE_EMAIL` | Email address (basic auth only) | `user@company.com` |
27
27
  | `CONFLUENCE_API_TOKEN` | API token or personal access token | `ATATT3x...` |
28
+ | `CONFLUENCE_PROFILE` | Named profile to use (optional) | `staging` |
29
+ | `CONFLUENCE_READ_ONLY` | Block all write operations when `true` | `true` |
30
+
31
+ **Global `--profile` flag (use a named profile for any command):**
32
+
33
+ ```sh
34
+ confluence --profile <name> <command>
35
+ ```
36
+
37
+ Config resolution works in two stages:
38
+ - **Direct env config:** If both `CONFLUENCE_DOMAIN` and `CONFLUENCE_API_TOKEN` are set, they are used directly and the config file / profiles are not consulted.
39
+ - **Profile-based config:** Otherwise, a profile is selected in this order: `--profile` flag > `CONFLUENCE_PROFILE` env > `activeProfile` in config > `default`.
28
40
 
29
41
  **Non-interactive init (good for CI/CD scripts):**
30
42
 
@@ -52,6 +64,27 @@ export CONFLUENCE_EMAIL="user@company.com"
52
64
  export CONFLUENCE_API_TOKEN="your-scoped-token"
53
65
  ```
54
66
 
67
+ **Read-only mode (recommended for AI agents):**
68
+
69
+ 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.
70
+
71
+ ```sh
72
+ # Via profile flag
73
+ confluence profile add agent --domain "company.atlassian.net" --token "xxx" --read-only
74
+
75
+ # Via environment variable (overrides config file)
76
+ export CONFLUENCE_READ_ONLY=true
77
+ ```
78
+
79
+ When read-only mode is active, any write command exits with an error:
80
+ ```
81
+ Error: This profile is in read-only mode. Write operations are not allowed.
82
+ ```
83
+
84
+ `profile list` shows read-only profiles with a `[read-only]` badge.
85
+
86
+ ---
87
+
55
88
  ## Page ID Resolution
56
89
 
57
90
  Most commands accept `<pageId>` — a numeric ID or any of the supported URL formats below.
@@ -91,10 +124,14 @@ confluence read "https://company.atlassian.net/wiki/spaces/MYSPACE/pages/1234567
91
124
  Initialize configuration. Saves credentials to `~/.confluence-cli/config.json`.
92
125
 
93
126
  ```sh
94
- confluence init [--domain <domain>] [--api-path <path>] [--auth-type basic|bearer] [--email <email>] [--token <token>]
127
+ confluence init [--domain <domain>] [--api-path <path>] [--auth-type basic|bearer] [--email <email>] [--token <token>] [--read-only]
95
128
  ```
96
129
 
97
- All flags are optional; omitting any flag triggers an interactive prompt for that field. Provide all flags to run fully non-interactive.
130
+ All flags are optional; omitting any flag triggers an interactive prompt for that field. Provide all flags to run fully non-interactive. Use the global `--profile` flag to save to a named profile:
131
+
132
+ ```sh
133
+ confluence --profile staging init --domain "staging.example.com" --auth-type bearer --token "your-token"
134
+ ```
98
135
 
99
136
  ---
100
137
 
@@ -520,6 +557,60 @@ confluence copy-tree 123456789 987654321 --exclude "Draft*,Archive*"
520
557
 
521
558
  ---
522
559
 
560
+ ### `profile list`
561
+
562
+ List all configuration profiles with the active profile marked.
563
+
564
+ ```sh
565
+ confluence profile list
566
+ ```
567
+
568
+ ---
569
+
570
+ ### `profile use <name>`
571
+
572
+ Switch the active configuration profile.
573
+
574
+ ```sh
575
+ confluence profile use <name>
576
+ ```
577
+
578
+ ```sh
579
+ confluence profile use staging
580
+ ```
581
+
582
+ ---
583
+
584
+ ### `profile add <name>`
585
+
586
+ Add a new configuration profile. Supports the same options as `init` (interactive, non-interactive, or hybrid).
587
+
588
+ ```sh
589
+ confluence profile add <name> [--domain <domain>] [--api-path <path>] [--auth-type basic|bearer] [--email <email>] [--token <token>] [--protocol http|https] [--read-only]
590
+ ```
591
+
592
+ Profile names may contain letters, numbers, hyphens, and underscores only.
593
+
594
+ ```sh
595
+ confluence profile add staging --domain "staging.example.com" --auth-type bearer --token "xyz"
596
+ ```
597
+
598
+ ---
599
+
600
+ ### `profile remove <name>`
601
+
602
+ Remove a configuration profile (prompts for confirmation). Cannot remove the only remaining profile.
603
+
604
+ ```sh
605
+ confluence profile remove <name>
606
+ ```
607
+
608
+ ```sh
609
+ confluence profile remove staging
610
+ ```
611
+
612
+ ---
613
+
523
614
  ### `stats`
524
615
 
525
616
  Show local usage statistics.
@@ -614,6 +705,8 @@ confluence search "release notes" --limit 20
614
705
  - **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.
615
706
  - **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.
616
707
  - **Cross-space moves**: `confluence move` only works within the same space. Moving across spaces is not supported.
708
+ - **Multiple instances**: Use `--profile <name>` or `CONFLUENCE_PROFILE` env var to target different Confluence instances without reconfiguring.
709
+ - **Read-only mode**: Set `CONFLUENCE_READ_ONLY=true` or use `--read-only` when creating profiles to prevent accidental writes. This is enforced at the CLI level — all write commands will be blocked.
617
710
 
618
711
  ## Error Patterns
619
712
 
@@ -624,3 +717,6 @@ confluence search "release notes" --limit 20
624
717
  | 400 on inline comment creation | Editor metadata required | Use `--location footer` or reply to existing inline comment with `--parent` |
625
718
  | `File not found: <path>` | `--file` path doesn't exist | Check the path before calling the command |
626
719
  | `At least one of --title, --file, or --content must be provided` | `update` called with no content options | Provide at least one of the required options |
720
+ | `Profile "<name>" not found!` | Specified profile doesn't exist | Run `confluence profile list` to see available profiles |
721
+ | `Cannot delete the only remaining profile.` | Tried to remove the last profile | Add another profile before removing |
722
+ | `This profile is in read-only mode` | Write command used with a read-only profile | Use a writable profile or remove `readOnly` from config |
package/README.md CHANGED
@@ -16,6 +16,8 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
16
16
  - 💬 **Comments** - List, create, and delete page comments (footer or inline)
17
17
  - 📦 **Export** - Save a page and its attachments to a local folder
18
18
  - 🛠️ **Edit workflow** - Export page content for editing and re-import
19
+ - 🔀 **Profiles** - Manage multiple Confluence instances with named configuration profiles
20
+ - 🔒 **Read-only mode** - Profile-level write protection for safe AI agent usage
19
21
  - 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup
20
22
 
21
23
  ## Installation
@@ -115,6 +117,15 @@ confluence init \
115
117
  --token "your-scoped-token"
116
118
  ```
117
119
 
120
+ **Named profile** (save to a specific profile):
121
+ ```bash
122
+ confluence --profile staging init \
123
+ --domain "staging.example.com" \
124
+ --api-path "/rest/api" \
125
+ --auth-type "bearer" \
126
+ --token "your-personal-access-token"
127
+ ```
128
+
118
129
  **Hybrid mode** (some fields provided, rest via prompts):
119
130
  ```bash
120
131
  # Domain and token provided, will prompt for auth method and email
@@ -130,6 +141,7 @@ confluence init --email "user@example.com" --token "your-api-token"
130
141
  - `-a, --auth-type <type>` - Authentication type: `basic` or `bearer`
131
142
  - `-e, --email <email>` - Email or username for basic authentication
132
143
  - `-t, --token <token>` - API token or password
144
+ - `--read-only` - Enable read-only mode (blocks all write operations)
133
145
 
134
146
  ⚠️ **Security note:** While flags work, storing tokens in shell history is risky. Prefer environment variables (Option 3) for production environments.
135
147
 
@@ -141,6 +153,8 @@ export CONFLUENCE_EMAIL="your.email@example.com" # required for basic auth (ali
141
153
  export CONFLUENCE_API_PATH="/wiki/rest/api" # Cloud default; use /rest/api for Server/DC
142
154
  # Optional: set to 'bearer' for self-hosted/Data Center instances
143
155
  export CONFLUENCE_AUTH_TYPE="basic"
156
+ # Optional: select a named profile (overridden by --profile flag)
157
+ export CONFLUENCE_PROFILE="default"
144
158
  ```
145
159
 
146
160
  **Scoped API token** (recommended for agents):
@@ -154,6 +168,12 @@ export CONFLUENCE_API_TOKEN="your-scoped-token"
154
168
 
155
169
  `CONFLUENCE_API_PATH` defaults to `/wiki/rest/api` for Atlassian Cloud domains and `/rest/api` otherwise. Override it when your site lives under a custom reverse proxy or on-premises path. `CONFLUENCE_AUTH_TYPE` defaults to `basic` when an email is present and falls back to `bearer` otherwise.
156
170
 
171
+ **Read-only mode** (recommended for AI agents):
172
+ ```bash
173
+ export CONFLUENCE_READ_ONLY=true
174
+ ```
175
+ When set, all write operations (`create`, `update`, `delete`, etc.) are blocked at the CLI level. The environment variable overrides the profile's `readOnly` setting.
176
+
157
177
  ### Getting Your API Token
158
178
 
159
179
  **Atlassian Cloud:**
@@ -434,6 +454,52 @@ vim ./page-to-edit.xml
434
454
  confluence update 123456789 --file ./page-to-edit.xml --format storage
435
455
  ```
436
456
 
457
+ ### Profile Management
458
+ ```bash
459
+ # List all profiles and see which is active
460
+ confluence profile list
461
+
462
+ # Switch the active profile
463
+ confluence profile use staging
464
+
465
+ # Add a new profile interactively
466
+ confluence profile add staging
467
+
468
+ # Add a new profile non-interactively
469
+ confluence profile add staging --domain "staging.example.com" --auth-type bearer --token "xyz"
470
+
471
+ # Add a read-only profile (blocks all write operations)
472
+ confluence profile add agent --domain "company.atlassian.net" --auth-type basic --email "bot@example.com" --token "xyz" --read-only
473
+
474
+ # Remove a profile
475
+ confluence profile remove staging
476
+
477
+ # Use a specific profile for a single command
478
+ confluence --profile staging spaces
479
+ ```
480
+
481
+ ### Read-Only Mode
482
+
483
+ Read-only mode blocks all write operations at the CLI level, making it safe to hand the tool to AI agents (Claude Code, Copilot, etc.) without risking accidental edits.
484
+
485
+ **Enable via profile:**
486
+ ```bash
487
+ # During init
488
+ confluence init --read-only
489
+
490
+ # When adding a profile
491
+ confluence profile add agent --domain "company.atlassian.net" --token "xyz" --read-only
492
+ ```
493
+
494
+ **Enable via environment variable:**
495
+ ```bash
496
+ export CONFLUENCE_READ_ONLY=true # overrides profile setting
497
+ ```
498
+
499
+ When read-only mode is active, any write command (`create`, `create-child`, `update`, `delete`, `move`, `edit`, `comment`, `attachment-upload`, `attachment-delete`, `property-set`, `property-delete`, `comment-delete`, `copy-tree`) exits with code 1 and prints an error message.
500
+
501
+ `confluence profile list` shows a `[read-only]` badge next to protected profiles.
502
+
437
503
  ### View Usage Statistics
438
504
  ```bash
439
505
  confluence stats
@@ -443,7 +509,7 @@ confluence stats
443
509
 
444
510
  | Command | Description | Options |
445
511
  |---|---|---|
446
- | `init` | Initialize CLI configuration | |
512
+ | `init` | Initialize CLI configuration | `--read-only` |
447
513
  | `read <pageId_or_url>` | Read page content | `--format <html\|text\|markdown>` |
448
514
  | `info <pageId_or_url>` | Get page information | |
449
515
  | `search <query>` | Search for pages | `--limit <number>` |
@@ -468,8 +534,14 @@ confluence stats
468
534
  | `property-set <pageId_or_url> <key>` | Set a content property (create or update) | `--value <json>`, `--file <path>`, `--format <text\|json>` |
469
535
  | `property-delete <pageId_or_url> <key>` | Delete a content property by key | `--yes` |
470
536
  | `export <pageId_or_url>` | Export a page to a directory with its attachments | `--format <html\|text\|markdown>`, `--dest <directory>`, `--file <filename>`, `--attachments-dir <name>`, `--pattern <glob>`, `--referenced-only`, `--skip-attachments` |
537
+ | `profile list` | List all configuration profiles | |
538
+ | `profile use <name>` | Set the active configuration profile | |
539
+ | `profile add <name>` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only` |
540
+ | `profile remove <name>` | Remove a configuration profile | |
471
541
  | `stats` | View your usage statistics | |
472
542
 
543
+ **Global option:** `--profile <name>` — Use a specific profile for any command (overrides `CONFLUENCE_PROFILE` env var and active profile).
544
+
473
545
  ## Examples
474
546
 
475
547
  ```bash
@@ -503,6 +575,11 @@ confluence attachment-delete 123456789 998877 --yes
503
575
 
504
576
  # View usage statistics
505
577
  confluence stats
578
+
579
+ # Profile management
580
+ confluence profile list
581
+ confluence profile use staging
582
+ confluence --profile staging spaces
506
583
  ```
507
584
 
508
585
  ## Development
package/bin/confluence.js CHANGED
@@ -4,7 +4,7 @@ const { program } = require('commander');
4
4
  const chalk = require('chalk');
5
5
  const inquirer = require('inquirer');
6
6
  const ConfluenceClient = require('../lib/confluence-client');
7
- const { getConfig, initConfig } = require('../lib/config');
7
+ const { getConfig, initConfig, listProfiles, setActiveProfile, deleteProfile, isValidProfileName } = require('../lib/config');
8
8
  const Analytics = require('../lib/analytics');
9
9
  const pkg = require('../package.json');
10
10
 
@@ -13,10 +13,24 @@ function buildPageUrl(config, path) {
13
13
  return `${protocol}://${config.domain}${path}`;
14
14
  }
15
15
 
16
+ function assertWritable(config) {
17
+ if (config.readOnly) {
18
+ console.error(chalk.red('Error: This profile is in read-only mode. Write operations are not allowed.'));
19
+ console.error(chalk.yellow('Tip: Use "confluence profile add <name>" without --read-only, or set readOnly to false in config.'));
20
+ process.exit(1);
21
+ }
22
+ }
23
+
16
24
  program
17
25
  .name('confluence')
18
26
  .description('CLI tool for Atlassian Confluence')
19
- .version(pkg.version);
27
+ .version(pkg.version)
28
+ .option('--profile <name>', 'Use a specific configuration profile');
29
+
30
+ // Helper: resolve profile name from global --profile flag
31
+ function getProfileName() {
32
+ return program.opts().profile || undefined;
33
+ }
20
34
 
21
35
  // Init command
22
36
  program
@@ -28,8 +42,10 @@ program
28
42
  .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
29
43
  .option('-e, --email <email>', 'Email or username for basic auth')
30
44
  .option('-t, --token <token>', 'API token')
45
+ .option('--read-only', 'Set profile to read-only mode (blocks write operations)')
31
46
  .action(async (options) => {
32
- await initConfig(options);
47
+ const profile = getProfileName();
48
+ await initConfig({ ...options, profile });
33
49
  });
34
50
 
35
51
  // Read command
@@ -40,7 +56,7 @@ program
40
56
  .action(async (pageId, options) => {
41
57
  const analytics = new Analytics();
42
58
  try {
43
- const client = new ConfluenceClient(getConfig());
59
+ const client = new ConfluenceClient(getConfig(getProfileName()));
44
60
  const content = await client.readPage(pageId, options.format);
45
61
  console.log(content);
46
62
  analytics.track('read', true);
@@ -58,7 +74,7 @@ program
58
74
  .action(async (pageId) => {
59
75
  const analytics = new Analytics();
60
76
  try {
61
- const client = new ConfluenceClient(getConfig());
77
+ const client = new ConfluenceClient(getConfig(getProfileName()));
62
78
  const info = await client.getPageInfo(pageId);
63
79
  console.log(chalk.blue('Page Information:'));
64
80
  console.log(`Title: ${chalk.green(info.title)}`);
@@ -85,7 +101,7 @@ program
85
101
  .action(async (query, options) => {
86
102
  const analytics = new Analytics();
87
103
  try {
88
- const client = new ConfluenceClient(getConfig());
104
+ const client = new ConfluenceClient(getConfig(getProfileName()));
89
105
  const results = await client.search(query, parseInt(options.limit), options.cql);
90
106
 
91
107
  if (results.length === 0) {
@@ -116,7 +132,7 @@ program
116
132
  .action(async () => {
117
133
  const analytics = new Analytics();
118
134
  try {
119
- const config = getConfig();
135
+ const config = getConfig(getProfileName());
120
136
  const client = new ConfluenceClient(config);
121
137
  const spaces = await client.getSpaces();
122
138
 
@@ -200,9 +216,10 @@ program
200
216
  .action(async (title, spaceKey, options) => {
201
217
  const analytics = new Analytics();
202
218
  try {
203
- const config = getConfig();
219
+ const config = getConfig(getProfileName());
220
+ assertWritable(config);
204
221
  const client = new ConfluenceClient(config);
205
-
222
+
206
223
  let content = '';
207
224
 
208
225
  if (options.file) {
@@ -243,9 +260,10 @@ program
243
260
  .action(async (title, parentId, options) => {
244
261
  const analytics = new Analytics();
245
262
  try {
246
- const config = getConfig();
263
+ const config = getConfig(getProfileName());
264
+ assertWritable(config);
247
265
  const client = new ConfluenceClient(config);
248
-
266
+
249
267
  // Get parent page info to get space key
250
268
  const parentInfo = await client.getPageInfo(parentId);
251
269
  const spaceKey = parentInfo.space.key;
@@ -297,9 +315,10 @@ program
297
315
  throw new Error('At least one of --title, --file, or --content must be provided.');
298
316
  }
299
317
 
300
- const config = getConfig();
318
+ const config = getConfig(getProfileName());
319
+ assertWritable(config);
301
320
  const client = new ConfluenceClient(config);
302
-
321
+
303
322
  let content = null; // Use null to indicate no content change
304
323
 
305
324
  if (options.file) {
@@ -336,7 +355,8 @@ program
336
355
  .action(async (pageId, newParentId, options) => {
337
356
  const analytics = new Analytics();
338
357
  try {
339
- const config = getConfig();
358
+ const config = getConfig(getProfileName());
359
+ assertWritable(config);
340
360
  const client = new ConfluenceClient(config);
341
361
  const result = await client.movePage(pageId, newParentId, options.title);
342
362
 
@@ -363,7 +383,8 @@ program
363
383
  .action(async (pageIdOrUrl, options) => {
364
384
  const analytics = new Analytics();
365
385
  try {
366
- const config = getConfig();
386
+ const config = getConfig(getProfileName());
387
+ assertWritable(config);
367
388
  const client = new ConfluenceClient(config);
368
389
  const pageInfo = await client.getPageInfo(pageIdOrUrl);
369
390
 
@@ -406,7 +427,8 @@ program
406
427
  .action(async (pageId, options) => {
407
428
  const analytics = new Analytics();
408
429
  try {
409
- const config = getConfig();
430
+ const config = getConfig(getProfileName());
431
+ assertWritable(config);
410
432
  const client = new ConfluenceClient(config);
411
433
  const pageData = await client.getPageForEdit(pageId);
412
434
 
@@ -443,7 +465,7 @@ program
443
465
  .action(async (title, options) => {
444
466
  const analytics = new Analytics();
445
467
  try {
446
- const config = getConfig();
468
+ const config = getConfig(getProfileName());
447
469
  const client = new ConfluenceClient(config);
448
470
  const pageInfo = await client.findPageByTitle(title, options.space);
449
471
 
@@ -473,7 +495,7 @@ program
473
495
  .action(async (pageId, options) => {
474
496
  const analytics = new Analytics();
475
497
  try {
476
- const config = getConfig();
498
+ const config = getConfig(getProfileName());
477
499
  const client = new ConfluenceClient(config);
478
500
  const maxResults = options.limit ? parseInt(options.limit, 10) : null;
479
501
  const pattern = options.pattern ? options.pattern.trim() : null;
@@ -605,7 +627,8 @@ program
605
627
 
606
628
  const fs = require('fs');
607
629
  const path = require('path');
608
- const config = getConfig();
630
+ const config = getConfig(getProfileName());
631
+ assertWritable(config);
609
632
  const client = new ConfluenceClient(config);
610
633
 
611
634
  const resolvedFiles = files.map((filePath) => ({
@@ -652,7 +675,8 @@ program
652
675
  .action(async (pageId, attachmentId, options) => {
653
676
  const analytics = new Analytics();
654
677
  try {
655
- const config = getConfig();
678
+ const config = getConfig(getProfileName());
679
+ assertWritable(config);
656
680
  const client = new ConfluenceClient(config);
657
681
 
658
682
  if (!options.yes) {
@@ -696,7 +720,7 @@ program
696
720
  .action(async (pageId, options) => {
697
721
  const analytics = new Analytics();
698
722
  try {
699
- const config = getConfig();
723
+ const config = getConfig(getProfileName());
700
724
  const client = new ConfluenceClient(config);
701
725
 
702
726
  const format = (options.format || 'text').toLowerCase();
@@ -766,7 +790,7 @@ program
766
790
  .action(async (pageId, key, options) => {
767
791
  const analytics = new Analytics();
768
792
  try {
769
- const config = getConfig();
793
+ const config = getConfig(getProfileName());
770
794
  const client = new ConfluenceClient(config);
771
795
 
772
796
  const format = (options.format || 'text').toLowerCase();
@@ -802,7 +826,8 @@ program
802
826
  .action(async (pageId, key, options) => {
803
827
  const analytics = new Analytics();
804
828
  try {
805
- const config = getConfig();
829
+ const config = getConfig(getProfileName());
830
+ assertWritable(config);
806
831
  const client = new ConfluenceClient(config);
807
832
 
808
833
  if (!options.value && !options.file) {
@@ -858,7 +883,8 @@ program
858
883
  .action(async (pageId, key, options) => {
859
884
  const analytics = new Analytics();
860
885
  try {
861
- const config = getConfig();
886
+ const config = getConfig(getProfileName());
887
+ assertWritable(config);
862
888
  const client = new ConfluenceClient(config);
863
889
 
864
890
  if (!options.yes) {
@@ -904,7 +930,7 @@ program
904
930
  .action(async (pageId, options) => {
905
931
  const analytics = new Analytics();
906
932
  try {
907
- const config = getConfig();
933
+ const config = getConfig(getProfileName());
908
934
  const client = new ConfluenceClient(config);
909
935
 
910
936
  const format = (options.format || 'text').toLowerCase();
@@ -1059,7 +1085,8 @@ program
1059
1085
  const analytics = new Analytics();
1060
1086
  let location = null;
1061
1087
  try {
1062
- const config = getConfig();
1088
+ const config = getConfig(getProfileName());
1089
+ assertWritable(config);
1063
1090
  const client = new ConfluenceClient(config);
1064
1091
 
1065
1092
  let content = '';
@@ -1173,7 +1200,8 @@ program
1173
1200
  .action(async (commentId, options) => {
1174
1201
  const analytics = new Analytics();
1175
1202
  try {
1176
- const config = getConfig();
1203
+ const config = getConfig(getProfileName());
1204
+ assertWritable(config);
1177
1205
  const client = new ConfluenceClient(config);
1178
1206
 
1179
1207
  if (!options.yes) {
@@ -1225,7 +1253,7 @@ program
1225
1253
  .action(async (pageId, options) => {
1226
1254
  const analytics = new Analytics();
1227
1255
  try {
1228
- const config = getConfig();
1256
+ const config = getConfig(getProfileName());
1229
1257
  const client = new ConfluenceClient(config);
1230
1258
  const fs = require('fs');
1231
1259
  const path = require('path');
@@ -1591,9 +1619,10 @@ program
1591
1619
  .action(async (sourcePageId, targetParentId, newTitle, options) => {
1592
1620
  const analytics = new Analytics();
1593
1621
  try {
1594
- const config = getConfig();
1622
+ const config = getConfig(getProfileName());
1623
+ assertWritable(config);
1595
1624
  const client = new ConfluenceClient(config);
1596
-
1625
+
1597
1626
  // Parse numeric flags with safe fallbacks
1598
1627
  const parsedDepth = parseInt(options.maxDepth, 10);
1599
1628
  const maxDepth = Number.isNaN(parsedDepth) ? 10 : parsedDepth;
@@ -1711,7 +1740,7 @@ program
1711
1740
  .action(async (pageId, options) => {
1712
1741
  const analytics = new Analytics();
1713
1742
  try {
1714
- const config = getConfig();
1743
+ const config = getConfig(getProfileName());
1715
1744
  const client = new ConfluenceClient(config);
1716
1745
 
1717
1746
  // Extract page ID from URL if needed
@@ -1852,6 +1881,82 @@ function printTree(nodes, config, options, depth = 1) {
1852
1881
  });
1853
1882
  }
1854
1883
 
1884
+ // Profile management commands
1885
+ const profileCmd = program
1886
+ .command('profile')
1887
+ .description('Manage configuration profiles');
1888
+
1889
+ profileCmd
1890
+ .command('list')
1891
+ .description('List all configuration profiles')
1892
+ .action(() => {
1893
+ const { profiles } = listProfiles();
1894
+ if (profiles.length === 0) {
1895
+ console.log(chalk.yellow('No profiles configured. Run "confluence init" to create one.'));
1896
+ return;
1897
+ }
1898
+ console.log(chalk.blue('Configuration profiles:\n'));
1899
+ profiles.forEach(p => {
1900
+ const marker = p.active ? chalk.green(' (active)') : '';
1901
+ const readOnlyBadge = p.readOnly ? chalk.red(' [read-only]') : '';
1902
+ console.log(` ${p.active ? chalk.green('*') : ' '} ${chalk.cyan(p.name)}${marker}${readOnlyBadge} - ${chalk.gray(p.domain)}`);
1903
+ });
1904
+ });
1905
+
1906
+ profileCmd
1907
+ .command('use <name>')
1908
+ .description('Set the active configuration profile')
1909
+ .action((name) => {
1910
+ try {
1911
+ setActiveProfile(name);
1912
+ console.log(chalk.green(`Switched to profile "${name}"`));
1913
+ } catch (error) {
1914
+ console.error(chalk.red('Error:'), error.message);
1915
+ process.exit(1);
1916
+ }
1917
+ });
1918
+
1919
+ profileCmd
1920
+ .command('add <name>')
1921
+ .description('Add a new configuration profile interactively')
1922
+ .option('-d, --domain <domain>', 'Confluence domain')
1923
+ .option('--protocol <protocol>', 'Protocol (http or https)')
1924
+ .option('-p, --api-path <path>', 'REST API path')
1925
+ .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
1926
+ .option('-e, --email <email>', 'Email or username for basic auth')
1927
+ .option('-t, --token <token>', 'API token')
1928
+ .option('--read-only', 'Set profile to read-only mode (blocks write operations)')
1929
+ .action(async (name, options) => {
1930
+ if (!isValidProfileName(name)) {
1931
+ console.error(chalk.red('Invalid profile name. Use only letters, numbers, hyphens, and underscores.'));
1932
+ process.exit(1);
1933
+ }
1934
+ await initConfig({ ...options, profile: name });
1935
+ });
1936
+
1937
+ profileCmd
1938
+ .command('remove <name>')
1939
+ .description('Remove a configuration profile')
1940
+ .action(async (name) => {
1941
+ try {
1942
+ const { confirmed } = await inquirer.prompt([{
1943
+ type: 'confirm',
1944
+ name: 'confirmed',
1945
+ message: `Delete profile "${name}"?`,
1946
+ default: false
1947
+ }]);
1948
+ if (!confirmed) {
1949
+ console.log(chalk.yellow('Cancelled.'));
1950
+ return;
1951
+ }
1952
+ deleteProfile(name);
1953
+ console.log(chalk.green(`Profile "${name}" removed.`));
1954
+ } catch (error) {
1955
+ console.error(chalk.red('Error:'), error.message);
1956
+ process.exit(1);
1957
+ }
1958
+ });
1959
+
1855
1960
  // Exported for testing
1856
1961
  module.exports = {
1857
1962
  program,
@@ -1862,6 +1967,7 @@ module.exports = {
1862
1967
  uniquePathFor,
1863
1968
  exportRecursive,
1864
1969
  sanitizeTitle,
1970
+ assertWritable,
1865
1971
  },
1866
1972
  };
1867
1973
 
package/lib/config.js CHANGED
@@ -6,12 +6,15 @@ const chalk = require('chalk');
6
6
 
7
7
  const CONFIG_DIR = path.join(os.homedir(), '.confluence-cli');
8
8
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+ const DEFAULT_PROFILE = 'default';
9
10
 
10
11
  const AUTH_CHOICES = [
11
12
  { name: 'Basic (credentials)', value: 'basic' },
12
13
  { name: 'Bearer token', value: 'bearer' }
13
14
  ];
14
15
 
16
+ const isValidProfileName = (name) => /^[a-zA-Z0-9_-]+$/.test(name);
17
+
15
18
  const requiredInput = (label) => (input) => {
16
19
  if (!input || !input.trim()) {
17
20
  return `${label} is required`;
@@ -68,6 +71,47 @@ const normalizeApiPath = (rawValue, domain) => {
68
71
  return withoutTrailing || inferApiPath(domain);
69
72
  };
70
73
 
74
+ // Read config file with backward compatibility for old flat format
75
+ function readConfigFile() {
76
+ if (!fs.existsSync(CONFIG_FILE)) {
77
+ return null;
78
+ }
79
+
80
+ try {
81
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
82
+
83
+ // Detect old flat format (has domain at top level, no profiles key)
84
+ if (raw.domain && !raw.profiles) {
85
+ const profile = {
86
+ domain: raw.domain,
87
+ protocol: raw.protocol,
88
+ apiPath: raw.apiPath,
89
+ token: raw.token,
90
+ authType: raw.authType
91
+ };
92
+ if (raw.email) {
93
+ profile.email = raw.email;
94
+ }
95
+ return {
96
+ activeProfile: DEFAULT_PROFILE,
97
+ profiles: { [DEFAULT_PROFILE]: profile }
98
+ };
99
+ }
100
+
101
+ return raw;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ // Write the full multi-profile config structure
108
+ function saveConfigFile(data) {
109
+ if (!fs.existsSync(CONFIG_DIR)) {
110
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
111
+ }
112
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
113
+ }
114
+
71
115
  // Helper function to validate CLI-provided options
72
116
  const validateCliOptions = (options) => {
73
117
  const errors = [];
@@ -115,23 +159,40 @@ const validateCliOptions = (options) => {
115
159
  };
116
160
 
117
161
  // Helper function to save configuration with validation
118
- const saveConfig = (configData) => {
119
- if (!fs.existsSync(CONFIG_DIR)) {
120
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
121
- }
122
-
162
+ const saveConfig = (configData, profileName) => {
123
163
  const config = {
124
164
  domain: configData.domain.trim(),
125
165
  protocol: normalizeProtocol(configData.protocol),
126
166
  apiPath: normalizeApiPath(configData.apiPath, configData.domain),
127
167
  token: configData.token.trim(),
128
- authType: configData.authType,
129
- email: configData.authType === 'basic' && configData.email ? configData.email.trim() : undefined
168
+ authType: configData.authType
130
169
  };
131
170
 
132
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
171
+ if (configData.authType === 'basic' && configData.email) {
172
+ config.email = configData.email.trim();
173
+ }
174
+
175
+ if (configData.readOnly) {
176
+ config.readOnly = true;
177
+ }
178
+
179
+ // Read existing config file (or create new structure)
180
+ const fileData = readConfigFile() || { activeProfile: DEFAULT_PROFILE, profiles: {} };
181
+
182
+ const targetProfile = profileName || fileData.activeProfile || DEFAULT_PROFILE;
183
+ fileData.profiles[targetProfile] = config;
184
+
185
+ // If this is the first profile, make it active
186
+ if (!fileData.activeProfile || !fileData.profiles[fileData.activeProfile]) {
187
+ fileData.activeProfile = targetProfile;
188
+ }
189
+
190
+ saveConfigFile(fileData);
133
191
 
134
192
  console.log(chalk.green('✅ Configuration saved successfully!'));
193
+ if (profileName) {
194
+ console.log(`Profile: ${chalk.cyan(targetProfile)}`);
195
+ }
135
196
  console.log(`Config file location: ${chalk.gray(CONFIG_FILE)}`);
136
197
  console.log(chalk.yellow('\n💡 Tip: You can regenerate this config anytime by running "confluence init"'));
137
198
  };
@@ -232,6 +293,16 @@ const promptForMissingValues = async (providedValues) => {
232
293
  };
233
294
 
234
295
  async function initConfig(cliOptions = {}) {
296
+ const profileName = cliOptions.profile;
297
+
298
+ // Validate profile name if provided
299
+ if (profileName && !isValidProfileName(profileName)) {
300
+ console.error(chalk.red('❌ Invalid profile name. Use only letters, numbers, hyphens, and underscores.'));
301
+ process.exit(1);
302
+ }
303
+
304
+ const readOnly = cliOptions.readOnly || false;
305
+
235
306
  // Extract provided values from CLI options
236
307
  const providedValues = {
237
308
  protocol: cliOptions.protocol,
@@ -248,6 +319,9 @@ async function initConfig(cliOptions = {}) {
248
319
  if (!hasCliOptions) {
249
320
  // Interactive mode: no CLI options provided
250
321
  console.log(chalk.blue('🚀 Confluence CLI Configuration'));
322
+ if (profileName) {
323
+ console.log(`Profile: ${chalk.cyan(profileName)}`);
324
+ }
251
325
  console.log('Please provide your Confluence connection details:\n');
252
326
 
253
327
  const answers = await inquirer.prompt([
@@ -307,7 +381,7 @@ async function initConfig(cliOptions = {}) {
307
381
  }
308
382
  ]);
309
383
 
310
- saveConfig(answers);
384
+ saveConfig({ ...answers, readOnly }, profileName);
311
385
  return;
312
386
  }
313
387
 
@@ -325,8 +399,8 @@ async function initConfig(cliOptions = {}) {
325
399
  // Check if all required values are provided for non-interactive mode
326
400
  // Non-interactive requires: domain, token, and either authType or email (for inference)
327
401
  const hasRequiredValues = Boolean(
328
- providedValues.domain &&
329
- providedValues.token &&
402
+ providedValues.domain &&
403
+ providedValues.token &&
330
404
  (providedValues.authType || providedValues.email)
331
405
  );
332
406
 
@@ -341,7 +415,7 @@ async function initConfig(cliOptions = {}) {
341
415
 
342
416
  const normalizedAuthType = normalizeAuthType(inferredAuthType, Boolean(providedValues.email));
343
417
  const normalizedDomain = providedValues.domain.trim();
344
-
418
+
345
419
  // Verify basic auth has email
346
420
  if (normalizedAuthType === 'basic' && !providedValues.email) {
347
421
  console.error(chalk.red('❌ Email is required for basic authentication'));
@@ -359,10 +433,11 @@ async function initConfig(cliOptions = {}) {
359
433
  apiPath: providedValues.apiPath || inferApiPath(normalizedDomain),
360
434
  token: providedValues.token,
361
435
  authType: normalizedAuthType,
362
- email: providedValues.email
436
+ email: providedValues.email,
437
+ readOnly
363
438
  };
364
439
 
365
- saveConfig(configData);
440
+ saveConfig(configData, profileName);
366
441
  } catch (error) {
367
442
  console.error(chalk.red(`❌ ${error.message}`));
368
443
  process.exit(1);
@@ -373,27 +448,31 @@ async function initConfig(cliOptions = {}) {
373
448
  // Hybrid mode: some values provided, prompt for the rest
374
449
  try {
375
450
  console.log(chalk.blue('🚀 Confluence CLI Configuration'));
451
+ if (profileName) {
452
+ console.log(`Profile: ${chalk.cyan(profileName)}`);
453
+ }
376
454
  console.log('Completing configuration with interactive prompts:\n');
377
455
 
378
456
  const mergedValues = await promptForMissingValues(providedValues);
379
-
457
+
380
458
  // Normalize auth type
381
459
  mergedValues.authType = normalizeAuthType(mergedValues.authType, Boolean(mergedValues.email));
382
-
383
- saveConfig(mergedValues);
460
+
461
+ saveConfig({ ...mergedValues, readOnly }, profileName);
384
462
  } catch (error) {
385
463
  console.error(chalk.red(`❌ ${error.message}`));
386
464
  process.exit(1);
387
465
  }
388
466
  }
389
467
 
390
- function getConfig() {
468
+ function getConfig(profileName) {
391
469
  const envDomain = process.env.CONFLUENCE_DOMAIN || process.env.CONFLUENCE_HOST;
392
470
  const envToken = process.env.CONFLUENCE_API_TOKEN || process.env.CONFLUENCE_PASSWORD;
393
471
  const envEmail = process.env.CONFLUENCE_EMAIL || process.env.CONFLUENCE_USERNAME;
394
472
  const envAuthType = process.env.CONFLUENCE_AUTH_TYPE;
395
473
  const envApiPath = process.env.CONFLUENCE_API_PATH;
396
474
  const envProtocol = process.env.CONFLUENCE_PROTOCOL;
475
+ const envReadOnly = process.env.CONFLUENCE_READ_ONLY;
397
476
 
398
477
  if (envDomain && envToken) {
399
478
  const authType = normalizeAuthType(envAuthType, Boolean(envEmail));
@@ -418,19 +497,39 @@ function getConfig() {
418
497
  apiPath,
419
498
  token: envToken.trim(),
420
499
  email: envEmail ? envEmail.trim() : undefined,
421
- authType
500
+ authType,
501
+ readOnly: envReadOnly === 'true'
422
502
  };
423
503
  }
424
504
 
425
- if (!fs.existsSync(CONFIG_FILE)) {
505
+ // Resolve profile: explicit param > CONFLUENCE_PROFILE env var > activeProfile > default
506
+ const resolvedProfileName = profileName
507
+ || process.env.CONFLUENCE_PROFILE
508
+ || null;
509
+
510
+ const fileData = readConfigFile();
511
+
512
+ if (!fileData) {
426
513
  console.error(chalk.red('❌ No configuration found!'));
427
514
  console.log(chalk.yellow('Please run "confluence init" to set up your configuration.'));
428
515
  console.log(chalk.gray('Or set environment variables: CONFLUENCE_DOMAIN, CONFLUENCE_API_TOKEN (or CONFLUENCE_PASSWORD), CONFLUENCE_EMAIL (or CONFLUENCE_USERNAME), and optionally CONFLUENCE_API_PATH, CONFLUENCE_PROTOCOL.'));
429
516
  process.exit(1);
430
517
  }
431
518
 
519
+ const targetProfile = resolvedProfileName || fileData.activeProfile || DEFAULT_PROFILE;
520
+ const storedConfig = fileData.profiles && fileData.profiles[targetProfile];
521
+
522
+ if (!storedConfig) {
523
+ console.error(chalk.red(`❌ Profile "${targetProfile}" not found!`));
524
+ const available = fileData.profiles ? Object.keys(fileData.profiles) : [];
525
+ if (available.length > 0) {
526
+ console.log(chalk.yellow(`Available profiles: ${available.join(', ')}`));
527
+ }
528
+ console.log(chalk.yellow('Run "confluence init --profile <name>" to create it, or "confluence profile list" to see available profiles.'));
529
+ process.exit(1);
530
+ }
531
+
432
532
  try {
433
- const storedConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
434
533
  const trimmedDomain = (storedConfig.domain || '').trim();
435
534
  const trimmedToken = (storedConfig.token || '').trim();
436
535
  const trimmedEmail = storedConfig.email ? storedConfig.email.trim() : undefined;
@@ -457,13 +556,18 @@ function getConfig() {
457
556
  process.exit(1);
458
557
  }
459
558
 
559
+ const readOnly = envReadOnly !== undefined
560
+ ? envReadOnly === 'true'
561
+ : Boolean(storedConfig.readOnly);
562
+
460
563
  return {
461
564
  domain: trimmedDomain,
462
565
  protocol: normalizeProtocol(storedConfig.protocol),
463
566
  apiPath,
464
567
  token: trimmedToken,
465
568
  email: trimmedEmail,
466
- authType
569
+ authType,
570
+ readOnly
467
571
  };
468
572
  } catch (error) {
469
573
  console.error(chalk.red('❌ Error reading configuration file:'), error.message);
@@ -472,7 +576,61 @@ function getConfig() {
472
576
  }
473
577
  }
474
578
 
579
+ function listProfiles() {
580
+ const fileData = readConfigFile();
581
+ if (!fileData || !fileData.profiles || Object.keys(fileData.profiles).length === 0) {
582
+ return { activeProfile: null, profiles: [] };
583
+ }
584
+ return {
585
+ activeProfile: fileData.activeProfile,
586
+ profiles: Object.keys(fileData.profiles).map(name => ({
587
+ name,
588
+ active: name === fileData.activeProfile,
589
+ domain: fileData.profiles[name].domain,
590
+ readOnly: Boolean(fileData.profiles[name].readOnly)
591
+ }))
592
+ };
593
+ }
594
+
595
+ function setActiveProfile(profileName) {
596
+ const fileData = readConfigFile();
597
+ if (!fileData) {
598
+ throw new Error('No configuration file found. Run "confluence init" first.');
599
+ }
600
+ if (!fileData.profiles || !fileData.profiles[profileName]) {
601
+ const available = fileData.profiles ? Object.keys(fileData.profiles) : [];
602
+ throw new Error(`Profile "${profileName}" not found. Available: ${available.join(', ')}`);
603
+ }
604
+ fileData.activeProfile = profileName;
605
+ saveConfigFile(fileData);
606
+ }
607
+
608
+ function deleteProfile(profileName) {
609
+ const fileData = readConfigFile();
610
+ if (!fileData) {
611
+ throw new Error('No configuration file found. Run "confluence init" first.');
612
+ }
613
+ if (!fileData.profiles || !fileData.profiles[profileName]) {
614
+ throw new Error(`Profile "${profileName}" not found.`);
615
+ }
616
+ if (Object.keys(fileData.profiles).length === 1) {
617
+ throw new Error('Cannot delete the only remaining profile.');
618
+ }
619
+ delete fileData.profiles[profileName];
620
+ if (fileData.activeProfile === profileName) {
621
+ fileData.activeProfile = Object.keys(fileData.profiles)[0];
622
+ }
623
+ saveConfigFile(fileData);
624
+ }
625
+
475
626
  module.exports = {
476
627
  initConfig,
477
- getConfig
628
+ getConfig,
629
+ listProfiles,
630
+ setActiveProfile,
631
+ deleteProfile,
632
+ isValidProfileName,
633
+ CONFIG_DIR,
634
+ CONFIG_FILE,
635
+ DEFAULT_PROFILE
478
636
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.25.1",
3
+ "version": "1.27.0",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {