@aaronshaf/ger 0.1.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.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,455 @@
1
+ import { Schema } from '@effect/schema'
2
+
3
+ // Authentication schemas
4
+ export const GerritCredentials: Schema.Schema<{
5
+ readonly host: string
6
+ readonly username: string
7
+ readonly password: string
8
+ }> = Schema.Struct({
9
+ host: Schema.String.pipe(
10
+ Schema.pattern(/^https?:\/\/.+$/),
11
+ Schema.annotations({ description: 'Gerrit server URL' }),
12
+ ),
13
+ username: Schema.String.pipe(
14
+ Schema.minLength(1),
15
+ Schema.annotations({ description: 'Gerrit username' }),
16
+ ),
17
+ password: Schema.String.pipe(
18
+ Schema.minLength(1),
19
+ Schema.annotations({ description: 'HTTP password or API token' }),
20
+ ),
21
+ })
22
+ export type GerritCredentials = Schema.Schema.Type<typeof GerritCredentials>
23
+
24
+ // Change schemas
25
+ export const ChangeInfo: Schema.Schema<{
26
+ readonly id: string
27
+ readonly project: string
28
+ readonly branch: string
29
+ readonly change_id: string
30
+ readonly subject: string
31
+ readonly status: 'NEW' | 'MERGED' | 'ABANDONED' | 'DRAFT'
32
+ readonly created?: string
33
+ readonly updated?: string
34
+ readonly insertions?: number
35
+ readonly deletions?: number
36
+ readonly _number: number
37
+ readonly owner?: {
38
+ readonly _account_id: number
39
+ readonly name?: string
40
+ readonly email?: string
41
+ readonly username?: string
42
+ }
43
+ readonly labels?: Record<
44
+ string,
45
+ {
46
+ readonly approved?: {
47
+ readonly _account_id: number
48
+ readonly name?: string
49
+ readonly email?: string
50
+ readonly username?: string
51
+ }
52
+ readonly rejected?: {
53
+ readonly _account_id: number
54
+ readonly name?: string
55
+ readonly email?: string
56
+ readonly username?: string
57
+ }
58
+ readonly recommended?: {
59
+ readonly _account_id: number
60
+ readonly name?: string
61
+ readonly email?: string
62
+ readonly username?: string
63
+ }
64
+ readonly disliked?: {
65
+ readonly _account_id: number
66
+ readonly name?: string
67
+ readonly email?: string
68
+ readonly username?: string
69
+ }
70
+ readonly value?: number
71
+ }
72
+ >
73
+ readonly submittable?: boolean
74
+ readonly work_in_progress?: boolean
75
+ }> = Schema.Struct({
76
+ id: Schema.String,
77
+ project: Schema.String,
78
+ branch: Schema.String,
79
+ change_id: Schema.String,
80
+ subject: Schema.String,
81
+ status: Schema.Literal('NEW', 'MERGED', 'ABANDONED', 'DRAFT'),
82
+ created: Schema.optional(Schema.String),
83
+ updated: Schema.optional(Schema.String),
84
+ insertions: Schema.optional(Schema.Number),
85
+ deletions: Schema.optional(Schema.Number),
86
+ _number: Schema.Number,
87
+ owner: Schema.optional(
88
+ Schema.Struct({
89
+ _account_id: Schema.Number,
90
+ name: Schema.optional(Schema.String),
91
+ email: Schema.optional(Schema.String),
92
+ username: Schema.optional(Schema.String),
93
+ }),
94
+ ),
95
+ labels: Schema.optional(
96
+ Schema.Record({
97
+ key: Schema.String,
98
+ value: Schema.Struct({
99
+ approved: Schema.optional(
100
+ Schema.Struct({
101
+ _account_id: Schema.Number,
102
+ name: Schema.optional(Schema.String),
103
+ email: Schema.optional(Schema.String),
104
+ username: Schema.optional(Schema.String),
105
+ }),
106
+ ),
107
+ rejected: Schema.optional(
108
+ Schema.Struct({
109
+ _account_id: Schema.Number,
110
+ name: Schema.optional(Schema.String),
111
+ email: Schema.optional(Schema.String),
112
+ username: Schema.optional(Schema.String),
113
+ }),
114
+ ),
115
+ recommended: Schema.optional(
116
+ Schema.Struct({
117
+ _account_id: Schema.Number,
118
+ name: Schema.optional(Schema.String),
119
+ email: Schema.optional(Schema.String),
120
+ username: Schema.optional(Schema.String),
121
+ }),
122
+ ),
123
+ disliked: Schema.optional(
124
+ Schema.Struct({
125
+ _account_id: Schema.Number,
126
+ name: Schema.optional(Schema.String),
127
+ email: Schema.optional(Schema.String),
128
+ username: Schema.optional(Schema.String),
129
+ }),
130
+ ),
131
+ value: Schema.optional(Schema.Number),
132
+ }),
133
+ }),
134
+ ),
135
+ submittable: Schema.optional(Schema.Boolean),
136
+ work_in_progress: Schema.optional(Schema.Boolean),
137
+ })
138
+ export type ChangeInfo = Schema.Schema.Type<typeof ChangeInfo>
139
+
140
+ // Comment schemas
141
+ export const CommentInput: Schema.Schema<{
142
+ readonly message: string
143
+ readonly unresolved?: boolean
144
+ }> = Schema.Struct({
145
+ message: Schema.String.pipe(
146
+ Schema.minLength(1),
147
+ Schema.annotations({ description: 'Comment message' }),
148
+ ),
149
+ unresolved: Schema.optional(Schema.Boolean),
150
+ })
151
+ export type CommentInput = Schema.Schema.Type<typeof CommentInput>
152
+
153
+ // Comment info returned from API
154
+ export const CommentInfo: Schema.Schema<{
155
+ readonly id: string
156
+ readonly path?: string
157
+ readonly line?: number
158
+ readonly range?: {
159
+ readonly start_line: number
160
+ readonly end_line: number
161
+ readonly start_character?: number
162
+ readonly end_character?: number
163
+ }
164
+ readonly message: string
165
+ readonly author?: {
166
+ readonly name?: string
167
+ readonly email?: string
168
+ readonly _account_id?: number
169
+ }
170
+ readonly updated?: string
171
+ readonly unresolved?: boolean
172
+ readonly in_reply_to?: string
173
+ }> = Schema.Struct({
174
+ id: Schema.String,
175
+ path: Schema.optional(Schema.String),
176
+ line: Schema.optional(Schema.Number),
177
+ range: Schema.optional(
178
+ Schema.Struct({
179
+ start_line: Schema.Number,
180
+ end_line: Schema.Number,
181
+ start_character: Schema.optional(Schema.Number),
182
+ end_character: Schema.optional(Schema.Number),
183
+ }),
184
+ ),
185
+ message: Schema.String,
186
+ author: Schema.optional(
187
+ Schema.Struct({
188
+ name: Schema.optional(Schema.String),
189
+ email: Schema.optional(Schema.String),
190
+ _account_id: Schema.optional(Schema.Number),
191
+ }),
192
+ ),
193
+ updated: Schema.optional(Schema.String),
194
+ unresolved: Schema.optional(Schema.Boolean),
195
+ in_reply_to: Schema.optional(Schema.String),
196
+ })
197
+ export type CommentInfo = Schema.Schema.Type<typeof CommentInfo>
198
+
199
+ // Message info for review messages
200
+ export const MessageInfo: Schema.Schema<{
201
+ readonly id: string
202
+ readonly message: string
203
+ readonly author?: {
204
+ readonly _account_id: number
205
+ readonly name?: string
206
+ readonly email?: string
207
+ }
208
+ readonly date: string
209
+ readonly _revision_number?: number
210
+ readonly tag?: string
211
+ }> = Schema.Struct({
212
+ id: Schema.String,
213
+ message: Schema.String,
214
+ author: Schema.optional(
215
+ Schema.Struct({
216
+ _account_id: Schema.Number,
217
+ name: Schema.optional(Schema.String),
218
+ email: Schema.optional(Schema.String),
219
+ }),
220
+ ),
221
+ date: Schema.String,
222
+ _revision_number: Schema.optional(Schema.Number),
223
+ tag: Schema.optional(Schema.String),
224
+ })
225
+ export type MessageInfo = Schema.Schema.Type<typeof MessageInfo>
226
+
227
+ export const ReviewInput: Schema.Schema<{
228
+ readonly message?: string
229
+ readonly labels?: Record<string, number>
230
+ readonly comments?: Record<
231
+ string,
232
+ ReadonlyArray<{
233
+ readonly line?: number
234
+ readonly range?: {
235
+ readonly start_line: number
236
+ readonly end_line: number
237
+ readonly start_character?: number
238
+ readonly end_character?: number
239
+ }
240
+ readonly message: string
241
+ readonly side?: 'PARENT' | 'REVISION'
242
+ readonly unresolved?: boolean
243
+ }>
244
+ >
245
+ }> = Schema.Struct({
246
+ message: Schema.optional(Schema.String),
247
+ labels: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Number })),
248
+ comments: Schema.optional(
249
+ Schema.Record({
250
+ key: Schema.String,
251
+ value: Schema.Array(
252
+ Schema.Struct({
253
+ line: Schema.optional(Schema.Number),
254
+ range: Schema.optional(
255
+ Schema.Struct({
256
+ start_line: Schema.Number,
257
+ end_line: Schema.Number,
258
+ start_character: Schema.optional(Schema.Number),
259
+ end_character: Schema.optional(Schema.Number),
260
+ }),
261
+ ),
262
+ message: Schema.String,
263
+ side: Schema.optional(Schema.Literal('PARENT', 'REVISION')),
264
+ unresolved: Schema.optional(Schema.Boolean),
265
+ }),
266
+ ),
267
+ }),
268
+ ),
269
+ })
270
+ export type ReviewInput = Schema.Schema.Type<typeof ReviewInput>
271
+
272
+ // File and diff schemas
273
+ export const FileInfo: Schema.Schema<{
274
+ readonly status?: 'A' | 'D' | 'R' | 'C' | 'M'
275
+ readonly lines_inserted?: number
276
+ readonly lines_deleted?: number
277
+ readonly size?: number
278
+ readonly size_delta?: number
279
+ readonly old_path?: string
280
+ }> = Schema.Struct({
281
+ status: Schema.optional(Schema.Literal('A', 'D', 'R', 'C', 'M')), // Added, Deleted, Renamed, Copied, Modified
282
+ lines_inserted: Schema.optional(Schema.Number),
283
+ lines_deleted: Schema.optional(Schema.Number),
284
+ size_delta: Schema.optional(Schema.Number),
285
+ size: Schema.optional(Schema.Number),
286
+ old_path: Schema.optional(Schema.String),
287
+ })
288
+ export type FileInfo = Schema.Schema.Type<typeof FileInfo>
289
+
290
+ export const FileDiffContent: Schema.Schema<{
291
+ readonly a?: string
292
+ readonly b?: string
293
+ readonly content: ReadonlyArray<{
294
+ readonly a?: ReadonlyArray<string>
295
+ readonly b?: ReadonlyArray<string>
296
+ readonly ab?: ReadonlyArray<string>
297
+ readonly edit_list?: ReadonlyArray<{
298
+ readonly op: 'i' | 'd' | 'r'
299
+ readonly a: ReadonlyArray<string>
300
+ readonly b: ReadonlyArray<string>
301
+ }>
302
+ readonly due_to_rebase?: boolean
303
+ readonly skip?: number
304
+ }>
305
+ readonly change_type?: 'ADDED' | 'MODIFIED' | 'DELETED' | 'RENAMED' | 'COPIED'
306
+ readonly diff_header?: ReadonlyArray<string>
307
+ }> = Schema.Struct({
308
+ a: Schema.optional(Schema.String), // Old file content path
309
+ b: Schema.optional(Schema.String), // New file content path
310
+ content: Schema.Array(
311
+ Schema.Struct({
312
+ a: Schema.optional(Schema.Array(Schema.String)), // Lines from old file
313
+ b: Schema.optional(Schema.Array(Schema.String)), // Lines from new file
314
+ ab: Schema.optional(Schema.Array(Schema.String)), // Common lines
315
+ edit_list: Schema.optional(
316
+ Schema.Array(
317
+ Schema.Struct({
318
+ op: Schema.Literal('i', 'd', 'r'), // insert, delete, replace
319
+ a: Schema.Array(Schema.String),
320
+ b: Schema.Array(Schema.String),
321
+ }),
322
+ ),
323
+ ),
324
+ due_to_rebase: Schema.optional(Schema.Boolean),
325
+ skip: Schema.optional(Schema.Number),
326
+ }),
327
+ ),
328
+ change_type: Schema.optional(Schema.Literal('ADDED', 'MODIFIED', 'DELETED', 'RENAMED', 'COPIED')),
329
+ diff_header: Schema.optional(Schema.Array(Schema.String)),
330
+ })
331
+ export type FileDiffContent = Schema.Schema.Type<typeof FileDiffContent>
332
+
333
+ export const RevisionInfo: Schema.Schema<{
334
+ readonly kind?: string
335
+ readonly _number: number
336
+ readonly created: string
337
+ readonly uploader: {
338
+ readonly _account_id: number
339
+ readonly name?: string
340
+ readonly email?: string
341
+ }
342
+ readonly ref: string
343
+ readonly fetch?: Record<string, unknown>
344
+ readonly commit?: {
345
+ readonly commit: string
346
+ readonly parents: ReadonlyArray<{
347
+ readonly commit: string
348
+ readonly subject: string
349
+ }>
350
+ readonly author: {
351
+ readonly name: string
352
+ readonly email: string
353
+ readonly date: string
354
+ }
355
+ readonly committer: {
356
+ readonly name: string
357
+ readonly email: string
358
+ readonly date: string
359
+ }
360
+ readonly subject: string
361
+ readonly message: string
362
+ }
363
+ }> = Schema.Struct({
364
+ kind: Schema.optional(Schema.String),
365
+ _number: Schema.Number,
366
+ created: Schema.String,
367
+ uploader: Schema.Struct({
368
+ _account_id: Schema.Number,
369
+ name: Schema.optional(Schema.String),
370
+ email: Schema.optional(Schema.String),
371
+ }),
372
+ ref: Schema.String,
373
+ fetch: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
374
+ commit: Schema.optional(
375
+ Schema.Struct({
376
+ commit: Schema.String,
377
+ parents: Schema.Array(
378
+ Schema.Struct({
379
+ commit: Schema.String,
380
+ subject: Schema.String,
381
+ }),
382
+ ),
383
+ author: Schema.Struct({
384
+ name: Schema.String,
385
+ email: Schema.String,
386
+ date: Schema.String,
387
+ }),
388
+ committer: Schema.Struct({
389
+ name: Schema.String,
390
+ email: Schema.String,
391
+ date: Schema.String,
392
+ }),
393
+ subject: Schema.String,
394
+ message: Schema.String,
395
+ }),
396
+ ),
397
+ files: Schema.optional(Schema.Record({ key: Schema.String, value: FileInfo })),
398
+ })
399
+ export type RevisionInfo = Schema.Schema.Type<typeof RevisionInfo>
400
+
401
+ // Diff output format options
402
+ export const DiffFormat: Schema.Schema<'unified' | 'json' | 'files'> = Schema.Literal(
403
+ 'unified',
404
+ 'json',
405
+ 'files',
406
+ )
407
+ export type DiffFormat = Schema.Schema.Type<typeof DiffFormat>
408
+
409
+ export const DiffOptions: Schema.Schema<{
410
+ readonly format?: 'unified' | 'json' | 'files'
411
+ readonly patchset?: number
412
+ readonly file?: string
413
+ readonly filesOnly?: boolean
414
+ readonly fullFiles?: boolean
415
+ readonly base?: number
416
+ readonly target?: number
417
+ }> = Schema.Struct({
418
+ format: Schema.optional(DiffFormat),
419
+ patchset: Schema.optional(Schema.Number),
420
+ file: Schema.optional(Schema.String),
421
+ filesOnly: Schema.optional(Schema.Boolean),
422
+ fullFiles: Schema.optional(Schema.Boolean),
423
+ base: Schema.optional(Schema.Number),
424
+ target: Schema.optional(Schema.Number),
425
+ })
426
+ export type DiffOptions = Schema.Schema.Type<typeof DiffOptions>
427
+
428
+ // Command options schemas
429
+ export const DiffCommandOptions: Schema.Schema<{
430
+ readonly xml?: boolean
431
+ readonly file?: string
432
+ readonly filesOnly?: boolean
433
+ readonly format?: 'unified' | 'json' | 'files'
434
+ }> = Schema.Struct({
435
+ xml: Schema.optional(Schema.Boolean),
436
+ file: Schema.optional(
437
+ Schema.String.pipe(
438
+ Schema.minLength(1),
439
+ Schema.annotations({ description: 'File path for diff (relative to repo root)' }),
440
+ ),
441
+ ),
442
+ filesOnly: Schema.optional(Schema.Boolean),
443
+ format: Schema.optional(DiffFormat),
444
+ })
445
+ export type DiffCommandOptions = Schema.Schema.Type<typeof DiffCommandOptions>
446
+
447
+ // API Response schemas
448
+ export const GerritError: Schema.Schema<{
449
+ readonly message: string
450
+ readonly status?: number
451
+ }> = Schema.Struct({
452
+ message: Schema.String,
453
+ status: Schema.optional(Schema.Number),
454
+ })
455
+ export type GerritError = Schema.Schema.Type<typeof GerritError>
@@ -0,0 +1,167 @@
1
+ import { Effect, Layer } from 'effect'
2
+ import { AiService, AiServiceError, NoAiToolFoundError, AiResponseParseError } from './ai'
3
+ import { ConfigService, ConfigServiceLive } from './config'
4
+ import { exec } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+
7
+ const execAsync = promisify(exec)
8
+
9
+ // Enhanced AI Service that uses configuration
10
+ export const AiServiceEnhanced = Layer.effect(
11
+ AiService,
12
+ Effect.gen(function* () {
13
+ const configService = yield* ConfigService
14
+
15
+ const detectAiTool = () =>
16
+ Effect.gen(function* () {
17
+ // First check configured preference
18
+ const aiConfig = yield* configService.getAiConfig.pipe(
19
+ Effect.orElseSucceed(() => ({ autoDetect: true })),
20
+ )
21
+
22
+ if ('tool' in aiConfig && aiConfig.tool && !aiConfig.autoDetect) {
23
+ // Check if configured tool is available
24
+ const result = yield* Effect.tryPromise({
25
+ try: () => execAsync(`which ${aiConfig.tool}`),
26
+ catch: () => null,
27
+ }).pipe(Effect.orElseSucceed(() => null))
28
+
29
+ if (result && result.stdout.trim()) {
30
+ return aiConfig.tool
31
+ }
32
+
33
+ // Configured tool not available, fall back to auto-detect
34
+ yield* Effect.logWarning(
35
+ `Configured AI tool '${aiConfig.tool}' not found, auto-detecting...`,
36
+ )
37
+ }
38
+
39
+ // Auto-detect available tools
40
+ const tools =
41
+ 'tool' in aiConfig && aiConfig.tool
42
+ ? [aiConfig.tool, 'claude', 'llm', 'opencode', 'gemini'].filter(
43
+ (v, i, a) => a.indexOf(v) === i,
44
+ )
45
+ : ['claude', 'llm', 'opencode', 'gemini']
46
+
47
+ for (const tool of tools) {
48
+ const result = yield* Effect.tryPromise({
49
+ try: () => execAsync(`which ${tool}`),
50
+ catch: () => null,
51
+ }).pipe(Effect.orElseSucceed(() => null))
52
+
53
+ if (result && result.stdout.trim()) {
54
+ return tool
55
+ }
56
+ }
57
+
58
+ return yield* Effect.fail(
59
+ new NoAiToolFoundError({
60
+ message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
61
+ }),
62
+ )
63
+ })
64
+
65
+ const extractResponseTag = (output: string) =>
66
+ Effect.gen(function* () {
67
+ // Extract content between <response> tags
68
+ const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
69
+
70
+ if (!responseMatch || !responseMatch[1]) {
71
+ return yield* Effect.fail(
72
+ new AiResponseParseError({
73
+ message: 'No <response> tag found in AI output',
74
+ rawOutput: output,
75
+ }),
76
+ )
77
+ }
78
+
79
+ return responseMatch[1].trim()
80
+ })
81
+
82
+ const runPrompt = (prompt: string, input: string) =>
83
+ Effect.gen(function* () {
84
+ const tool = yield* detectAiTool()
85
+
86
+ // Prepare the command based on the tool
87
+ const fullInput = `${prompt}\n\n${input}`
88
+ let command: string
89
+
90
+ switch (tool) {
91
+ case 'claude':
92
+ // Claude CLI uses -p flag for piped input
93
+ command = 'claude -p'
94
+ break
95
+ case 'llm':
96
+ // LLM CLI syntax
97
+ command = 'llm'
98
+ break
99
+ case 'opencode':
100
+ // Opencode CLI syntax
101
+ command = 'opencode'
102
+ break
103
+ case 'gemini':
104
+ // Gemini CLI syntax (adjust as needed)
105
+ command = 'gemini'
106
+ break
107
+ default:
108
+ command = tool
109
+ }
110
+
111
+ // Run the AI tool with the prompt and input
112
+ const result = yield* Effect.tryPromise({
113
+ try: async () => {
114
+ const child = require('node:child_process').spawn(command, {
115
+ shell: true,
116
+ stdio: ['pipe', 'pipe', 'pipe'],
117
+ })
118
+
119
+ // Write input to stdin
120
+ child.stdin.write(fullInput)
121
+ child.stdin.end()
122
+
123
+ // Collect output
124
+ let stdout = ''
125
+ let stderr = ''
126
+
127
+ child.stdout.on('data', (data: Buffer) => {
128
+ stdout += data.toString()
129
+ })
130
+
131
+ child.stderr.on('data', (data: Buffer) => {
132
+ stderr += data.toString()
133
+ })
134
+
135
+ return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
136
+ child.on('close', (code: number) => {
137
+ if (code !== 0) {
138
+ reject(new Error(`AI tool exited with code ${code}: ${stderr}`))
139
+ } else {
140
+ resolve({ stdout, stderr })
141
+ }
142
+ })
143
+
144
+ child.on('error', reject)
145
+ })
146
+ },
147
+ catch: (error) =>
148
+ new AiServiceError({
149
+ message: `Failed to run AI tool: ${error instanceof Error ? error.message : String(error)}`,
150
+ cause: error,
151
+ }),
152
+ })
153
+
154
+ // Extract response tag
155
+ return yield* extractResponseTag(result.stdout)
156
+ })
157
+
158
+ return AiService.of({
159
+ detectAiTool,
160
+ extractResponseTag,
161
+ runPrompt,
162
+ })
163
+ }),
164
+ ).pipe(Layer.provide(ConfigServiceLive))
165
+
166
+ // Export a simpler Live layer for backward compatibility
167
+ export const AiServiceLive = AiServiceEnhanced