@aaronshaf/ger 0.1.1 → 0.1.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.
package/bun.lock CHANGED
@@ -11,18 +11,18 @@
11
11
  "chalk": "^5.6.0",
12
12
  "cli-table3": "^0.6.5",
13
13
  "commander": "^14.0.0",
14
- "effect": "^3.17.8",
14
+ "effect": "^3.17.9",
15
15
  "signal-exit": "3.0.7",
16
16
  },
17
17
  "devDependencies": {
18
- "@biomejs/biome": "^2.2.0",
18
+ "@biomejs/biome": "^2.2.2",
19
19
  "@types/node": "^24.3.0",
20
20
  "ast-grep": "^0.1.0",
21
- "bun-types": "^1.2.20",
21
+ "bun-types": "^1.2.21",
22
22
  "husky": "^9.1.7",
23
23
  "lint-staged": "^16.1.5",
24
24
  "msw": "^2.10.5",
25
- "oxlint": "^1.12.0",
25
+ "oxlint": "^1.13.0",
26
26
  "typescript": "^5.9.2",
27
27
  },
28
28
  "peerDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  import { Effect, pipe, Schema, Layer } from 'effect'
2
- import { AiService } from '@/services/ai'
2
+ import { ReviewStrategyService, type ReviewStrategy } from '@/services/review-strategy'
3
3
  import { commentCommandWithInput } from './comment'
4
4
  import { Console } from 'effect'
5
5
  import { type ApiError, GerritApiService } from '@/api/gerrit'
@@ -89,6 +89,8 @@ interface ReviewOptions {
89
89
  comment?: boolean
90
90
  yes?: boolean
91
91
  prompt?: string
92
+ provider?: string
93
+ systemPrompt?: string
92
94
  }
93
95
 
94
96
  // Schema for validating AI-generated inline comments
@@ -314,21 +316,28 @@ const promptUser = (message: string): Effect.Effect<boolean, never> =>
314
316
 
315
317
  export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
