@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 +4 -4
- package/package.json +1 -1
- package/src/cli/commands/review.ts +54 -46
- package/src/cli/index.ts +11 -4
- package/src/services/review-strategy.ts +308 -0
- package/tests/unit/services/review-strategy.test.ts +494 -0
- package/src/services/ai-enhanced.ts +0 -168
- package/src/services/ai.ts +0 -190
- package/tests/ai-service.test.ts +0 -489
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.
|
|
14
|
+
"effect": "^3.17.9",
|
|
15
15
|
"signal-exit": "3.0.7",
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"@biomejs/biome": "^2.2.
|
|
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.
|
|
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.
|
|
25
|
+
"oxlint": "^1.13.0",
|
|
26
26
|
"typescript": "^5.9.2",
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, pipe, Schema, Layer } from 'effect'
|
|
2
|
-
import {
|
|
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
|
|
319
|
+
const reviewStrategy = yield* ReviewStrategyService
|
|
318
320
|
const gitService = yield* GitWorktreeService
|
|
319
321
|
|
|
320
|
-
// Load default prompts
|
|
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
|
|
327
|
-
yield* Console.log('→ Checking
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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(
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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('
|
|
403
|
+
Effect.catchTag('ReviewStrategyError', (error) =>
|
|
392
404
|
Effect.gen(function* () {
|
|
393
|
-
yield* Console.error(`✗
|
|
394
|
-
yield*
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
477
|
+
yield* Console.log(`[DEBUG] Running overall review with ${selectedStrategy.name}`)
|
|
464
478
|
}
|
|
465
479
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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('
|
|
486
|
+
Effect.catchTag('ReviewStrategyError', (error) =>
|
|
471
487
|
Effect.gen(function* () {
|
|
472
|
-
yield* Console.error(`✗
|
|
473
|
-
yield*
|
|
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
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
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(
|
|
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
|
+
)
|