@bestend/confluence-cli 1.16.0 → 2.0.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.
package/bin/confluence.js CHANGED
@@ -4,26 +4,48 @@ 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
 
11
+ function buildPageUrl(config, path) {
12
+ const protocol = config.protocol || 'https';
13
+ return `${protocol}://${config.domain}${path}`;
14
+ }
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
+
11
24
  program
12
25
  .name('confluence')
13
26
  .description('CLI tool for Atlassian Confluence')
14
- .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
+ }
15
34
 
16
35
  // Init command
17
36
  program
18
37
  .command('init')
19
38
  .description('Initialize Confluence CLI configuration')
20
39
  .option('-d, --domain <domain>', 'Confluence domain')
40
+ .option('--protocol <protocol>', 'Protocol (http or https)')
21
41
  .option('-p, --api-path <path>', 'REST API path')
22
42
  .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
23
- .option('-e, --email <email>', 'Email for basic auth')
43
+ .option('-e, --email <email>', 'Email or username for basic auth')
24
44
  .option('-t, --token <token>', 'API token')
45
+ .option('--read-only', 'Set profile to read-only mode (blocks write operations)')
25
46
  .action(async (options) => {
26
- await initConfig(options);
47
+ const profile = getProfileName();
48
+ await initConfig({ ...options, profile });
27
49
  });
28
50
 
29
51
  // Read command