316
318
  Effect.gen(function* () {
317
- const aiService = yield* AiService
319
+ const reviewStrategy = yield* ReviewStrategyService
318
320
  const gitService = yield* GitWorktreeService
319
321
 
320
- // Load default prompts first
322
+ // Load default prompts
321
323
  const prompts = yield* loadDefaultPrompts
322
324
 
323
325
  // Validate preconditions
324
326
  yield* gitService.validatePreconditions()
325
327
 
326
- // Check for AI tool availability
327
- yield* Console.log('→ Checking for AI tool availability...')
328
- const aiTool = yield* aiService
329
- .detectAiTool()
330
- .pipe(Effect.catchTag('NoAiToolFoundError', (error) => Effect.fail(new Error(error.message))))
331
- yield* Console.log(`✓ Found AI tool: ${aiTool}`)
328
+ // Check for available AI strategies
329
+ yield* Console.log('→ Checking AI tool availability...')
330
+ const availableStrategies = yield* reviewStrategy.getAvailableStrategies()
331
+
332
+ if (availableStrategies.length === 0) {
333
+ return yield* Effect.fail(
334
+ new Error('No AI tools available. Please install claude, gemini, or codex CLI.'),
335
+ )
336
+ }
337
+
338
+ // Select strategy based on user preference
339
+ const selectedStrategy = yield* reviewStrategy.selectStrategy(options.provider)
340
+ yield* Console.log(`✓ Using AI tool: ${selectedStrategy.name}`)
332
341
 
333
342
  // Load custom review prompt if provided
334
343
  let userReviewPrompt = prompts.defaultReviewPrompt
@@ -379,36 +388,39 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
379
388
  changedFiles,
380
389
  )
381
390
 
391
+ // Run inline review using selected strategy
382
392
  if (options.debug) {
383
- yield* Console.log('[DEBUG] Running AI for inline comments...')
393
+ yield* Console.log(`[DEBUG] Running inline review with ${selectedStrategy.name}`)
384
394
  yield* Console.log(`[DEBUG] Working directory: ${worktreeInfo.path}`)
385
395
  }
386
396
 
387
- // Run inline review with worktree as working directory
388
- const inlineResponse = yield* aiService
389
- .runPrompt(inlinePrompt, '', { cwd: worktreeInfo.path })
397
+ const inlineResponse = yield* reviewStrategy
398
+ .executeWithStrategy(selectedStrategy, inlinePrompt, {
399
+ cwd: worktreeInfo.path,
400
+ systemPrompt: options.systemPrompt || prompts.inlineReviewSystemPrompt,
401
+ })
390
402
  .pipe(
391
- Effect.catchTag('AiResponseParseError', (error) =>
403
+ Effect.catchTag('ReviewStrategyError', (error) =>
392
404
  Effect.gen(function* () {
393
- yield* Console.error(`✗ Failed to parse AI response: ${error.message}`)
394
- yield* Console.error('Raw AI output:')
395
- yield* Console.error('-'.repeat(80))
396
- yield* Console.error(error.rawOutput || 'No output captured')
397
- yield* Console.error('-'.repeat(80))
398
- return yield* Effect.fail(error)
405
+ yield* Console.error(`✗ Inline review failed: ${error.message}`)
406
+ return yield* Effect.fail(new Error(error.message))
399
407
  }),
400
408
  ),
401
- Effect.catchTag('AiServiceError', (error) =>
402
- Effect.die(new Error(`AI service error: ${error.message}`)),
403
- ),
404
409
  )
405
410
 
406
411
  if (options.debug) {
407
- yield* Console.log(`[DEBUG] Inline response:\n${inlineResponse}`)
412
+ yield* Console.log(`[DEBUG] Inline review completed`)
413
+ yield* Console.log(`[DEBUG] Response length: ${inlineResponse.length} chars`)
408
414
  }
409
415
 
410
- // Response is already extracted by runPrompt
411
- const extractedInlineResponse = inlineResponse
416
+ // Response content is ready for parsing
417
+ const extractedInlineResponse = inlineResponse.trim()
418
+
419
+ if (options.debug) {
420
+ yield* Console.log(
421
+ `[DEBUG] Extracted response for parsing:\n${extractedInlineResponse}`,
422
+ )
423
+ }
412
424
 
413
425
  // Parse JSON array from response
414
426
  const inlineCommentsArray = yield* Effect.tryPromise({
@@ -418,8 +430,9 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
418
430
  Effect.catchAll((error) =>
419
431
  Effect.gen(function* () {
420
432
  yield* Console.error(`✗ Failed to parse inline comments JSON: ${error}`)
433
+ yield* Console.error(`Raw extracted response: "${extractedInlineResponse}"`)
421
434
  if (!options.debug) {
422
- yield* Console.error('Run with --debug to see raw AI output')
435
+ yield* Console.error('Run with --debug to see full AI output')
423
436
  }
424
437
  return yield* Effect.fail(error)
425
438
  }),
@@ -459,35 +472,32 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
459
472
  changedFiles,
460
473
  )
461
474
 
475
+ // Run overall review using selected strategy
462
476
  if (options.debug) {
463
- yield* Console.log('[DEBUG] Running AI for overall review...')
477
+ yield* Console.log(`[DEBUG] Running overall review with ${selectedStrategy.name}`)
464
478
  }
465
479
 
466
- // Run overall review
467
- const overallResponse = yield* aiService
468
- .runPrompt(overallPrompt, '', { cwd: worktreeInfo.path })
480
+ const overallResponse = yield* reviewStrategy
481
+ .executeWithStrategy(selectedStrategy, overallPrompt, {
482
+ cwd: worktreeInfo.path,
483
+ systemPrompt: options.systemPrompt || prompts.overallReviewSystemPrompt,
484
+ })
469
485
  .pipe(
470
- Effect.catchTag('AiResponseParseError', (error) =>
486
+ Effect.catchTag('ReviewStrategyError', (error) =>
471
487
  Effect.gen(function* () {
472
- yield* Console.error(`✗ Failed to parse AI response: ${error.message}`)
473
- yield* Console.error('Raw AI output:')
474
- yield* Console.error('-'.repeat(80))
475
- yield* Console.error(error.rawOutput || 'No output captured')
476
- yield* Console.error('-'.repeat(80))
477
- return yield* Effect.fail(error)
488
+ yield* Console.error(`✗ Overall review failed: ${error.message}`)
489
+ return yield* Effect.fail(new Error(error.message))
478
490
  }),
479
491
  ),
480
- Effect.catchTag('AiServiceError', (error) =>
481
- Effect.die(new Error(`AI service error: ${error.message}`)),
482
- ),
483
492
  )
484
493
 
485
494
  if (options.debug) {
486
- yield* Console.log(`[DEBUG] Overall response:\n${overallResponse}`)
495
+ yield* Console.log(`[DEBUG] Overall review completed`)
496
+ yield* Console.log(`[DEBUG] Response length: ${overallResponse.length} chars`)
487
497
  }
488
498
 
489
- // Response is already extracted by runPrompt
490
- const extractedOverallResponse = overallResponse
499
+ // Response content is ready for use
500
+ const extractedOverallResponse = overallResponse.trim()
491
501
 
492
502
  // Handle overall review output/posting
493
503
  yield* handleOverallReview(extractedOverallResponse, changeId, options)
@@ -549,8 +559,6 @@ const handleInlineComments = (
549
559
  } else {
550
560
  yield* Console.log('→ Inline comments not posted')
551
561
  }
552
- } else {
553
- yield* Console.log('\n→ No valid inline comments to post')
554
562
  }
555
563
  }
556
564
  })
package/src/cli/index.ts CHANGED
@@ -25,7 +25,7 @@ import { Command } from 'commander'
25
25
  import { Effect } from 'effect'
26
26
  import { GerritApiServiceLive } from '@/api/gerrit'
27
27
  import { ConfigServiceLive } from '@/services/config'
28
- import { AiServiceLive } from '@/services/ai-enhanced'
28
+ import { ReviewStrategyServiceLive } from '@/services/review-strategy'
29
29
  import { GitWorktreeServiceLive } from '@/services/git-worktree'
30
30
  import { abandonCommand } from './commands/abandon'
31
31
  import { commentCommand } from './commands/comment'
@@ -368,10 +368,15 @@ program
368
368
  .option('-y, --yes', 'Skip confirmation prompts when posting comments')
369
369
  .option('--debug', 'Show debug output including AI responses')
370
370
  .option('--prompt <file>', 'Path to custom review prompt file (e.g., ~/prompts/review.md)')
371
+ .option(
372
+ '--provider <provider>',
373
+ 'Preferred AI provider (claude-sdk, claude, gemini, codex, opencode)',
374
+ )
375
+ .option('--system-prompt <prompt>', 'Custom system prompt for the AI')
371
376
  .addHelpText(
372
377
  'after',
373
378
  `
374
- This command uses AI (claude, llm, or opencode CLI) to review a Gerrit change.
379
+ This command uses AI (Claude SDK, claude CLI, gemini CLI, codex CLI, or opencode CLI) to review a Gerrit change.
375
380
  It performs a two-stage review process:
376
381
 
377
382
  1. Generates inline comments for specific code issues
@@ -382,7 +387,7 @@ Use --comment to post the review to Gerrit (with confirmation prompts).
382
387
  Use --comment --yes to post without confirmation.
383
388
 
384
389
  Requirements:
385
- - One of these AI tools must be installed: claude, llm, or opencode
390
+ - One of these AI tools must be available: Claude SDK (ANTHROPIC_API_KEY), claude CLI, gemini CLI, codex CLI, or opencode CLI
386
391
  - Gerrit credentials must be configured (run 'ger setup' first)
387
392
 
388
393
  Examples:
@@ -406,8 +411,10 @@ Examples:
406
411
  yes: options.yes,
407
412
  debug: options.debug,
408
413
  prompt: options.prompt,
414
+ provider: options.provider,
415
+ systemPrompt: options.systemPrompt,
409
416
  }).pipe(
410
- Effect.provide(AiServiceLive),
417
+ Effect.provide(ReviewStrategyServiceLive),
411
418
  Effect.provide(GerritApiServiceLive),
412
419
  Effect.provide(ConfigServiceLive),
413
420
  Effect.provide(GitWorktreeServiceLive),
@@ -0,0 +1,308 @@
1
+ import { Context, Data, Effect, Layer } from 'effect'
2
+ import { Console } from 'effect'
3
+ import { exec } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+
6
+ const execAsync = promisify(exec)
7
+
8
+ // Simple strategy focused only on review needs
9
+ export class ReviewStrategyError extends Data.TaggedError('ReviewStrategyError')<{
10
+ message: string
11
+ cause?: unknown
12
+ }> {}
13
+
14
+ // Review strategy interface - focused on specific review patterns
15
+ export interface ReviewStrategy {
16
+ readonly name: string
17
+ readonly isAvailable: () => Effect.Effect<boolean, never>
18
+ readonly executeReview: (
19
+ prompt: string,
20
+ options?: { cwd?: string; systemPrompt?: string },
21
+ ) => Effect.Effect<string, ReviewStrategyError>
22
+ }
23
+
24
+ // Strategy implementations for different AI tools
25
+ export const claudeCliStrategy: ReviewStrategy = {
26
+ name: 'Claude CLI',
27
+ isAvailable: () =>
28
+ Effect.gen(function* () {
29
+ const result = yield* Effect.tryPromise({
30
+ try: () => execAsync('which claude'),
31
+ catch: () => null,
32
+ }).pipe(Effect.orElseSucceed(() => null))
33
+
34
+ return Boolean(result && result.stdout.trim())
35
+ }),
36
+ executeReview: (prompt, options = {}) =>
37
+ Effect.gen(function* () {
38
+ const result = yield* Effect.tryPromise({
39
+ try: async () => {
40
+ const child = require('node:child_process').spawn('claude -p', {
41
+ shell: true,
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ cwd: options.cwd || process.cwd(),
44
+ })
45
+
46
+ child.stdin.write(prompt)
47
+ child.stdin.end()
48
+
49
+ let stdout = ''
50
+ let stderr = ''
51
+
52
+ child.stdout.on('data', (data: Buffer) => {
53
+ stdout += data.toString()
54
+ })
55
+
56
+ child.stderr.on('data', (data: Buffer) => {
57
+ stderr += data.toString()
58
+ })
59
+
60
+ return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
61
+ child.on('close', (code: number) => {
62
+ if (code !== 0) {
63
+ reject(new Error(`Claude CLI exited with code ${code}: ${stderr}`))
64
+ } else {
65
+ resolve({ stdout, stderr })
66
+ }
67
+ })
68
+
69
+ child.on('error', reject)
70
+ })
71
+ },
72
+ catch: (error) =>
73
+ new ReviewStrategyError({
74
+ message: `Claude CLI failed: ${error instanceof Error ? error.message : String(error)}`,
75
+ cause: error,
76
+ }),
77
+ })
78
+
79
+ // Extract response from <response> tags or use full output
80
+ const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
81
+ return responseMatch ? responseMatch[1].trim() : result.stdout.trim()
82
+ }),
83
+ }
84
+
85
+ export const geminiCliStrategy: ReviewStrategy = {
86
+ name: 'Gemini CLI',
87
+ isAvailable: () =>
88
+ Effect.gen(function* () {
89
+ const result = yield* Effect.tryPromise({
90
+ try: () => execAsync('which gemini'),
91
+ catch: () => null,
92
+ }).pipe(Effect.orElseSucceed(() => null))
93
+
94
+ return Boolean(result && result.stdout.trim())
95
+ }),
96
+ executeReview: (prompt, options = {}) =>
97
+ Effect.gen(function* () {
98
+ const result = yield* Effect.tryPromise({
99
+ try: async () => {
100
+ const child = require('node:child_process').spawn('gemini -p', {
101
+ shell: true,
102
+ stdio: ['pipe', 'pipe', 'pipe'],
103
+ cwd: options.cwd || process.cwd(),
104
+ })
105
+
106
+ child.stdin.write(prompt)
107
+ child.stdin.end()
108
+
109
+ let stdout = ''
110
+ let stderr = ''
111
+
112
+ child.stdout.on('data', (data: Buffer) => {
113
+ stdout += data.toString()
114
+ })
115
+
116
+ child.stderr.on('data', (data: Buffer) => {
117
+ stderr += data.toString()
118
+ })
119
+
120
+ return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
121
+ child.on('close', (code: number) => {
122
+ if (code !== 0) {
123
+ reject(new Error(`Gemini CLI exited with code ${code}: ${stderr}`))
124
+ } else {
125
+ resolve({ stdout, stderr })
126
+ }
127
+ })
128
+
129
+ child.on('error', reject)
130
+ })
131
+ },
132
+ catch: (error) =>
133
+ new ReviewStrategyError({
134
+ message: `Gemini CLI failed: ${error instanceof Error ? error.message : String(error)}`,
135
+ cause: error,
136
+ }),
137
+ })
138
+
139
+ return result.stdout.trim()
140
+ }),
141
+ }
142
+
143
+ export const openCodeCliStrategy: ReviewStrategy = {
144
+ name: 'OpenCode CLI',
145
+ isAvailable: () =>
146
+ Effect.gen(function* () {
147
+ const result = yield* Effect.tryPromise({
148
+ try: () => execAsync('which opencode'),
149
+ catch: () => null,
150
+ }).pipe(Effect.orElseSucceed(() => null))
151
+
152
+ return Boolean(result && result.stdout.trim())
153
+ }),
154
+ executeReview: (prompt, options = {}) =>
155
+ Effect.gen(function* () {
156
+ const result = yield* Effect.tryPromise({
157
+ try: async () => {
158
+ const child = require('node:child_process').spawn('opencode -p', {
159
+ shell: true,
160
+ stdio: ['pipe', 'pipe', 'pipe'],
161
+ cwd: options.cwd || process.cwd(),
162
+ })
163
+
164
+ child.stdin.write(prompt)
165
+ child.stdin.end()
166
+
167
+ let stdout = ''
168
+ let stderr = ''
169
+
170
+ child.stdout.on('data', (data: Buffer) => {
171
+ stdout += data.toString()
172
+ })
173
+
174
+ child.stderr.on('data', (data: Buffer) => {
175
+ stderr += data.toString()
176
+ })
177
+
178
+ return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
179
+ child.on('close', (code: number) => {
180
+ if (code !== 0) {
181
+ reject(new Error(`OpenCode CLI exited with code ${code}: ${stderr}`))
182
+ } else {
183
+ resolve({ stdout, stderr })
184
+ }
185
+ })
186
+
187
+ child.on('error', reject)
188
+ })
189
+ },
190
+ catch: (error) =>
191
+ new ReviewStrategyError({
192
+ message: `OpenCode CLI failed: ${error instanceof Error ? error.message : String(error)}`,
193
+ cause: error,
194
+ }),
195
+ })
196
+
197
+ return result.stdout.trim()
198
+ }),
199
+ }
200
+
201
+ export const codexCliStrategy: ReviewStrategy = {
202
+ name: 'Codex CLI',
203
+ isAvailable: () =>
204
+ Effect.gen(function* () {
205
+ const result = yield* Effect.tryPromise({
206
+ try: () => execAsync('which codex'),
207
+ catch: () => null,
208
+ }).pipe(Effect.orElseSucceed(() => null))
209
+
210
+ return Boolean(result && result.stdout.trim())
211
+ }),
212
+ executeReview: (prompt, options = {}) =>
213
+ Effect.gen(function* () {
214
+ const command = `codex exec "${prompt.replace(/"/g, '\\"')}"`
215
+
216
+ const result = yield* Effect.tryPromise({
217
+ try: () => execAsync(command, { cwd: options.cwd }),
218
+ catch: (error) =>
219
+ new ReviewStrategyError({
220
+ message: `Codex CLI failed: ${error instanceof Error ? error.message : String(error)}`,
221
+ cause: error,
222
+ }),
223
+ })
224
+
225
+ return result.stdout.trim()
226
+ }),
227
+ }
228
+
229
+ // Review service using strategy pattern
230
+ export class ReviewStrategyService extends Context.Tag('ReviewStrategyService')<
231
+ ReviewStrategyService,
232
+ {
233
+ readonly getAvailableStrategies: () => Effect.Effect<ReviewStrategy[], never>
234
+ readonly selectStrategy: (
235
+ preferredName?: string,
236
+ ) => Effect.Effect<ReviewStrategy, ReviewStrategyError>
237
+ readonly executeWithStrategy: (
238
+ strategy: ReviewStrategy,
239
+ prompt: string,
240
+ options?: { cwd?: string; systemPrompt?: string },
241
+ ) => Effect.Effect<string, ReviewStrategyError>
242
+ }
243
+ >() {}
244
+
245
+ export const ReviewStrategyServiceLive = Layer.succeed(
246
+ ReviewStrategyService,
247
+ ReviewStrategyService.of({
248
+ getAvailableStrategies: () =>
249
+ Effect.gen(function* () {
250
+ const strategies = [
251
+ claudeCliStrategy,
252
+ geminiCliStrategy,
253
+ openCodeCliStrategy,
254
+ codexCliStrategy,
255
+ ]
256
+ const available: ReviewStrategy[] = []
257
+
258
+ for (const strategy of strategies) {
259
+ const isAvailable = yield* strategy.isAvailable()
260
+ if (isAvailable) {
261
+ available.push(strategy)
262
+ }
263
+ }
264
+
265
+ return available
266
+ }),
267
+
268
+ selectStrategy: (preferredName?: string) =>
269
+ Effect.gen(function* () {
270
+ const strategies = [
271
+ claudeCliStrategy,
272
+ geminiCliStrategy,
273
+ openCodeCliStrategy,
274
+ codexCliStrategy,
275
+ ]
276
+ const available: ReviewStrategy[] = []
277
+
278
+ for (const strategy of strategies) {
279
+ const isAvailable = yield* strategy.isAvailable()
280
+ if (isAvailable) {
281
+ available.push(strategy)
282
+ }
283
+ }
284
+
285
+ if (available.length === 0) {
286
+ return yield* Effect.fail(
287
+ new ReviewStrategyError({
288
+ message: 'No AI tools available. Please install claude, gemini, or codex CLI.',
289
+ }),
290
+ )
291
+ }
292
+
293
+ if (preferredName) {
294
+ const preferred = available.find((s: ReviewStrategy) =>
295
+ s.name.toLowerCase().includes(preferredName.toLowerCase()),
296
+ )
297
+ if (preferred) {
298
+ return preferred
299
+ }
300
+ }
301
+
302
+ return available[0] // Return first available
303
+ }),
304
+
305
+ executeWithStrategy: (strategy, prompt, options = {}) =>
306
+ strategy.executeReview(prompt, options),
307
+ }),
308
+ )