@aaronshaf/ger 2.0.2 → 2.0.4

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.
@@ -0,0 +1,42 @@
1
+ # ADR 0023: Surface Reviewers and CCs in `ger show`
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ `ger` supports reviewer management (`add-reviewer`, `remove-reviewer`) but did not expose a reliable way to view current reviewers for a change in one command.
10
+
11
+ This created a workflow gap:
12
+
13
+ - Users could mutate reviewer state but not inspect it from the CLI.
14
+ - `show` already served as the canonical "single change detail" command and was the best place to surface reviewer assignments.
15
+
16
+ ## Decision
17
+
18
+ Add reviewer visibility to `ger show` by:
19
+
20
+ 1. Keeping `getChange` lightweight and using a `listChanges` fallback (with detailed account/label options) when reviewer state is not present in the base change response.
21
+ 2. Extending `ChangeInfo` schema with Gerrit reviewer state maps (`REVIEWER`, `CC`, `REMOVED`).
22
+ 3. Rendering reviewers and CCs in all `show` output formats (pretty, JSON, XML).
23
+
24
+ ## Rationale
25
+
26
+ - **Single source of truth**: `show` remains the canonical command for full change context.
27
+ - **No new command surface**: avoids adding a narrowly scoped `list-reviewers` command.
28
+ - **Automation-friendly**: JSON/XML consumers can parse reviewer state without scraping text output.
29
+ - **Backward compatible**: reviewer fields are optional and do not break servers or older data shapes.
30
+
31
+ ## Consequences
32
+
33
+ ### Positive
34
+
35
+ - Users can verify reviewer assignment directly after add/remove operations.
36
+ - Better parity between mutation commands and read visibility.
37
+ - More complete machine-readable change payloads.
38
+
39
+ ### Negative
40
+
41
+ - Extra `listChanges` request when reviewer data is absent from `getChange`.
42
+ - Additional schema/output maintenance for reviewer state rendering.
@@ -28,3 +28,4 @@ Records of significant architectural decisions with context and rationale. Each
28
28
  | [0020](0020-code-coverage-enforcement.md) | 80% coverage threshold in pre-commit |
29
29
  | [0021](0021-typescript-isolated-declarations.md) | Explicit return types on exports |
30
30
  | [0022](0022-biome-oxlint-tooling.md) | Biome formatter + oxlint linter |
31
+ | [0023](0023-show-reviewer-list.md) | Surface reviewers and CCs in `ger show` |
@@ -23,11 +23,13 @@ ger show # Auto-detect from HEAD
23
23
 
24
24
  **Output includes:**
25
25
  - Change metadata (number, project, branch, status)
26
- - Owner and reviewer information
26
+ - Owner, reviewers, and CC information
27
27
  - Submit requirements
28
28
  - Full diff
29
29
  - All comments with context
30
30
 
31
+ Reviewer listing for a specific change is provided by `show` (there is no separate `list-reviewers` command).
32
+
31
33
  ### diff
32
34
 
33
35
  Get change diff in various formats.
@@ -14,25 +14,36 @@ const ChangeInfo = Schema.Struct({
14
14
  _number: Schema.Number, // Numeric change ID
15
15
  project: Schema.String, // Project name
16
16
  branch: Schema.String, // Target branch
17
+ change_id: Schema.String, // Gerrit Change-Id
17
18
  topic: Schema.optional(Schema.String),
18
19
  subject: Schema.String, // First line of commit message
19
20
  status: Schema.Literal('NEW', 'MERGED', 'ABANDONED', 'DRAFT'),
20
- created: Schema.String, // ISO timestamp
21
- updated: Schema.String, // ISO timestamp
22
- submitted: Schema.optional(Schema.String),
23
- submitter: Schema.optional(AccountInfo),
24
- owner: AccountInfo,
21
+ created: Schema.optional(Schema.String),
22
+ updated: Schema.optional(Schema.String),
23
+ owner: Schema.optional(AccountInfo),
25
24
  current_revision: Schema.optional(Schema.String),
26
25
  revisions: Schema.optional(Schema.Record(Schema.String, RevisionInfo)),
27
26
  labels: Schema.optional(Schema.Record(Schema.String, LabelInfo)),
28
- reviewers: Schema.optional(ReviewerMap),
29
- messages: Schema.optional(Schema.Array(ChangeMessage)),
30
- mergeable: Schema.optional(Schema.Boolean),
27
+ reviewers: Schema.optional(ReviewerStateMap),
28
+ submittable: Schema.optional(Schema.Boolean),
29
+ work_in_progress: Schema.optional(Schema.Boolean),
31
30
  insertions: Schema.optional(Schema.Number),
32
31
  deletions: Schema.optional(Schema.Number),
33
32
  })