@@ -34,7 +56,7 @@ program
34
56
  .action(async (pageId, options) => {
35
57
  const analytics = new Analytics();
36
58
  try {
37
- const client = new ConfluenceClient(getConfig());
59
+ const client = new ConfluenceClient(getConfig(getProfileName()));
38
60
  const content = await client.readPage(pageId, options.format);
39
61
  console.log(content);
40
62
  analytics.track('read', true);
@@ -52,7 +74,7 @@ program
52
74
  .action(async (pageId) => {
53
75
  const analytics = new Analytics();
54
76
  try {
55
- const client = new ConfluenceClient(getConfig());
77
+ const client = new ConfluenceClient(getConfig(getProfileName()));
56
78
  const info = await client.getPageInfo(pageId);
57
79
  console.log(chalk.blue('Page Information:'));
58
80
  console.log(`Title: ${chalk.green(info.title)}`);
@@ -75,11 +97,12 @@ program
75
97
  .command('search <query>')
76
98
  .description('Search for Confluence pages')
77
99
  .option('-l, --limit <limit>', 'Limit number of results', '10')
100
+ .option('--cql', 'Pass query as raw CQL instead of text search')
78
101
  .action(async (query, options) => {
79
102
  const analytics = new Analytics();
80
103
  try {
81
- const client = new ConfluenceClient(getConfig());
82
- const results = await client.search(query, parseInt(options.limit));
104
+ const client = new ConfluenceClient(getConfig(getProfileName()));
105
+ const results = await client.search(query, parseInt(options.limit), options.cql);
83
106
 
84
107
  if (results.length === 0) {
85
108
  console.log(chalk.yellow('No results found.'));
@@ -109,7 +132,7 @@ program
109
132
  .action(async () => {
110
133
  const analytics = new Analytics();
111
134
  try {
112
- const config = getConfig();
135
+ const config = getConfig(getProfileName());
113
136
  const client = new ConfluenceClient(config);
114
137
  const spaces = await client.getSpaces();
115
138
 
@@ -139,22 +162,64 @@ program
139
162
  }
140
163
  });
141
164
 
165
+ // Install skill command
166
+ program
167
+ .command('install-skill')
168
+ .description('Copy Claude Code skill files into your project\'s .claude/skills/ directory')
169
+ .option('--dest <directory>', 'Target directory', './.claude/skills/confluence')
170
+ .option('-y, --yes', 'Skip confirmation prompt')
171
+ .action(async (options) => {
172
+ const fs = require('fs');
173
+ const path = require('path');
174
+
175
+ const skillSrc = path.join(__dirname, '..', '.claude', 'skills', 'confluence', 'SKILL.md');
176
+
177
+ if (!fs.existsSync(skillSrc)) {
178
+ console.error(chalk.red('Error: skill file not found in package. Try reinstalling confluence-cli.'));
179
+ process.exit(1);
180
+ }
181
+
182
+ const destDir = path.resolve(options.dest);
183
+ const destFile = path.join(destDir, 'SKILL.md');
184
+
185
+ if (fs.existsSync(destFile) && !options.yes) {
186
+ const { confirmed } = await inquirer.prompt([
187
+ {
188
+ type: 'confirm',
189
+ name: 'confirmed',
190
+ default: true,
191
+ message: `Overwrite existing skill file at ${destFile}?`
192
+ }
193
+ ]);
194
+
195
+ if (!confirmed) {
196
+ console.log(chalk.yellow('Cancelled.'));
197
+ return;
198
+ }
199
+ }
200
+
201
+ fs.mkdirSync(destDir, { recursive: true });
202
+ fs.copyFileSync(skillSrc, destFile);
203
+
204
+ console.log(chalk.green('✅ Skill installed successfully!'));
205
+ console.log(`Location: ${chalk.gray(destFile)}`);
206
+ console.log(chalk.yellow('Claude Code will now pick up confluence-cli knowledge from this file.'));
207
+ });
208
+
142
209
  // Create command
143
210
  program
144
- .command('create <title> [spaceKey]')
211
+ .command('create <title> <spaceKey>')
145
212
  .description('Create a new Confluence page')
146
213
  .option('-f, --file <file>', 'Read content from file')
147
214
  .option('-c, --content <content>', 'Page content as string')
148
215
  .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
149
- .option('--parent <parentId>', 'Parent page ID (creates page as child of this page)')
150
- .option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
151
- .option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
152
216
  .action(async (title, spaceKey, options) => {
153
217
  const analytics = new Analytics();
154
218
  try {
155
- const config = getConfig();
219
+ const config = getConfig(getProfileName());
220
+ assertWritable(config);
156
221
  const client = new ConfluenceClient(config);
157
-
222
+
158
223
  let content = '';
159
224
 
160
225
  if (options.file) {
@@ -169,32 +234,13 @@ program
169
234
  throw new Error('Either --file or --content option is required');
170
235
  }
171
236
 
172
- let result;
173
- if (options.parent) {
174
- const parentInfo = await client.getPageInfo(options.parent);
175
- const derivedSpaceKey = parentInfo.space.key;
176
- result = await client.createChildPage(title, derivedSpaceKey, options.parent, content, options.format, {
177
- validateStorage: options.validateStorage,
178
- sanitizeStorage: options.sanitizeStorage
179
- });
180
- console.log(chalk.green('✅ Page created successfully!'));
181
- console.log(`Title: ${chalk.blue(result.title)}`);
182
- console.log(`ID: ${chalk.blue(result.id)}`);
183
- console.log(`Parent: ${chalk.blue(parentInfo.title)} (${options.parent})`);
184
- } else {
185
- if (!spaceKey) {
186
- throw new Error('Space key is required when --parent is not specified');
187
- }
188
- result = await client.createPage(title, spaceKey, content, options.format, {
189
- validateStorage: options.validateStorage,
190
- sanitizeStorage: options.sanitizeStorage
191
- });
192
- console.log(chalk.green('✅ Page created successfully!'));
193
- console.log(`Title: ${chalk.blue(result.title)}`);
194
- console.log(`ID: ${chalk.blue(result.id)}`);
195
- }
237
+ const result = await client.createPage(title, spaceKey, content, options.format);
238
+
239
+ console.log(chalk.green('✅ Page created successfully!'));
240
+ console.log(`Title: ${chalk.blue(result.title)}`);
241
+ console.log(`ID: ${chalk.blue(result.id)}`);
196
242
  console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
197
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
243
+ console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
198
244
 
199
245
  analytics.track('create', true);
200
246
  } catch (error) {
@@ -211,16 +257,13 @@ program
211
257
  .option('-f, --file <file>', 'Read content from file')
212
258
  .option('-c, --content <content>', 'Page content as string')
213
259
  .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
214
- .option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
215
- .option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
216
- .option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
217
- .option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
218
260
  .action(async (title, parentId, options) => {
219
261
  const analytics = new Analytics();
220
262
  try {
221
- const config = getConfig();
263
+ const config = getConfig(getProfileName());
264
+ assertWritable(config);
222
265
  const client = new ConfluenceClient(config);
223
-
266
+
224
267
  // Get parent page info to get space key
225
268
  const parentInfo = await client.getPageInfo(parentId);
226
269
  const spaceKey = parentInfo.space.key;
@@ -239,17 +282,14 @@ program
239
282
  throw new Error('Either --file or --content option is required');
240
283
  }
241
284
 
242
- const result = await client.createChildPage(title, spaceKey, parentId, content, options.format, {
243
- validateStorage: options.validateStorage,
244
- sanitizeStorage: options.sanitizeStorage
245
- });
285
+ const result = await client.createChildPage(title, spaceKey, parentId, content, options.format);
246
286
 
247
287
  console.log(chalk.green('✅ Child page created successfully!'));
248
288
  console.log(`Title: ${chalk.blue(result.title)}`);
249
289
  console.log(`ID: ${chalk.blue(result.id)}`);
250
290
  console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`);
251
291
  console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
252
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
292
+ console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
253
293
 
254
294
  analytics.track('create_child', true);
255
295
  } catch (error) {
@@ -267,8 +307,6 @@ program
267
307
  .option('-f, --file <file>', 'Read content from file')
268
308
  .option('-c, --content <content>', 'Page content as string')
269
309
  .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
270
- .option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
271
- .option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
272
310
  .action(async (pageId, options) => {
273
311
  const analytics = new Analytics();
274
312
  try {
@@ -277,9 +315,10 @@ program
277
315
  throw new Error('At least one of --title, --file, or --content must be provided.');
278
316
  }
279
317
 
280
- const config = getConfig();
318
+ const config = getConfig(getProfileName());
319
+ assertWritable(config);
281
320
  const client = new ConfluenceClient(config);
282
-
321
+
283
322
  let content = null; // Use null to indicate no content change
284
323
 
285
324
  if (options.file) {
@@ -292,16 +331,13 @@ program
292
331
  content = options.content;
293
332
  }
294
333
 
295
- const result = await client.updatePage(pageId, options.title, content, options.format, {
296
- validateStorage: options.validateStorage,
297
- sanitizeStorage: options.sanitizeStorage
298
- });
334
+ const result = await client.updatePage(pageId, options.title, content, options.format);
299
335
 
300
336
  console.log(chalk.green('✅ Page updated successfully!'));
301
337
  console.log(`Title: ${chalk.blue(result.title)}`);
302
338
  console.log(`ID: ${chalk.blue(result.id)}`);
303
339
  console.log(`Version: ${chalk.blue(result.version.number)}`);
304
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
340
+ console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
305
341
 
306
342
  analytics.track('update', true);
307
343
  } catch (error) {
@@ -311,6 +347,34 @@ program
311
347
  }
312
348
  });
313
349
 
350
+ // Move command
351
+ program
352
+ .command('move <pageId_or_url> <newParentId_or_url>')
353
+ .description('Move a page to a new parent location (within same space)')
354
+ .option('-t, --title <title>', 'New page title (optional)')
355
+ .action(async (pageId, newParentId, options) => {
356
+ const analytics = new Analytics();
357
+ try {
358
+ const config = getConfig(getProfileName());
359
+ assertWritable(config);
360
+ const client = new ConfluenceClient(config);
361
+ const result = await client.movePage(pageId, newParentId, options.title);
362
+
363
+ console.log(chalk.green('✅ Page moved successfully!'));
364
+ console.log(`Title: ${chalk.blue(result.title)}`);
365
+ console.log(`ID: ${chalk.blue(result.id)}`);
366
+ console.log(`New Parent: ${chalk.blue(newParentId)}`);
367
+ console.log(`Version: ${chalk.blue(result.version.number)}`);
368
+ console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
369
+
370
+ analytics.track('move', true);
371
+ } catch (error) {
372
+ analytics.track('move', false);
373
+ console.error(chalk.red('Error:'), error.message);
374
+ process.exit(1);
375
+ }
376
+ });
377
+
314
378
  // Delete command
315
379
  program
316
380
  .command('delete <pageIdOrUrl>')
@@ -319,7 +383,8 @@ program
319
383
  .action(async (pageIdOrUrl, options) => {
320
384
  const analytics = new Analytics();
321
385
  try {
322
- const config = getConfig();
386
+ const config = getConfig(getProfileName());
387
+ assertWritable(config);
323
388
  const client = new ConfluenceClient(config);
324
389
  const pageInfo = await client.getPageInfo(pageIdOrUrl);
325
390
 
@@ -362,7 +427,8 @@ program
362
427
  .action(async (pageId, options) => {
363
428
  const analytics = new Analytics();
364
429
  try {
365
- const config = getConfig();
430
+ const config = getConfig(getProfileName());
431
+ assertWritable(config);
366
432
  const client = new ConfluenceClient(config);
367
433
  const pageData = await client.getPageForEdit(pageId);
368
434
 
@@ -399,7 +465,7 @@ program
399
465
  .action(async (title, options) => {
400
466
  const analytics = new Analytics();
401
467
  try {
402
- const config = getConfig();
468
+ const config = getConfig(getProfileName());
403
469
  const client = new ConfluenceClient(config);
404
470
  const pageInfo = await client.findPageByTitle(title, options.space);
405
471
 
@@ -407,7 +473,7 @@ program
407
473
  console.log(`Title: ${chalk.green(pageInfo.title)}`);
408
474
  console.log(`ID: ${chalk.green(pageInfo.id)}`);
409
475
  console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`);
410
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${pageInfo.url}`)}`);
476
+ console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${pageInfo.url}`)}`)}`);
411
477
 
412
478
  analytics.track('find', true);
413
479
  } catch (error) {
@@ -425,10 +491,11 @@ program
425
491
  .option('-p, --pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
426
492
  .option('-d, --download', 'Download matching attachments')
427
493
  .option('--dest <directory>', 'Directory to save downloads (default: current directory)', '.')
494
+ .option('-f, --format <format>', 'Output format (text, json)', 'text')
428
495
  .action(async (pageId, options) => {
429
496
  const analytics = new Analytics();
430
497
  try {
431
- const config = getConfig();
498
+ const config = getConfig(getProfileName());
432
499
  const client = new ConfluenceClient(config);
433
500
  const maxResults = options.limit ? parseInt(options.limit, 10) : null;
434
501
  const pattern = options.pattern ? options.pattern.trim() : null;
@@ -437,22 +504,47 @@ program
437
504
  throw new Error('Limit must be a positive number.');
438
505
  }
439
506
 
507
+ const format = (options.format || 'text').toLowerCase();
508
+ if (!['text', 'json'].includes(format)) {
509
+ throw new Error('Format must be one of: text, json');
510
+ }
511
+
440
512
  const attachments = await client.getAllAttachments(pageId, { maxResults });
441
513
  const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
442
514
 
443
515
  if (filtered.length === 0) {
444
- console.log(chalk.yellow('No attachments found.'));
516
+ if (format === 'json') {
517
+ console.log(JSON.stringify({ attachmentCount: 0, attachments: [] }, null, 2));
518
+ } else {
519
+ console.log(chalk.yellow('No attachments found.'));
520
+ }
445
521
  analytics.track('attachments', true);
446
522
  return;
447
523
  }
448
524
 
449
- console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
450
- filtered.forEach((att, index) => {
451
- const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
452
- const typeLabel = att.mediaType || 'unknown';
453
- console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
454
- console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
455
- });
525
+ if (format === 'json' && !options.download) {
526
+ const output = {
527
+ attachmentCount: filtered.length,
528
+ attachments: filtered.map(att => ({
529
+ id: att.id,
530
+ title: att.title,
531
+ mediaType: att.mediaType || '',
532
+ fileSize: att.fileSize,
533
+ fileSizeFormatted: att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size',
534
+ version: att.version,
535
+ downloadLink: att.downloadLink
536
+ }))
537
+ };
538
+ console.log(JSON.stringify(output, null, 2));
539
+ } else if (!options.download) {
540
+ console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
541
+ filtered.forEach((att, index) => {
542
+ const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
543
+ const typeLabel = att.mediaType || 'unknown';
544
+ console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
545
+ console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
546
+ });
547
+ }
456
548
 
457
549
  if (options.download) {
458
550
  const fs = require('fs');
@@ -481,17 +573,28 @@ program
481
573
  writer.on('finish', resolve);
482
574
  });
483
575
 
484
- let downloaded = 0;
576
+ const downloadResults = [];
485
577
  for (const attachment of filtered) {
486
578
  const targetPath = uniquePathFor(destDir, attachment.title);
487
- // Pass the full attachment object so downloadAttachment can use downloadLink directly
488
579
  const dataStream = await client.downloadAttachment(pageId, attachment);
489
580
  await writeStream(dataStream, targetPath);
490
- downloaded += 1;
491
- console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
581
+ downloadResults.push({ title: attachment.title, id: attachment.id, savedTo: targetPath });
582
+ if (format !== 'json') {
583
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
584
+ }
492
585
  }
493
586
 
494
- console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${destDir}`));
587
+ if (format === 'json') {
588
+ const output = {
589
+ attachmentCount: filtered.length,
590
+ downloaded: downloadResults.length,
591
+ destination: destDir,
592
+ attachments: downloadResults
593
+ };
594
+ console.log(JSON.stringify(output, null, 2));
595
+ } else {
596
+ console.log(chalk.green(`Downloaded ${downloadResults.length} attachment${downloadResults.length === 1 ? '' : 's'} to ${destDir}`));
597
+ }
495
598
  }
496
599
 
497
600
  analytics.track('attachments', true);
@@ -502,6 +605,318 @@ program
502
605
  }
503
606
  });
504
607
 
608
+ // Attachment upload command
609
+ program
610
+ .command('attachment-upload <pageId>')
611
+ .description('Upload one or more attachments to a page')
612
+ .option('-f, --file <file>', 'File to upload (repeatable)', (value, previous) => {
613
+ const files = Array.isArray(previous) ? previous : [];
614
+ files.push(value);
615
+ return files;
616
+ }, [])
617
+ .option('--comment <comment>', 'Comment for the attachment(s)')
618
+ .option('--replace', 'Replace an existing attachment with the same filename')
619
+ .option('--minor-edit', 'Mark the upload as a minor edit')
620
+ .action(async (pageId, options) => {
621
+ const analytics = new Analytics();
622
+ try {
623
+ const files = Array.isArray(options.file) ? options.file.filter(Boolean) : [];
624
+ if (files.length === 0) {
625
+ throw new Error('At least one --file option is required.');
626
+ }
627
+
628
+ const fs = require('fs');
629
+ const path = require('path');
630
+ const config = getConfig(getProfileName());
631
+ assertWritable(config);
632
+ const client = new ConfluenceClient(config);
633
+
634
+ const resolvedFiles = files.map((filePath) => ({
635
+ original: filePath,
636
+ resolved: path.resolve(filePath)
637
+ }));
638
+
639
+ resolvedFiles.forEach((file) => {
640
+ if (!fs.existsSync(file.resolved)) {
641
+ throw new Error(`File not found: ${file.original}`);
642
+ }
643
+ });
644
+
645
+ let uploaded = 0;
646
+ for (const file of resolvedFiles) {
647
+ const result = await client.uploadAttachment(pageId, file.resolved, {
648
+ comment: options.comment,
649
+ replace: options.replace,
650
+ minorEdit: options.minorEdit === true ? true : undefined
651
+ });
652
+ const attachment = result.results[0];
653
+ if (attachment) {
654
+ console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`);
655
+ } else {
656
+ console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`);
657
+ }
658
+ uploaded += 1;
659
+ }
660
+
661
+ console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`));
662
+ analytics.track('attachment_upload', true);
663
+ } catch (error) {
664
+ analytics.track('attachment_upload', false);
665
+ console.error(chalk.red('Error:'), error.message);
666
+ process.exit(1);
667
+ }
668
+ });
669
+
670
+ // Attachment delete command
671
+ program
672
+ .command('attachment-delete <pageId> <attachmentId>')
673
+ .description('Delete an attachment by ID from a page')
674
+ .option('-y, --yes', 'Skip confirmation prompt')
675
+ .action(async (pageId, attachmentId, options) => {
676
+ const analytics = new Analytics();
677
+ try {
678
+ const config = getConfig(getProfileName());
679
+ assertWritable(config);
680
+ const client = new ConfluenceClient(config);
681
+
682
+ if (!options.yes) {
683
+ const { confirmed } = await inquirer.prompt([
684
+ {
685
+ type: 'confirm',
686
+ name: 'confirmed',
687
+ default: false,
688
+ message: `Delete attachment ${attachmentId} from page ${pageId}?`
689
+ }
690
+ ]);
691
+
692
+ if (!confirmed) {
693
+ console.log(chalk.yellow('Cancelled.'));
694
+ analytics.track('attachment_delete_cancel', true);
695
+ return;
696
+ }
697
+ }
698
+
699
+ const result = await client.deleteAttachment(pageId, attachmentId);
700
+
701
+ console.log(chalk.green('✅ Attachment deleted successfully!'));
702
+ console.log(`ID: ${chalk.blue(result.id)}`);
703
+ console.log(`Page ID: ${chalk.blue(result.pageId)}`);
704
+ analytics.track('attachment_delete', true);
705
+ } catch (error) {
706
+ analytics.track('attachment_delete', false);
707
+ console.error(chalk.red('Error:'), error.message);
708
+ process.exit(1);
709
+ }
710
+ });
711
+
712
+ // Property list command
713
+ program
714
+ .command('property-list <pageId>')
715
+ .description('List all content properties for a page')
716
+ .option('-f, --format <format>', 'Output format (text, json)', 'text')
717
+ .option('-l, --limit <limit>', 'Maximum number of properties to fetch (default: 25)')
718
+ .option('--start <start>', 'Start index for results (default: 0)', '0')
719
+ .option('--all', 'Fetch all properties (ignores pagination)')
720
+ .action(async (pageId, options) => {
721
+ const analytics = new Analytics();
722
+ try {
723
+ const config = getConfig(getProfileName());
724
+ const client = new ConfluenceClient(config);
725
+
726
+ const format = (options.format || 'text').toLowerCase();
727
+ if (!['text', 'json'].includes(format)) {
728
+ throw new Error('Format must be one of: text, json');
729
+ }
730
+
731
+ const limit = options.limit ? parseInt(options.limit, 10) : null;
732
+ if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
733
+ throw new Error('Limit must be a positive number.');
734
+ }
735
+
736
+ const start = options.start ? parseInt(options.start, 10) : 0;
737
+ if (options.start && (Number.isNaN(start) || start < 0)) {
738
+ throw new Error('Start must be a non-negative number.');
739
+ }
740
+
741
+ let properties = [];
742
+ let nextStart = null;
743
+
744
+ if (options.all) {
745
+ properties = await client.getAllProperties(pageId, {
746
+ maxResults: limit || null,
747
+ start
748
+ });
749
+ } else {
750
+ const response = await client.listProperties(pageId, {
751
+ limit: limit || undefined,
752
+ start
753
+ });
754
+ properties = response.results;
755
+ nextStart = response.nextStart;
756
+ }
757
+
758
+ if (format === 'json') {
759
+ const output = { properties };
760
+ if (!options.all) {
761
+ output.nextStart = nextStart;
762
+ }
763
+ console.log(JSON.stringify(output, null, 2));
764
+ } else if (properties.length === 0) {
765
+ console.log(chalk.yellow('No properties found.'));
766
+ } else {
767
+ properties.forEach((prop, i) => {
768
+ const preview = JSON.stringify(prop.value);
769
+ const truncated = preview.length > 80 ? preview.slice(0, 77) + '...' : preview;
770
+ console.log(`${chalk.blue(i + 1 + '.')} ${chalk.green(prop.key)} (v${prop.version.number}): ${truncated}`);
771
+ });
772
+
773
+ if (!options.all && nextStart !== null && nextStart !== undefined) {
774
+ console.log(chalk.gray(`Next start: ${nextStart}`));
775
+ }
776
+ }
777
+ analytics.track('property_list', true);
778
+ } catch (error) {
779
+ analytics.track('property_list', false);
780
+ console.error(chalk.red('Error:'), error.message);
781
+ process.exit(1);
782
+ }
783
+ });
784
+
785
+ // Property get command
786
+ program
787
+ .command('property-get <pageId> <key>')
788
+ .description('Get a content property by key')
789
+ .option('-f, --format <format>', 'Output format (text, json)', 'text')
790
+ .action(async (pageId, key, options) => {
791
+ const analytics = new Analytics();
792
+ try {
793
+ const config = getConfig(getProfileName());
794
+ const client = new ConfluenceClient(config);
795
+
796
+ const format = (options.format || 'text').toLowerCase();
797
+ if (!['text', 'json'].includes(format)) {
798
+ throw new Error('Format must be one of: text, json');
799
+ }
800
+
801
+ const property = await client.getProperty(pageId, key);
802
+
803
+ if (format === 'json') {
804
+ console.log(JSON.stringify(property, null, 2));
805
+ } else {
806
+ console.log(`${chalk.green('Key:')} ${property.key}`);
807
+ console.log(`${chalk.green('Version:')} ${property.version.number}`);
808
+ console.log(`${chalk.green('Value:')}`);
809
+ console.log(JSON.stringify(property.value, null, 2));
810
+ }
811
+ analytics.track('property_get', true);
812
+ } catch (error) {
813
+ analytics.track('property_get', false);
814
+ console.error(chalk.red('Error:'), error.message);
815
+ process.exit(1);
816
+ }
817
+ });
818
+
819
+ // Property set command
820
+ program
821
+ .command('property-set <pageId> <key>')
822
+ .description('Set a content property (create or update)')
823
+ .option('-v, --value <json>', 'Property value as JSON')
824
+ .option('--file <file>', 'Read property value from a JSON file')
825
+ .option('-f, --format <format>', 'Output format (text, json)', 'text')
826
+ .action(async (pageId, key, options) => {
827
+ const analytics = new Analytics();
828
+ try {
829
+ const config = getConfig(getProfileName());
830
+ assertWritable(config);
831
+ const client = new ConfluenceClient(config);
832
+
833
+ if (!options.value && !options.file) {
834
+ throw new Error('Provide a value with --value or --file.');
835
+ }
836
+
837
+ let value;
838
+ if (options.file) {
839
+ const fs = require('fs');
840
+ const raw = fs.readFileSync(options.file, 'utf-8');
841
+ try {
842
+ value = JSON.parse(raw);
843
+ } catch {
844
+ throw new Error(`Invalid JSON in file ${options.file}`);
845
+ }
846
+ } else {
847
+ try {
848
+ value = JSON.parse(options.value);
849
+ } catch {
850
+ throw new Error('Invalid JSON in --value');
851
+ }
852
+ }
853
+
854
+ const format = (options.format || 'text').toLowerCase();
855
+ if (!['text', 'json'].includes(format)) {
856
+ throw new Error('Format must be one of: text, json');
857
+ }
858
+
859
+ const result = await client.setProperty(pageId, key, value);
860
+
861
+ if (format === 'json') {
862
+ console.log(JSON.stringify(result, null, 2));
863
+ } else {
864
+ console.log(chalk.green('✅ Property set successfully!'));
865
+ console.log(`${chalk.green('Key:')} ${result.key}`);
866
+ console.log(`${chalk.green('Version:')} ${result.version.number}`);
867
+ console.log(`${chalk.green('Value:')}`);
868
+ console.log(JSON.stringify(result.value, null, 2));
869
+ }
870
+ analytics.track('property_set', true);
871
+ } catch (error) {
872
+ analytics.track('property_set', false);
873
+ console.error(chalk.red('Error:'), error.message);
874
+ process.exit(1);
875
+ }
876
+ });
877
+
878
+ // Property delete command
879
+ program
880
+ .command('property-delete <pageId> <key>')
881
+ .description('Delete a content property by key')
882
+ .option('-y, --yes', 'Skip confirmation prompt')
883
+ .action(async (pageId, key, options) => {
884
+ const analytics = new Analytics();
885
+ try {
886
+ const config = getConfig(getProfileName());
887
+ assertWritable(config);
888
+ const client = new ConfluenceClient(config);
889
+
890
+ if (!options.yes) {
891
+ const { confirmed } = await inquirer.prompt([
892
+ {
893
+ type: 'confirm',
894
+ name: 'confirmed',
895
+ default: false,
896
+ message: `Delete property "${key}" from page ${pageId}?`
897
+ }
898
+ ]);
899
+
900
+ if (!confirmed) {
901
+ console.log(chalk.yellow('Cancelled.'));
902
+ analytics.track('property_delete_cancel', true);
903
+ return;
904
+ }
905
+ }
906
+
907
+ const result = await client.deleteProperty(pageId, key);
908
+
909
+ console.log(chalk.green('✅ Property deleted successfully!'));
910
+ console.log(`${chalk.green('Key:')} ${chalk.blue(result.key)}`);
911
+ console.log(`${chalk.green('Page ID:')} ${chalk.blue(result.pageId)}`);
912
+ analytics.track('property_delete', true);
913
+ } catch (error) {
914
+ analytics.track('property_delete', false);
915
+ console.error(chalk.red('Error:'), error.message);
916
+ process.exit(1);
917
+ }
918
+ });
919
+
505
920
  // Comments command
506
921
  program
507
922
  .command('comments <pageId>')
@@ -515,7 +930,7 @@ program
515
930
  .action(async (pageId, options) => {
516
931
  const analytics = new Analytics();
517
932
  try {
518
- const config = getConfig();
933
+ const config = getConfig(getProfileName());
519
934
  const client = new ConfluenceClient(config);
520
935
 
521
936
  const format = (options.format || 'text').toLowerCase();
@@ -670,7 +1085,8 @@ program
670
1085
  const analytics = new Analytics();
671
1086
  let location = null;
672
1087
  try {
673
- const config = getConfig();
1088
+ const config = getConfig(getProfileName());
1089
+ assertWritable(config);
674
1090
  const client = new ConfluenceClient(config);
675
1091
 
676
1092
  let content = '';
@@ -784,7 +1200,8 @@ program
784
1200
  .action(async (commentId, options) => {
785
1201
  const analytics = new Analytics();
786
1202
  try {
787
- const config = getConfig();
1203
+ const config = getConfig(getProfileName());
1204
+ assertWritable(config);
788
1205
  const client = new ConfluenceClient(config);
789
1206
 
790
1207
  if (!options.yes) {
@@ -827,14 +1244,26 @@ program
827
1244
  .option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
828
1245
  .option('--referenced-only', 'Download only attachments referenced in the page content')
829
1246
  .option('--skip-attachments', 'Do not download attachments')
1247
+ .option('-r, --recursive', 'Export page and all descendants')
1248
+ .option('--max-depth <depth>', 'Limit recursion depth (default: 10)', parseInt)
1249
+ .option('--exclude <patterns>', 'Comma-separated title glob patterns to skip')
1250
+ .option('--delay-ms <ms>', 'Delay between page exports in ms (default: 100)', parseInt)
1251
+ .option('--dry-run', 'Preview pages without writing files')
1252
+ .option('--overwrite', 'Overwrite existing export directory (replaces content, removes stale files)')
830
1253
  .action(async (pageId, options) => {
831
1254
  const analytics = new Analytics();
832
1255
  try {
833
- const config = getConfig();
1256
+ const config = getConfig(getProfileName());
834
1257
  const client = new ConfluenceClient(config);
835
1258
  const fs = require('fs');
836
1259
  const path = require('path');
837
1260
 
1261
+ if (options.recursive) {
1262
+ await exportRecursive(client, fs, path, pageId, options);
1263
+ analytics.track('export', true);
1264
+ return;
1265
+ }
1266
+
838
1267
  const format = (options.format || 'markdown').toLowerCase();
839
1268
  const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
840
1269
  const contentExt = formatExt[format] || 'txt';
@@ -852,11 +1281,18 @@ program
852
1281
  const baseDir = path.resolve(options.dest || '.');
853
1282
  const folderName = sanitizeTitle(pageInfo.title || 'page');
854
1283
  const exportDir = path.join(baseDir, folderName);
1284
+ if (options.overwrite && fs.existsSync(exportDir)) {
1285
+ if (!isExportDirectory(fs, path, exportDir)) {
1286
+ throw new Error(`Refusing to overwrite "${exportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`);
1287
+ }
1288
+ fs.rmSync(exportDir, { recursive: true, force: true });
1289
+ }
855
1290
  fs.mkdirSync(exportDir, { recursive: true });
856
1291
 
857
1292
  const contentFile = options.file || `page.${contentExt}`;
858
1293
  const contentPath = path.join(exportDir, contentFile);
859
1294
  fs.writeFileSync(contentPath, content);
1295
+ writeExportMarker(fs, path, exportDir, { pageId, title: pageInfo.title });
860
1296
 
861
1297
  console.log(chalk.green('✅ Page exported'));
862
1298
  console.log(`Title: ${chalk.blue(pageInfo.title)}`);
@@ -882,33 +1318,12 @@ program
882
1318
  const attachmentsDir = path.join(exportDir, attachmentsDirName);
883
1319
  fs.mkdirSync(attachmentsDir, { recursive: true });
884
1320
 
885
- const uniquePathFor = (dir, filename) => {
886
- const parsed = path.parse(filename);
887
- let attempt = path.join(dir, filename);
888
- let counter = 1;
889
- while (fs.existsSync(attempt)) {
890
- const suffix = ` (${counter})`;
891
- const nextName = `${parsed.name}${suffix}${parsed.ext}`;
892
- attempt = path.join(dir, nextName);
893
- counter += 1;
894
- }
895
- return attempt;
896
- };
897
-
898
- const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
899
- const writer = fs.createWriteStream(targetPath);
900
- stream.pipe(writer);
901
- stream.on('error', reject);
902
- writer.on('error', reject);
903
- writer.on('finish', resolve);
904
- });
905
-
906
1321
  let downloaded = 0;
907
1322
  for (const attachment of filtered) {
908
- const targetPath = uniquePathFor(attachmentsDir, attachment.title);
1323
+ const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title);
909
1324
  // Pass the full attachment object so downloadAttachment can use downloadLink directly
910
1325
  const dataStream = await client.downloadAttachment(pageId, attachment);
911
- await writeStream(dataStream, targetPath);
1326
+ await writeStream(fs, dataStream, targetPath);
912
1327
  downloaded += 1;
913
1328
  console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
914
1329
  }
@@ -925,6 +1340,219 @@ program
925
1340
  }
926
1341
  });
