berget 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +56 -0
- package/.github/workflows/test.yml +38 -0
- package/README.md +177 -38
- package/dist/package.json +8 -2
- package/dist/src/commands/chat.js +166 -17
- package/dist/src/services/chat-service.js +10 -10
- package/dist/tests/commands/chat.test.js +107 -0
- package/dist/vitest.config.js +9 -0
- package/examples/README.md +95 -0
- package/examples/ai-review.sh +30 -0
- package/examples/install-global-security-hook.sh +170 -0
- package/examples/security-check.sh +102 -0
- package/examples/smart-commit.sh +26 -0
- package/package.json +8 -2
- package/src/commands/chat.ts +169 -17
- package/src/services/chat-service.ts +13 -23
- package/tests/commands/chat.test.ts +117 -0
- package/vitest.config.ts +8 -0
package/src/commands/chat.ts
CHANGED
|
@@ -37,7 +37,8 @@ export function registerChatCommands(program: Command): void {
|
|
|
37
37
|
chat
|
|
38
38
|
.command(SUBCOMMANDS.CHAT.RUN)
|
|
39
39
|
.description('Run a chat session with a specified model')
|
|
40
|
-
.argument('[model]', 'Model to use (default:
|
|
40
|
+
.argument('[model]', 'Model to use (default: openai/gpt-oss)')
|
|
41
|
+
.argument('[message]', 'Message to send directly (skips interactive mode)')
|
|
41
42
|
.option('-s, --system <message>', 'System message')
|
|
42
43
|
.option('-t, --temperature <temp>', 'Temperature (0-1)', parseFloat)
|
|
43
44
|
.option('-m, --max-tokens <tokens>', 'Maximum tokens to generate', parseInt)
|
|
@@ -46,8 +47,8 @@ export function registerChatCommands(program: Command): void {
|
|
|
46
47
|
'--api-key-id <id>',
|
|
47
48
|
'ID of the API key to use from your saved keys'
|
|
48
49
|
)
|
|
49
|
-
.option('--stream', '
|
|
50
|
-
.action(async (options) => {
|
|
50
|
+
.option('--no-stream', 'Disable streaming (streaming is enabled by default)')
|
|
51
|
+
.action(async (model, message, options) => {
|
|
51
52
|
try {
|
|
52
53
|
const chatService = ChatService.getInstance()
|
|
53
54
|
|
|
@@ -218,12 +219,6 @@ export function registerChatCommands(program: Command): void {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
// Set up readline interface for user input
|
|
222
|
-
const rl = readline.createInterface({
|
|
223
|
-
input: process.stdin,
|
|
224
|
-
output: process.stdout,
|
|
225
|
-
})
|
|
226
|
-
|
|
227
222
|
// Prepare messages array
|
|
228
223
|
const messages: ChatMessage[] = []
|
|
229
224
|
|
|
@@ -235,6 +230,139 @@ export function registerChatCommands(program: Command): void {
|
|
|
235
230
|
})
|
|
236
231
|
}
|
|
237
232
|
|
|
233
|
+
// Check if input is being piped in
|
|
234
|
+
let inputMessage = message
|
|
235
|
+
let stdinContent = ''
|
|
236
|
+
|
|
237
|
+
if (!process.stdin.isTTY) {
|
|
238
|
+
// Read from stdin (piped input)
|
|
239
|
+
const chunks = []
|
|
240
|
+
for await (const chunk of process.stdin) {
|
|
241
|
+
chunks.push(chunk)
|
|
242
|
+
}
|
|
243
|
+
stdinContent = Buffer.concat(chunks).toString('utf8').trim()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Combine stdin content with message if both exist
|
|
247
|
+
if (stdinContent && message) {
|
|
248
|
+
inputMessage = `${stdinContent}\n\n${message}`
|
|
249
|
+
} else if (stdinContent && !message) {
|
|
250
|
+
inputMessage = stdinContent
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If a message is provided (either as argument, from stdin, or both), send it directly and exit
|
|
254
|
+
if (inputMessage) {
|
|
255
|
+
// Add user message
|
|
256
|
+
messages.push({
|
|
257
|
+
role: 'user',
|
|
258
|
+
content: inputMessage,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Call the API
|
|
263
|
+
const completionOptions: ChatCompletionOptions = {
|
|
264
|
+
model: model || 'openai/gpt-oss',
|
|
265
|
+
messages: messages,
|
|
266
|
+
temperature:
|
|
267
|
+
options.temperature !== undefined ? options.temperature : 0.7,
|
|
268
|
+
max_tokens: options.maxTokens || 4096,
|
|
269
|
+
stream: options.stream !== false
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Only add apiKey if it actually exists
|
|
273
|
+
if (apiKey) {
|
|
274
|
+
completionOptions.apiKey = apiKey
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Add streaming support (now default)
|
|
278
|
+
if (completionOptions.stream) {
|
|
279
|
+
let assistantResponse = ''
|
|
280
|
+
|
|
281
|
+
// Stream the response in real-time
|
|
282
|
+
completionOptions.onChunk = (chunk: any) => {
|
|
283
|
+
if (chunk.choices && chunk.choices[0] && chunk.choices[0].delta && chunk.choices[0].delta.content) {
|
|
284
|
+
const content = chunk.choices[0].delta.content
|
|
285
|
+
try {
|
|
286
|
+
process.stdout.write(content)
|
|
287
|
+
} catch (error: any) {
|
|
288
|
+
// Handle EPIPE errors gracefully (when pipe is closed)
|
|
289
|
+
if (error.code === 'EPIPE') {
|
|
290
|
+
// Stop streaming if the pipe is closed
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
throw error
|
|
294
|
+
}
|
|
295
|
+
assistantResponse += content
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await chatService.createCompletion(completionOptions)
|
|
301
|
+
} catch (streamError) {
|
|
302
|
+
console.error(chalk.red('\nStreaming error:'), streamError)
|
|
303
|
+
|
|
304
|
+
// Fallback to non-streaming if streaming fails
|
|
305
|
+
console.log(chalk.yellow('Falling back to non-streaming mode...'))
|
|
306
|
+
completionOptions.stream = false
|
|
307
|
+
delete completionOptions.onChunk
|
|
308
|
+
|
|
309
|
+
const response = await chatService.createCompletion(completionOptions)
|
|
310
|
+
|
|
311
|
+
if (response && response.choices && response.choices[0] && response.choices[0].message) {
|
|
312
|
+
assistantResponse = response.choices[0].message.content
|
|
313
|
+
console.log(assistantResponse)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
console.log() // Add newline at the end
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const response = await chatService.createCompletion(
|
|
321
|
+
completionOptions
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
// Check if response has the expected structure
|
|
325
|
+
if (
|
|
326
|
+
!response ||
|
|
327
|
+
!response.choices ||
|
|
328
|
+
!response.choices[0] ||
|
|
329
|
+
!response.choices[0].message
|
|
330
|
+
) {
|
|
331
|
+
console.error(
|
|
332
|
+
chalk.red('Error: Unexpected response format from API')
|
|
333
|
+
)
|
|
334
|
+
console.error(
|
|
335
|
+
chalk.red('Response:', JSON.stringify(response, null, 2))
|
|
336
|
+
)
|
|
337
|
+
throw new Error('Unexpected response format from API')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Get assistant's response
|
|
341
|
+
const assistantMessage = response.choices[0].message.content
|
|
342
|
+
|
|
343
|
+
// Display the response
|
|
344
|
+
if (containsMarkdown(assistantMessage)) {
|
|
345
|
+
console.log(renderMarkdown(assistantMessage))
|
|
346
|
+
} else {
|
|
347
|
+
console.log(assistantMessage)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return
|
|
351
|
+
} catch (error) {
|
|
352
|
+
console.error(chalk.red('Error: Failed to get response'))
|
|
353
|
+
if (error instanceof Error) {
|
|
354
|
+
console.error(chalk.red(error.message))
|
|
355
|
+
}
|
|
356
|
+
process.exit(1)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Set up readline interface for user input (only for interactive mode)
|
|
361
|
+
const rl = readline.createInterface({
|
|
362
|
+
input: process.stdin,
|
|
363
|
+
output: process.stdout,
|
|
364
|
+
})
|
|
365
|
+
|
|
238
366
|
console.log(chalk.cyan('Chat with Berget AI (type "exit" to quit)'))
|
|
239
367
|
console.log(chalk.cyan('----------------------------------------'))
|
|
240
368
|
|
|
@@ -257,12 +385,12 @@ export function registerChatCommands(program: Command): void {
|
|
|
257
385
|
try {
|
|
258
386
|
// Call the API
|
|
259
387
|
const completionOptions: ChatCompletionOptions = {
|
|
260
|
-
model:
|
|
388
|
+
model: model || 'openai/gpt-oss',
|
|
261
389
|
messages: messages,
|
|
262
390
|
temperature:
|
|
263
391
|
options.temperature !== undefined ? options.temperature : 0.7,
|
|
264
392
|
max_tokens: options.maxTokens || 4096,
|
|
265
|
-
stream: options.stream
|
|
393
|
+
stream: options.stream !== false
|
|
266
394
|
}
|
|
267
395
|
|
|
268
396
|
// Only add apiKey if it actually exists
|
|
@@ -270,22 +398,46 @@ export function registerChatCommands(program: Command): void {
|
|
|
270
398
|
completionOptions.apiKey = apiKey
|
|
271
399
|
}
|
|
272
400
|
|
|
273
|
-
// Add streaming support
|
|
274
|
-
if (
|
|
401
|
+
// Add streaming support (now default)
|
|
402
|
+
if (completionOptions.stream) {
|
|
275
403
|
let assistantResponse = ''
|
|
276
404
|
console.log(chalk.blue('Assistant: '))
|
|
277
405
|
|
|
278
|
-
//
|
|
279
|
-
// since markdown needs the complete text to render properly
|
|
406
|
+
// Stream the response in real-time
|
|
280
407
|
completionOptions.onChunk = (chunk: any) => {
|
|
281
408
|
if (chunk.choices && chunk.choices[0] && chunk.choices[0].delta && chunk.choices[0].delta.content) {
|
|
282
409
|
const content = chunk.choices[0].delta.content
|
|
283
|
-
|
|
410
|
+
try {
|
|
411
|
+
process.stdout.write(content)
|
|
412
|
+
} catch (error: any) {
|
|
413
|
+
// Handle EPIPE errors gracefully (when pipe is closed)
|
|
414
|
+
if (error.code === 'EPIPE') {
|
|
415
|
+
// Stop streaming if the pipe is closed
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
throw error
|
|
419
|
+
}
|
|
284
420
|
assistantResponse += content
|
|
285
421
|
}
|
|
286
422
|
}
|
|
287
423
|
|
|
288
|
-
|
|
424
|
+
try {
|
|
425
|
+
await chatService.createCompletion(completionOptions)
|
|
426
|
+
} catch (streamError) {
|
|
427
|
+
console.error(chalk.red('\nStreaming error:'), streamError)
|
|
428
|
+
|
|
429
|
+
// Fallback to non-streaming if streaming fails
|
|
430
|
+
console.log(chalk.yellow('Falling back to non-streaming mode...'))
|
|
431
|
+
completionOptions.stream = false
|
|
432
|
+
delete completionOptions.onChunk
|
|
433
|
+
|
|
434
|
+
const response = await chatService.createCompletion(completionOptions)
|
|
435
|
+
|
|
436
|
+
if (response && response.choices && response.choices[0] && response.choices[0].message) {
|
|
437
|
+
assistantResponse = response.choices[0].message.content
|
|
438
|
+
console.log(assistantResponse)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
289
441
|
console.log('\n')
|
|
290
442
|
|
|
291
443
|
// Add assistant response to messages
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createAuthenticatedClient
|
|
1
|
+
import { createAuthenticatedClient } from '../client'
|
|
2
2
|
import { logger } from '../utils/logger'
|
|
3
3
|
|
|
4
4
|
export interface ChatMessage {
|
|
@@ -323,28 +323,15 @@ export class ChatService {
|
|
|
323
323
|
options: any,
|
|
324
324
|
headers: Record<string, string>
|
|
325
325
|
): Promise<any> {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const url = new URL(`${API_BASE_URL}/v1/chat/completions`)
|
|
330
|
-
|
|
331
|
-
// Debug the headers and options
|
|
332
|
-
logger.debug('Streaming headers:')
|
|
333
|
-
logger.debug(JSON.stringify(headers, null, 2))
|
|
334
|
-
|
|
335
|
-
logger.debug('Streaming options:')
|
|
336
|
-
logger.debug(
|
|
337
|
-
JSON.stringify(
|
|
338
|
-
{
|
|
339
|
-
...options,
|
|
340
|
-
onChunk: options.onChunk ? 'function present' : 'no function',
|
|
341
|
-
},
|
|
342
|
-
null,
|
|
343
|
-
2
|
|
344
|
-
)
|
|
345
|
-
)
|
|
326
|
+
// Use the same base URL as the client
|
|
327
|
+
const baseUrl = process.env.API_BASE_URL || 'https://api.berget.ai'
|
|
328
|
+
const url = new URL(`${baseUrl}/v1/chat/completions`)
|
|
346
329
|
|
|
347
330
|
try {
|
|
331
|
+
logger.debug(`Making streaming request to: ${url.toString()}`)
|
|
332
|
+
logger.debug(`Headers:`, JSON.stringify(headers, null, 2))
|
|
333
|
+
logger.debug(`Body:`, JSON.stringify(options, null, 2))
|
|
334
|
+
|
|
348
335
|
// Make fetch request directly to handle streaming
|
|
349
336
|
const response = await fetch(url.toString(), {
|
|
350
337
|
method: 'POST',
|
|
@@ -356,14 +343,17 @@ export class ChatService {
|
|
|
356
343
|
body: JSON.stringify(options),
|
|
357
344
|
})
|
|
358
345
|
|
|
346
|
+
logger.debug(`Response status: ${response.status}`)
|
|
347
|
+
logger.debug(`Response headers:`, JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2))
|
|
348
|
+
|
|
359
349
|
if (!response.ok) {
|
|
360
350
|
const errorText = await response.text()
|
|
361
351
|
logger.error(
|
|
362
352
|
`Stream request failed: ${response.status} ${response.statusText}`
|
|
363
353
|
)
|
|
364
|
-
logger.
|
|
354
|
+
logger.error(`Error response: ${errorText}`)
|
|
365
355
|
throw new Error(
|
|
366
|
-
`Stream request failed: ${response.status} ${response.statusText}`
|
|
356
|
+
`Stream request failed: ${response.status} ${response.statusText} - ${errorText}`
|
|
367
357
|
)
|
|
368
358
|
}
|
|
369
359
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { registerChatCommands } from '../../src/commands/chat'
|
|
4
|
+
import { ChatService } from '../../src/services/chat-service'
|
|
5
|
+
import { DefaultApiKeyManager } from '../../src/utils/default-api-key'
|
|
6
|
+
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
vi.mock('../../src/services/chat-service')
|
|
9
|
+
vi.mock('../../src/utils/default-api-key')
|
|
10
|
+
vi.mock('readline', () => ({
|
|
11
|
+
createInterface: vi.fn(() => ({
|
|
12
|
+
question: vi.fn(),
|
|
13
|
+
close: vi.fn()
|
|
14
|
+
}))
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
describe('Chat Commands', () => {
|
|
18
|
+
let program: Command
|
|
19
|
+
let mockChatService: any
|
|
20
|
+
let mockDefaultApiKeyManager: any
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
program = new Command()
|
|
24
|
+
|
|
25
|
+
// Mock ChatService
|
|
26
|
+
mockChatService = {
|
|
27
|
+
createCompletion: vi.fn(),
|
|
28
|
+
listModels: vi.fn()
|
|
29
|
+
}
|
|
30
|
+
vi.mocked(ChatService.getInstance).mockReturnValue(mockChatService)
|
|
31
|
+
|
|
32
|
+
// Mock DefaultApiKeyManager
|
|
33
|
+
mockDefaultApiKeyManager = {
|
|
34
|
+
getDefaultApiKeyData: vi.fn(),
|
|
35
|
+
promptForDefaultApiKey: vi.fn()
|
|
36
|
+
}
|
|
37
|
+
vi.mocked(DefaultApiKeyManager.getInstance).mockReturnValue(mockDefaultApiKeyManager)
|
|
38
|
+
|
|
39
|
+
registerChatCommands(program)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.clearAllMocks()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('chat run command', () => {
|
|
47
|
+
it('should use openai/gpt-oss as default model', () => {
|
|
48
|
+
const chatCommand = program.commands.find(cmd => cmd.name() === 'chat')
|
|
49
|
+
const runCommand = chatCommand?.commands.find(cmd => cmd.name() === 'run')
|
|
50
|
+
|
|
51
|
+
expect(runCommand).toBeDefined()
|
|
52
|
+
|
|
53
|
+
// Check the help text which contains the default model
|
|
54
|
+
const helpText = runCommand?.helpInformation()
|
|
55
|
+
expect(helpText).toContain('openai/gpt-oss')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should have streaming enabled by default', () => {
|
|
59
|
+
const chatCommand = program.commands.find(cmd => cmd.name() === 'chat')
|
|
60
|
+
const runCommand = chatCommand?.commands.find(cmd => cmd.name() === 'run')
|
|
61
|
+
|
|
62
|
+
expect(runCommand).toBeDefined()
|
|
63
|
+
|
|
64
|
+
// Check that the option is --no-stream (meaning streaming is default)
|
|
65
|
+
const streamOption = runCommand?.options.find(opt => opt.long === '--no-stream')
|
|
66
|
+
expect(streamOption).toBeDefined()
|
|
67
|
+
expect(streamOption?.description).toContain('Disable streaming')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should create completion with correct default options', async () => {
|
|
71
|
+
// Mock API key
|
|
72
|
+
process.env.BERGET_API_KEY = 'test-key'
|
|
73
|
+
|
|
74
|
+
// Mock successful completion
|
|
75
|
+
mockChatService.createCompletion.mockResolvedValue({
|
|
76
|
+
choices: [{
|
|
77
|
+
message: { content: 'Test response' }
|
|
78
|
+
}]
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// This would normally test the actual command execution
|
|
82
|
+
// but since it involves readline interaction, we just verify
|
|
83
|
+
// that the service would be called with correct defaults
|
|
84
|
+
expect(mockChatService.createCompletion).not.toHaveBeenCalled()
|
|
85
|
+
|
|
86
|
+
// Clean up
|
|
87
|
+
delete process.env.BERGET_API_KEY
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('chat list command', () => {
|
|
92
|
+
it('should list available models', async () => {
|
|
93
|
+
const mockModels = {
|
|
94
|
+
data: [
|
|
95
|
+
{
|
|
96
|
+
id: 'gpt-oss',
|
|
97
|
+
owned_by: 'openai',
|
|
98
|
+
active: true,
|
|
99
|
+
capabilities: {
|
|
100
|
+
vision: false,
|
|
101
|
+
function_calling: true,
|
|
102
|
+
json_mode: true
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
mockChatService.listModels.mockResolvedValue(mockModels)
|
|
109
|
+
|
|
110
|
+
const chatCommand = program.commands.find(cmd => cmd.name() === 'chat')
|
|
111
|
+
const listCommand = chatCommand?.commands.find(cmd => cmd.name() === 'list')
|
|
112
|
+
|
|
113
|
+
expect(listCommand).toBeDefined()
|
|
114
|
+
expect(listCommand?.description()).toBe('List available chat models')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
})
|