34
33
  ```
35
34
 
35
+ ### ReviewerStateMap
36
+
37
+ Reviewer assignments grouped by Gerrit reviewer state.
38
+
39
+ ```typescript
40
+ const ReviewerStateMap = Schema.Struct({
41
+ REVIEWER: Schema.optional(Schema.Array(AccountInfo)),
42
+ CC: Schema.optional(Schema.Array(AccountInfo)),
43
+ REMOVED: Schema.optional(Schema.Array(AccountInfo)),
44
+ })
45
+ ```
46
+
36
47
  ### AccountInfo
37
48
 
38
49
  User account information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
5
5
  "keywords": [
6
6
  "gerrit",
@@ -4,6 +4,7 @@ import { type ApiError, GerritApiService } from '@/api/gerrit'
4
4
  interface AbandonOptions {
5
5
  message?: string
6
6
  xml?: boolean
7
+ json?: boolean
7
8
  }
8
9
 
9
10
  export const abandonCommand = (
@@ -26,7 +27,20 @@ export const abandonCommand = (
26
27
  // Perform the abandon
27
28
  yield* gerritApi.abandonChange(changeId, options.message)
28
29
 
29
- if (options.xml) {
30
+ if (options.json) {
31
+ console.log(
32
+ JSON.stringify(
33
+ {
34
+ status: 'success',
35
+ change_number: change._number,
36
+ subject: change.subject,
37
+ ...(options.message ? { message: options.message } : {}),
38
+ },
39
+ null,
40
+ 2,
41
+ ),
42
+ )
43
+ } else if (options.xml) {
30
44
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
31
45
  console.log(`<abandon_result>`)
32
46
  console.log(` <status>success</status>`)
@@ -46,7 +60,19 @@ export const abandonCommand = (
46
60
  // If we can't get change details, still try to abandon with just the ID
47
61
  yield* gerritApi.abandonChange(changeId, options.message)
48
62
 
49
- if (options.xml) {
63
+ if (options.json) {
64
+ console.log(
65
+ JSON.stringify(
66
+ {
67
+ status: 'success',
68
+ change_id: changeId,
69
+ ...(options.message ? { message: options.message } : {}),
70
+ },
71
+ null,
72
+ 2,
73
+ ),
74
+ )
75
+ } else if (options.xml) {
50
76
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
51
77
  console.log(`<abandon_result>`)
52
78
  console.log(` <status>success</status>`)
@@ -6,6 +6,7 @@ interface AddReviewerOptions {
6
6
  cc?: boolean
7
7
  notify?: string
8
8
  xml?: boolean
9
+ json?: boolean
9
10
  group?: boolean
10
11
  }
11
12
 
@@ -24,6 +25,10 @@ const outputXmlError = (message: string): void => {
24
25
  console.log(`</add_reviewer_result>`)
25
26
  }
26
27
 
28
+ const outputJsonError = (message: string): void => {
29
+ console.log(JSON.stringify({ status: 'error', error: message }, null, 2))
30
+ }
31
+
27
32
  class ValidationError extends Error {
28
33
  readonly _tag = 'ValidationError'
29
34
  }
@@ -40,7 +45,9 @@ export const addReviewerCommand = (
40
45
  if (!changeId) {
41
46
  const message =
42
47
  'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
43
- if (options.xml) {
48
+ if (options.json) {
49
+ outputJsonError(message)
50
+ } else if (options.xml) {
44
51
  outputXmlError(message)
45
52
  } else {
46
53
  console.error(`✗ ${message}`)
@@ -51,7 +58,9 @@ export const addReviewerCommand = (
51
58
  if (reviewers.length === 0) {
52
59
  const entityType = options.group ? 'group' : 'reviewer'
53
60
  const message = `At least one ${entityType} is required.`
54
- if (options.xml) {
61
+ if (options.json) {
62
+ outputJsonError(message)
63
+ } else if (options.xml) {
55
64
  outputXmlError(message)
56
65
  } else {
57
66
  console.error(`✗ ${message}`)
@@ -67,7 +76,9 @@ export const addReviewerCommand = (
67
76
  const emailLikeInputs = reviewers.filter((r) => r.includes('@'))
68
77
  if (emailLikeInputs.length > 0) {
69
78
  const message = `The --group flag expects group identifiers, but received email-like input: ${emailLikeInputs.join(', ')}. Did you mean to omit --group?`
70
- if (options.xml) {
79
+ if (options.json) {
80
+ outputJsonError(message)
81
+ } else if (options.xml) {
71
82
  outputXmlError(message)
72
83
  } else {
73
84
  console.error(`✗ ${message}`)
@@ -85,7 +96,9 @@ export const addReviewerCommand = (
85
96
  const upperNotify = options.notify.toUpperCase()
86
97
  if (!VALID_NOTIFY_LEVELS.includes(upperNotify as NotifyLevel)) {
87
98
  const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
88
- if (options.xml) {
99
+ if (options.json) {
100
+ outputJsonError(message)
101
+ } else if (options.xml) {
89
102
  outputXmlError(message)
90
103
  } else {
91
104
  console.error(`✗ ${message}`)
@@ -120,7 +133,26 @@ export const addReviewerCommand = (
120
133
  }
121
134
  }
122
135
 
123
- if (options.xml) {
136
+ if (options.json) {
137
+ const allSuccess = results.every((r) => r.success)
138
+ console.log(
139
+ JSON.stringify(
140
+ {
141
+ status: allSuccess ? 'success' : 'partial_failure',
142
+ change_id: changeId,
143
+ state,
144
+ entity_type: entityType,
145
+ reviewers: results.map((r) =>
146
+ r.success
147
+ ? { input: r.reviewer, name: r.name, status: 'added' }
148
+ : { input: r.reviewer, error: r.error, status: 'failed' },
149
+ ),
150
+ },
151
+ null,
152
+ 2,
153
+ ),
154
+ )
155
+ } else if (options.xml) {
124
156
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
125
157
  console.log(`<add_reviewer_result>`)
126
158
  console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
@@ -14,18 +14,27 @@ Examples:
14
14
  # Post a line-specific comment
15
15
  $ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
16
16
 
17
+ # Reply to a specific comment thread (resolves the thread by default)
18
+ $ ger comment 12345 --file src/main.js --line 42 --reply-to 37935b71_9e79a76c -m "Done, fixed"
19
+
20
+ # Reply but keep the thread unresolved
21
+ $ ger comment 12345 --file src/main.js --line 42 --reply-to 37935b71_9e79a76c --unresolved -m "What do you think?"
22
+
17
23
  # Post multiple comments using batch mode
18
24
  $ echo '{"message": "Review complete", "comments": [
19
25
  {"file": "src/main.js", "line": 10, "message": "Good refactor"}
20
26
  ]}' | ger comment 12345 --batch
21
27
 
22
- Note: Line numbers refer to the NEW version of the file, not diff line numbers.`
28
+ Note: Line numbers refer to the NEW version of the file, not diff line numbers.
29
+ Note: Comment IDs for --reply-to can be found in \`ger comments --xml\` or \`ger comments --json\` output (<id> / "id" field).`
23
30
 