927
1342
 
1343
+ const EXPORT_MARKER = '.confluence-export.json';
1344
+
1345
+ function writeExportMarker(fs, path, exportDir, meta) {
1346
+ const marker = {
1347
+ exportedAt: new Date().toISOString(),
1348
+ pageId: meta.pageId,
1349
+ title: meta.title,
1350
+ tool: 'confluence-cli',
1351
+ };
1352
+ fs.writeFileSync(path.join(exportDir, EXPORT_MARKER), JSON.stringify(marker, null, 2));
1353
+ }
1354
+
1355
+ function isExportDirectory(fs, path, dir) {
1356
+ return fs.existsSync(path.join(dir, EXPORT_MARKER));
1357
+ }
1358
+
1359
+ function uniquePathFor(fs, path, dir, filename) {
1360
+ const parsed = path.parse(filename);
1361
+ let attempt = path.join(dir, filename);
1362
+ let counter = 1;
1363
+ while (fs.existsSync(attempt)) {
1364
+ const suffix = ` (${counter})`;
1365
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
1366
+ attempt = path.join(dir, nextName);
1367
+ counter += 1;
1368
+ }
1369
+ return attempt;
1370
+ }
1371
+
1372
+ function writeStream(fs, stream, targetPath) {
1373
+ return new Promise((resolve, reject) => {
1374
+ const writer = fs.createWriteStream(targetPath);
1375
+ stream.pipe(writer);
1376
+ stream.on('error', reject);
1377
+ writer.on('error', reject);
1378
+ writer.on('finish', resolve);
1379
+ });
1380
+ }
1381
+
1382
+ async function exportRecursive(client, fs, path, pageId, options) {
1383
+ const maxDepth = options.maxDepth || 10;
1384
+ const delayMs = options.delayMs != null ? options.delayMs : 100;
1385
+ const excludePatterns = options.exclude
1386
+ ? options.exclude.split(',').map(p => p.trim()).filter(Boolean)
1387
+ : [];
1388
+ const format = (options.format || 'markdown').toLowerCase();
1389
+ const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
1390
+ const contentExt = formatExt[format] || 'txt';
1391
+ const contentFile = options.file || `page.${contentExt}`;
1392
+ const baseDir = path.resolve(options.dest || '.');
1393
+
1394
+ // 1. Fetch root page
1395
+ const rootPage = await client.getPageInfo(pageId);
1396
+ console.log(`Fetching descendants of "${chalk.blue(rootPage.title)}"...`);
1397
+
1398
+ // 2. Fetch all descendants
1399
+ const descendants = await client.getAllDescendantPages(pageId, maxDepth);
1400
+
1401
+ // 3. Filter by exclude patterns
1402
+ const allPages = [{ id: rootPage.id, title: rootPage.title, parentId: null }];
1403
+ for (const page of descendants) {
1404
+ if (excludePatterns.length && client.shouldExcludePage(page.title, excludePatterns)) {
1405
+ continue;
1406
+ }
1407
+ allPages.push(page);
1408
+ }
1409
+
1410
+ // 4. Build tree
1411
+ const tree = client.buildPageTree(allPages.slice(1), pageId);
1412
+
1413
+ const totalPages = allPages.length;
1414
+ console.log(`Found ${chalk.blue(totalPages)} page${totalPages === 1 ? '' : 's'} to export.`);
1415
+
1416
+ // 5. Dry run — print tree and return
1417
+ if (options.dryRun) {
1418
+ const printTree = (nodes, indent = '') => {
1419
+ for (const node of nodes) {
1420
+ console.log(`${indent}${chalk.blue(node.title)} (${node.id})`);
1421
+ if (node.children && node.children.length) {
1422
+ printTree(node.children, indent + ' ');
1423
+ }
1424
+ }
1425
+ };
1426
+ console.log(`\n${chalk.blue(rootPage.title)} (${rootPage.id})`);
1427
+ printTree(tree, ' ');
1428
+ console.log(chalk.yellow('\nDry run — no files written.'));
1429
+ return;
1430
+ }
1431
+
1432
+ // 6. Overwrite — remove existing root export directory for a clean slate
1433
+ if (options.overwrite) {
1434
+ const rootFolderName = sanitizeTitle(rootPage.title);
1435
+ const rootExportDir = path.join(baseDir, rootFolderName);
1436
+ if (fs.existsSync(rootExportDir)) {
1437
+ if (!isExportDirectory(fs, path, rootExportDir)) {
1438
+ throw new Error(`Refusing to overwrite "${rootExportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`);
1439
+ }
1440
+ fs.rmSync(rootExportDir, { recursive: true, force: true });
1441
+ }
1442
+ }
1443
+
1444
+ // 7. Walk tree depth-first and export each page
1445
+ const failures = [];
1446
+ let exported = 0;
1447
+
1448
+ async function exportPage(page, dir) {
1449
+ exported += 1;
1450
+ console.log(`[${exported}/${totalPages}] Exporting: ${chalk.blue(page.title)}`);
1451
+
1452
+ const folderName = sanitizeTitle(page.title);
1453
+ let exportDir = path.join(dir, folderName);
1454
+
1455
+ // Handle duplicate sibling folder names
1456
+ if (fs.existsSync(exportDir)) {
1457
+ let counter = 1;
1458
+ while (fs.existsSync(`${exportDir} (${counter})`)) {
1459
+ counter += 1;
1460
+ }
1461
+ exportDir = `${exportDir} (${counter})`;
1462
+ }
1463
+ fs.mkdirSync(exportDir, { recursive: true });
1464
+
1465
+ // Fetch content and write
1466
+ const content = await client.readPage(
1467
+ page.id,
1468
+ format,
1469
+ options.referencedOnly ? { extractReferencedAttachments: true } : {}
1470
+ );
1471
+ const referencedAttachments = options.referencedOnly
1472
+ ? (client._referencedAttachments || new Set())
1473
+ : null;
1474
+ fs.writeFileSync(path.join(exportDir, contentFile), content);
1475
+
1476
+ // Download attachments
1477
+ if (!options.skipAttachments) {
1478
+ const pattern = options.pattern ? options.pattern.trim() : null;
1479
+ const allAttachments = await client.getAllAttachments(page.id);
1480
+
1481
+ let filtered;
1482
+ if (pattern) {
1483
+ filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
1484
+ } else if (options.referencedOnly) {
1485
+ filtered = allAttachments.filter(att => referencedAttachments?.has(att.title));
1486
+ } else {
1487
+ filtered = allAttachments;
1488
+ }
1489
+
1490
+ if (filtered.length > 0) {
1491
+ const attachmentsDirName = options.attachmentsDir || 'attachments';
1492
+ const attachmentsDir = path.join(exportDir, attachmentsDirName);
1493
+ fs.mkdirSync(attachmentsDir, { recursive: true });
1494
+
1495
+ for (const attachment of filtered) {
1496
+ const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title);
1497
+ const dataStream = await client.downloadAttachment(page.id, attachment);
1498
+ await writeStream(fs, dataStream, targetPath);
1499
+ }
1500
+ }
1501
+ }
1502
+
1503
+ return exportDir;
1504
+ }
1505
+
1506
+ async function walkTree(nodes, parentDir) {
1507
+ for (let i = 0; i < nodes.length; i++) {
1508
+ const node = nodes[i];
1509
+ try {
1510
+ const nodeDir = await exportPage(node, parentDir);
1511
+ if (node.children && node.children.length) {
1512
+ await walkTree(node.children, nodeDir);
1513
+ }
1514
+ } catch (error) {
1515
+ failures.push({ id: node.id, title: node.title, error: error.message });
1516
+ console.error(chalk.red(` Failed: ${node.title} — ${error.message}`));
1517
+ }
1518
+
1519
+ // Rate limiting between pages
1520
+ if (delayMs > 0 && exported < totalPages) {
1521
+ await new Promise(resolve => setTimeout(resolve, delayMs));
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ // Export root page
1527
+ let rootDir;
1528
+ try {
1529
+ rootDir = await exportPage(rootPage, baseDir);
1530
+ writeExportMarker(fs, path, rootDir, { pageId, title: rootPage.title });
1531
+ } catch (error) {
1532
+ failures.push({ id: rootPage.id, title: rootPage.title, error: error.message });
1533
+ console.error(chalk.red(` Failed: ${rootPage.title} — ${error.message}`));
1534
+ // Can't continue without root directory
1535
+ throw new Error(`Failed to export root page: ${error.message}`);
1536
+ }
1537
+
1538
+ if (delayMs > 0 && tree.length > 0) {
1539
+ await new Promise(resolve => setTimeout(resolve, delayMs));
1540
+ }
1541
+
1542
+ // Export descendants
1543
+ await walkTree(tree, rootDir);
1544
+
1545
+ // 8. Summary
1546
+ const succeeded = exported - failures.length;
1547
+ console.log(chalk.green(`\n✅ Exported ${succeeded}/${totalPages} page${totalPages === 1 ? '' : 's'} to ${rootDir}`));
1548
+ if (failures.length > 0) {
1549
+ console.log(chalk.red(`\n${failures.length} failure${failures.length === 1 ? '' : 's'}:`));
1550
+ for (const f of failures) {
1551
+ console.log(chalk.red(` - ${f.title} (${f.id}): ${f.error}`));
1552
+ }
1553
+ }
1554
+ }
1555
+
928
1556
  function sanitizeTitle(value) {
929
1557
  const fallback = 'page';
930
1558
  if (!value || typeof value !== 'string') {
@@ -991,9 +1619,10 @@ program
991
1619
  .action(async (sourcePageId, targetParentId, newTitle, options) => {
992
1620
  const analytics = new Analytics();
993
1621
  try {
994
- const config = getConfig();
1622
+ const config = getConfig(getProfileName());
1623
+ assertWritable(config);
995
1624
  const client = new ConfluenceClient(config);
996
-
1625
+
997
1626
  // Parse numeric flags with safe fallbacks
998
1627
  const parsedDepth = parseInt(options.maxDepth, 10);
999
1628
  const maxDepth = Number.isNaN(parsedDepth) ? 10 : parsedDepth;
@@ -1084,7 +1713,7 @@ program
1084
1713
  console.log(` - ...and ${result.failures.length - 10} more`);
1085
1714
  }
1086
1715
  }
1087
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result.rootPage._links.webui}`)}`);
1716
+ console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result.rootPage._links.webui}`)}`)}`);
1088
1717
  if (options.failOnError && result.failures?.length) {
1089
1718
  analytics.track('copy_tree', false);
1090
1719
  console.error(chalk.red('Completed with failures and --fail-on-error is set.'));
@@ -1111,7 +1740,7 @@ program
1111
1740
  .action(async (pageId, options) => {
1112
1741
  const analytics = new Analytics();
1113
1742
  try {
1114
- const config = getConfig();
1743
+ const config = getConfig(getProfileName());
1115
1744
  const client = new ConfluenceClient(config);
1116
1745
 
1117
1746
  // Extract page ID from URL if needed
@@ -1146,7 +1775,7 @@ program
1146
1775
  type: page.type,
1147
1776
  status: page.status,
1148
1777
  spaceKey: page.space?.key,
1149
- url: `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`,
1778
+ url: `${buildPageUrl(config, `/wiki/spaces/${page.space?.key}/pages/${page.id}`)}`,
1150
1779
  parentId: page.parentId || resolvedPageId
1151
1780
  }))
1152
1781
  };
@@ -1175,7 +1804,7 @@ program
1175
1804
  }
1176
1805
 
1177
1806
  if (options.showUrl) {
1178
- const url = `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`;
1807
+ const url = `${buildPageUrl(config, `/wiki/spaces/${page.space?.key}/pages/${page.id}`)}`;
1179
1808
  output += `\n ${chalk.gray(url)}`;
1180
1809
  }
1181
1810
 
@@ -1240,7 +1869,7 @@ function printTree(nodes, config, options, depth = 1) {
1240
1869
  }
1241
1870
 
1242
1871
  if (options.showUrl) {
1243
- const url = `https://${config.domain}/wiki/spaces/${node.space?.key}/pages/${node.id}`;
1872
+ const url = `${buildPageUrl(config, `/wiki/spaces/${node.space?.key}/pages/${node.id}`)}`;
1244
1873
  output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`;
1245
1874
  }
1246
1875
 
@@ -1252,8 +1881,99 @@ function printTree(nodes, config, options, depth = 1) {
1252
1881
  });
1253
1882
  }
1254
1883
 
1255
- if (process.argv.length <= 2) {
1256
- program.help({ error: false });
1257
- }
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
+ });
1258
1905
 
1259
- program.parse(process.argv);
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
+
1960
+ // Exported for testing
1961
+ module.exports = {
1962
+ program,
1963
+ _test: {
1964
+ EXPORT_MARKER,
1965
+ writeExportMarker,
1966
+ isExportDirectory,
1967
+ uniquePathFor,
1968
+ exportRecursive,
1969
+ sanitizeTitle,
1970
+ assertWritable,
1971
+ },
1972
+ };
1973
+
1974
+ if (require.main === module) {
1975
+ if (process.argv.length <= 2) {
1976
+ program.help({ error: false });
1977
+ }
1978
+ program.parse(process.argv);
1979
+ }