24
31
  interface CommentOptions {
25
32
  message?: string
26
33
  xml?: boolean
34
+ json?: boolean
27
35
  file?: string
28
36
  line?: number
37
+ replyTo?: string
29
38
  unresolved?: boolean
30
39
  batch?: boolean
31
40
  }
@@ -185,6 +194,21 @@ export const createReviewInputFromString = (
185
194
  }
186
195
 
187
196
  const createReviewInput = (options: CommentOptions): Effect.Effect<ReviewInput, Error> => {
197
+ // Validate --reply-to constraints early
198
+ if (options.replyTo !== undefined) {
199
+ if (options.batch) {
200
+ return Effect.fail(new Error('--reply-to cannot be used with --batch'))
201
+ }
202
+ if (!(options.file && options.line)) {
203
+ return Effect.fail(new Error('--reply-to requires --file and --line'))
204
+ }
205
+ if (options.replyTo.trim().length === 0) {
206
+ return Effect.fail(new Error('--reply-to comment ID cannot be empty'))
207
+ }
208
+ // Normalize to trimmed value so the payload never contains leading/trailing whitespace
209
+ options = { ...options, replyTo: options.replyTo.trim() }
210
+ }
211
+
188
212
  // Batch mode
189
213
  if (options.batch) {
190
214
  return pipe(
@@ -226,7 +250,13 @@ const createReviewInput = (options: CommentOptions): Effect.Effect<ReviewInput,
226
250
  {
227
251
  line: options.line,
228
252
  message: options.message,
229
- unresolved: options.unresolved,
253
+ ...(options.replyTo !== undefined ? { in_reply_to: options.replyTo } : {}),
254
+ // When replying, default unresolved to false (resolves the thread) unless explicitly set
255
+ ...(options.replyTo !== undefined
256
+ ? { unresolved: options.unresolved ?? false }
257
+ : options.unresolved !== undefined
258
+ ? { unresolved: options.unresolved }
259
+ : {}),
230
260
  },
231
261
  ],
232
262
  },
@@ -428,8 +458,14 @@ const formatXmlOutput = (
428
458
  lines.push(` <comment>`)
429
459
  lines.push(` <file>${options.file}</file>`)
430
460
  lines.push(` <line>${options.line}</line>`)
461
+ if (options.replyTo) lines.push(` <in_reply_to>${options.replyTo}</in_reply_to>`)
431
462
  lines.push(` <message><![CDATA[${options.message}]]></message>`)
432
- if (options.unresolved) lines.push(` <unresolved>true</unresolved>`)
463
+ // Always emit unresolved when replying so callers know thread resolution state
464
+ if (options.replyTo !== undefined) {
465
+ lines.push(` <unresolved>${(options.unresolved ?? false).toString()}</unresolved>`)
466
+ } else if (options.unresolved) {
467
+ lines.push(` <unresolved>true</unresolved>`)
468
+ }
433
469
  lines.push(` </comment>`)
434
470
  } else {
435
471
  lines.push(` <message><![CDATA[${options.message}]]></message>`)
@@ -459,6 +495,10 @@ const formatHumanOutput = (
459
495
  console.log(`Posted ${totalComments} line comment(s)`)
460
496
  } else if (options.file && options.line) {
461
497
  console.log(`File: ${options.file}, Line: ${options.line}`)
498
+ if (options.replyTo) {
499
+ const resolved = !(options.unresolved ?? false)
500
+ console.log(`Reply to: ${options.replyTo} (thread ${resolved ? 'resolved' : 'unresolved'})`)
501
+ }
462
502
  console.log(`Message: ${options.message}`)
463
503
  if (options.unresolved) console.log(`Status: Unresolved`)
464
504
  }
@@ -466,6 +506,51 @@ const formatHumanOutput = (
466
506
  // since it was already shown in the "OVERALL REVIEW TO POST" section
467
507
  })
468
508
 
509
+ // Helper to format JSON output
510
+ const formatJsonOutput = (
511
+ change: ChangeInfo,
512
+ review: ReviewInput,
513
+ options: CommentOptions,
514
+ changeId: string,
515
+ ): Effect.Effect<void> =>
516
+ Effect.sync(() => {
517
+ const output: Record<string, unknown> = {
518
+ status: 'success',
519
+ change_id: changeId,
520
+ change_number: change._number,
521
+ change_subject: change.subject,
522
+ change_status: change.status,
523
+ }
524
+
525
+ if (options.batch && review.comments) {
526
+ output.comments = Object.entries(review.comments).flatMap(([file, comments]) =>
527
+ comments.map((comment) => ({
528
+ file,
529
+ ...(comment.line ? { line: comment.line } : {}),
530
+ message: comment.message,
531
+ ...(comment.unresolved ? { unresolved: true } : {}),
532
+ })),
533
+ )
534
+ } else if (options.file && options.line) {
535
+ output.comment = {
536
+ file: options.file,
537
+ line: options.line,
538
+ ...(options.replyTo ? { in_reply_to: options.replyTo } : {}),
539
+ message: options.message,
540
+ // Always include unresolved when replying so callers know thread resolution state
541
+ ...(options.replyTo !== undefined
542
+ ? { unresolved: options.unresolved ?? false }
543
+ : options.unresolved
544
+ ? { unresolved: true }
545
+ : {}),
546
+ }
547
+ } else {
548
+ output.message = options.message
549
+ }
550
+
551
+ console.log(JSON.stringify(output, null, 2))
552
+ })
553
+
469
554
  // Main output formatter
470
555
  const formatOutput = (
471
556
  change: ChangeInfo,
@@ -473,6 +558,8 @@ const formatOutput = (
473
558
  options: CommentOptions,
474
559
  changeId: string,
475
560
  ): Effect.Effect<void> =>
476
- options.xml
477
- ? formatXmlOutput(change, review, options, changeId)
478
- : formatHumanOutput(change, review, options)
561
+ options.json
562
+ ? formatJsonOutput(change, review, options, changeId)
563
+ : options.xml
564
+ ? formatXmlOutput(change, review, options, changeId)
565
+ : formatHumanOutput(change, review, options)
@@ -6,6 +6,7 @@ import { getDiffContext } from '@/utils/diff-context'
6
6
 
7
7
  interface CommentsOptions {
8
8
  xml?: boolean
9
+ json?: boolean
9
10
  }
10
11
 
11
12
  const getCommentsForChange = (
@@ -63,7 +64,36 @@ export const commentsCommand = (
63
64
  })
64
65
 
65
66
  // Format output
66
- if (options.xml) {
67
+ if (options.json) {
68
+ const jsonOutput = {
69
+ status: 'success',
70
+ change_id: changeId,
71
+ comment_count: commentsWithContext.length,
72
+ comments: commentsWithContext.map(({ comment, context }) => ({
73
+ id: comment.id,
74
+ ...(comment.path ? { path: comment.path } : {}),
75
+ ...(comment.line ? { line: comment.line } : {}),
76
+ ...(comment.range ? { range: comment.range } : {}),
77
+ ...(comment.author
78
+ ? {
79
+ author: {
80
+ ...(comment.author.name ? { name: comment.author.name } : {}),
81
+ ...(comment.author.email ? { email: comment.author.email } : {}),
82
+ ...(comment.author._account_id !== undefined
83
+ ? { account_id: comment.author._account_id }
84
+ : {}),
85
+ },
86
+ }
87
+ : {}),
88
+ ...(comment.updated ? { updated: comment.updated } : {}),
89
+ ...(comment.unresolved !== undefined ? { unresolved: comment.unresolved } : {}),
90
+ ...(comment.in_reply_to ? { in_reply_to: comment.in_reply_to } : {}),
91
+ message: comment.message,
92
+ ...(context ? { diff_context: context } : {}),
93
+ })),
94
+ }
95
+ console.log(JSON.stringify(jsonOutput, null, 2))
96
+ } else if (options.xml) {
67
97
  formatCommentsXml(changeId, commentsWithContext)
68
98
  } else {
69
99
  formatCommentsPretty(commentsWithContext)
@@ -71,7 +101,9 @@ export const commentsCommand = (
71
101
  }).pipe(
72
102
  // Regional error boundary for the entire command
73
103
  Effect.catchTag('ApiError', (error) => {
74
- if (options.xml) {
104
+ if (options.json) {
105
+ console.log(JSON.stringify({ status: 'error', error: error.message }, null, 2))
106
+ } else if (options.xml) {
75
107
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
76
108
  console.log(`<comments_result>`)
77
109
  console.log(` <status>error</status>`)
@@ -35,7 +35,19 @@ export const diffCommand = (
35
35
  ),
36
36
  )
37
37
 
38
- if (validatedOptions.xml) {
38
+ if (validatedOptions.json) {
39
+ // JSON output
40
+ const jsonOutput: Record<string, unknown> = {
41
+ status: 'success',
42
+ change_id: changeId,
43
+ }
44
+ if (Array.isArray(diff)) {
45
+ jsonOutput.files = diff
46
+ } else {
47
+ jsonOutput.content = diff
48
+ }
49
+ console.log(JSON.stringify(jsonOutput, null, 2))
50
+ } else if (validatedOptions.xml) {
39
51
  // XML output for LLM consumption
40
52
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
41
53
  console.log(`<diff_result>`)
@@ -4,6 +4,7 @@ import { sanitizeCDATA } from '@/utils/shell-safety'
4
4
 
5
5
  interface GroupsMembersOptions {
6
6
  xml?: boolean
7
+ json?: boolean
7
8
  }
8
9
 
9
10
  /**
@@ -25,7 +26,9 @@ export const groupsMembersCommand = (
25
26
  const members = yield* gerritApi.getGroupMembers(groupId).pipe(
26
27
  Effect.catchTag('ApiError', (error) =>
27
28
  Effect.gen(function* () {
28
- if (options.xml) {
29
+ if (options.json) {
30
+ console.log(JSON.stringify({ status: 'error', error: error.message }, null, 2))
31
+ } else if (options.xml) {
29
32
  console.log('<?xml version="1.0" encoding="UTF-8"?>')
30
33
  console.log('<group_members_result>')
31
34
  console.log(' <status>error</status>')
@@ -47,7 +50,11 @@ export const groupsMembersCommand = (
47
50
 
48
51
  // Handle empty results
49
52
  if (members.length === 0) {
50
- if (options.xml) {
53
+ if (options.json) {
54
+ console.log(
55
+ JSON.stringify({ status: 'success', group_id: groupId, count: 0, members: [] }, null, 2),
56
+ )
57
+ } else if (options.xml) {
51
58
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
52
59
  console.log(`<group_members_result>`)
53
60
  console.log(` <status>success</status>`)
@@ -62,7 +69,25 @@ export const groupsMembersCommand = (
62
69
  }
63
70
 
64
71
  // Output results
65
- if (options.xml) {
72
+ if (options.json) {
73
+ console.log(
74
+ JSON.stringify(
75
+ {
76
+ status: 'success',
77
+ group_id: groupId,
78
+ count: members.length,
79
+ members: members.map((member) => ({
80
+ account_id: member._account_id,
81
+ ...(member.name ? { name: member.name } : {}),
82
+ ...(member.email ? { email: member.email } : {}),
83
+ ...(member.username ? { username: member.username } : {}),
84
+ })),
85
+ },
86
+ null,
87
+ 2,
88
+ ),
89
+ )
90
+ } else if (options.xml) {
66
91
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
67
92
  console.log(`<group_members_result>`)
68
93
  console.log(` <status>success</status>`)
@@ -4,6 +4,7 @@ import { sanitizeCDATA } from '@/utils/shell-safety'
4
4
 
5
5
  interface GroupsShowOptions {
6
6
  xml?: boolean
7
+ json?: boolean
7
8
  }
8
9
 
9
10
  /**
@@ -25,7 +26,9 @@ export const groupsShowCommand = (
25
26
  const group = yield* gerritApi.getGroupDetail(groupId).pipe(
26
27
  Effect.catchTag('ApiError', (error) =>
27
28
  Effect.gen(function* () {
28
- if (options.xml) {
29
+ if (options.json) {
30
+ console.log(JSON.stringify({ status: 'error', error: error.message }, null, 2))
31
+ } else if (options.xml) {
29
32
  console.log('<?xml version="1.0" encoding="UTF-8"?>')
30
33
  console.log('<group_detail_result>')
31
34
  console.log(' <status>error</status>')
@@ -46,7 +49,40 @@ export const groupsShowCommand = (
46
49
  )
47
50
 
48
51
  // Output results
49
- if (options.xml) {
52
+ if (options.json) {
53
+ console.log(
54
+ JSON.stringify(
55
+ {
56
+ status: 'success',
57
+ group: {
58
+ id: group.id,
59
+ ...(group.name ? { name: group.name } : {}),
60
+ ...(group.description ? { description: group.description } : {}),
61
+ ...(group.owner ? { owner: group.owner } : {}),
62
+ ...(group.owner_id ? { owner_id: group.owner_id } : {}),
63
+ ...(group.group_id !== undefined ? { group_id: group.group_id } : {}),
64
+ ...(group.options?.visible_to_all !== undefined
65
+ ? { visible_to_all: group.options.visible_to_all }
66
+ : {}),
67
+ ...(group.created_on ? { created_on: group.created_on } : {}),
68
+ ...(group.url ? { url: group.url } : {}),
69
+ members: (group.members ?? []).map((member) => ({
70
+ account_id: member._account_id,
71
+ ...(member.name ? { name: member.name } : {}),
72
+ ...(member.email ? { email: member.email } : {}),
73
+ ...(member.username ? { username: member.username } : {}),
74
+ })),
75
+ subgroups: (group.includes ?? []).map((subgroup) => ({
76
+ id: subgroup.id,
77
+ ...(subgroup.name ? { name: subgroup.name } : {}),
78
+ })),
79
+ },
80
+ },
81
+ null,
82
+ 2,
83
+ ),
84
+ )
85
+ } else if (options.xml) {
50
86
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
51
87
  console.log(`<group_detail_result>`)
52
88
  console.log(` <status>success</status>`)
@@ -9,6 +9,7 @@ interface GroupsOptions {
9
9
  user?: string
10
10
  limit?: string
11
11
  xml?: boolean
12
+ json?: boolean
12
13
  }
13
14
 
14
15
  /**
@@ -44,7 +45,9 @@ export const groupsCommand = (
44
45
  .pipe(
45
46
  Effect.catchTag('ApiError', (error) =>
46
47
  Effect.gen(function* () {
47
- if (options.xml) {
48
+ if (options.json) {
49
+ console.log(JSON.stringify({ status: 'error', error: error.message }, null, 2))
50
+ } else if (options.xml) {
48
51
  console.log('<?xml version="1.0" encoding="UTF-8"?>')
49
52
  console.log('<groups_result>')
50
53
  console.log(' <status>error</status>')
@@ -64,7 +67,9 @@ export const groupsCommand = (
64
67
 
65
68
  // Handle empty results
66
69
  if (groups.length === 0) {
67
- if (options.xml) {
70
+ if (options.json) {
71
+ console.log(JSON.stringify({ status: 'success', count: 0, groups: [] }, null, 2))
72
+ } else if (options.xml) {
68
73
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
69
74
  console.log(`<groups_result>`)
70
75
  console.log(` <status>success</status>`)
@@ -78,7 +83,31 @@ export const groupsCommand = (
78
83
  }
79
84
 
80
85
  // Output results
81
- if (options.xml) {
86
+ if (options.json) {
87
+ console.log(
88
+ JSON.stringify(
89
+ {
90
+ status: 'success',
91
+ count: groups.length,
92
+ groups: groups.map((group) => ({
93
+ id: group.id,
94
+ ...(group.name ? { name: group.name } : {}),
95
+ ...(group.description ? { description: group.description } : {}),
96
+ ...(group.owner ? { owner: group.owner } : {}),
97
+ ...(group.owner_id ? { owner_id: group.owner_id } : {}),
98
+ ...(group.group_id !== undefined ? { group_id: group.group_id } : {}),
99
+ ...(group.options?.visible_to_all !== undefined
100
+ ? { visible_to_all: group.options.visible_to_all }
101
+ : {}),
102
+ ...(group.created_on ? { created_on: group.created_on } : {}),
103
+ ...(group.url ? { url: group.url } : {}),
104
+ })),
105
+ },
106
+ null,
107
+ 2,
108
+ ),
109
+ )
110
+ } else if (options.xml) {
82
111
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
83
112
  console.log(`<groups_result>`)
84
113
  console.log(` <status>success</status>`)