berget 2.2.5 → 2.2.6

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 (44) hide show
  1. package/.github/workflows/publish.yml +2 -2
  2. package/.github/workflows/test.yml +1 -1
  3. package/dist/package.json +3 -1
  4. package/dist/src/commands/code/__tests__/fake-command-runner.js +52 -0
  5. package/dist/src/commands/code/__tests__/fake-file-store.js +46 -0
  6. package/dist/src/commands/code/__tests__/fake-prompter.js +91 -0
  7. package/dist/src/commands/code/__tests__/setup-flow.test.js +238 -0
  8. package/dist/src/commands/code/adapters/clack-prompter.js +71 -0
  9. package/dist/src/commands/code/adapters/fs-file-store.js +75 -0
  10. package/dist/src/commands/code/adapters/spawn-command-runner.js +49 -0
  11. package/dist/src/commands/code/errors.js +27 -0
  12. package/dist/src/commands/code/ports/command-runner.js +2 -0
  13. package/dist/src/commands/code/ports/file-store.js +2 -0
  14. package/dist/src/commands/code/ports/prompter.js +2 -0
  15. package/dist/src/commands/code/setup.js +392 -0
  16. package/dist/src/commands/code.js +187 -631
  17. package/dist/src/constants/command-structure.js +2 -0
  18. package/dist/tests/commands/code.test.js +31 -0
  19. package/dist/tests/utils/opencode-validator.test.js +15 -14
  20. package/package.json +3 -1
  21. package/src/commands/code/__tests__/fake-command-runner.ts +47 -0
  22. package/src/commands/code/__tests__/fake-file-store.ts +35 -0
  23. package/src/commands/code/__tests__/fake-prompter.ts +83 -0
  24. package/src/commands/code/__tests__/setup-flow.test.ts +274 -0
  25. package/src/commands/code/adapters/clack-prompter.ts +43 -0
  26. package/src/commands/code/adapters/fs-file-store.ts +33 -0
  27. package/src/commands/code/adapters/spawn-command-runner.ts +36 -0
  28. package/src/commands/code/errors.ts +23 -0
  29. package/src/commands/code/ports/command-runner.ts +6 -0
  30. package/src/commands/code/ports/file-store.ts +6 -0
  31. package/src/commands/code/ports/prompter.ts +23 -0
  32. package/src/commands/code/setup.ts +402 -0
  33. package/src/commands/code.ts +209 -746
  34. package/src/constants/command-structure.ts +3 -0
  35. package/templates/agents/app.md +22 -0
  36. package/templates/agents/backend.md +22 -0
  37. package/templates/agents/devops.md +28 -0
  38. package/templates/agents/frontend.md +24 -0
  39. package/templates/agents/fullstack.md +22 -0
  40. package/templates/agents/quality.md +64 -0
  41. package/templates/agents/security.md +20 -0
  42. package/tests/commands/code.test.ts +47 -0
  43. package/tests/utils/opencode-validator.test.ts +16 -15
  44. package/opencode.json +0 -146
@@ -0,0 +1,23 @@
1
+ export class PrerequisiteError extends Error {
2
+ constructor(public readonly binary: string) {
3
+ super(`Required binary not found: ${binary}`)
4
+ this.name = 'PrerequisiteError'
5
+ }
6
+ }
7
+
8
+ export class CancelledError extends Error {
9
+ constructor() {
10
+ super('Wizard cancelled')
11
+ this.name = 'CancelledError'
12
+ }
13
+ }
14
+
15
+ export class CommandFailedError extends Error {
16
+ constructor(
17
+ public readonly command: string,
18
+ public readonly exitCode: number
19
+ ) {
20
+ super(`Command "${command}" failed with exit code ${exitCode}`)
21
+ this.name = 'CommandFailedError'
22
+ }
23
+ }
@@ -0,0 +1,6 @@
1
+ export interface CommandRunner {
2
+ checkInstalled(binary: string): Promise<boolean>
3
+ run(command: string, args: readonly string[], options?: {
4
+ cwd?: string
5
+ }): Promise<string>
6
+ }
@@ -0,0 +1,6 @@
1
+ export interface FileStore {
2
+ exists(path: string): Promise<boolean>
3
+ readFile(path: string): Promise<string | null>
4
+ writeFile(path: string, content: string): Promise<void>
5
+ mkdir(path: string): Promise<void>
6
+ }
@@ -0,0 +1,23 @@
1
+ export interface Prompter {
2
+ intro(message: string): void
3
+ outro(message: string): void
4
+ note(message: string, title?: string): void
5
+ spinner(): Spinner
6
+ select<T>(opts: {
7
+ message: string
8
+ options: ReadonlyArray<{
9
+ value: T
10
+ label: string
11
+ hint?: string
12
+ }>
13
+ }): Promise<T>
14
+ confirm(opts: {
15
+ message: string
16
+ initialValue?: boolean
17
+ }): Promise<boolean>
18
+ }
19
+
20
+ export interface Spinner {
21
+ start(message: string): void
22
+ stop(message: string): void
23
+ }
@@ -0,0 +1,402 @@
1
+ import type { Prompter } from './ports/prompter'
2
+ import type { FileStore } from './ports/file-store'
3
+ import type { CommandRunner } from './ports/command-runner'
4
+ import { CancelledError, CommandFailedError, PrerequisiteError } from './errors'
5
+ import { modify, parse, applyEdits } from 'jsonc-parser'
6
+
7
+ const OPENCODE_PLUGIN = '@bergetai/opencode-auth@1.0.16'
8
+ const PI_PROVIDER = 'npm:@bergetai/pi-provider'
9
+ const OPENCODE_PLUGIN_NAME = '@bergetai/opencode-auth'
10
+ const PI_PROVIDER_NAME = '@bergetai/pi-provider'
11
+
12
+ export interface WizardDeps {
13
+ prompter: Prompter
14
+ files: FileStore
15
+ commands: CommandRunner
16
+ homeDir: string
17
+ cwd: string
18
+ }
19
+
20
+ export async function runSetup(deps: WizardDeps): Promise<void> {
21
+ const { prompter, files, commands, homeDir, cwd } = deps
22
+
23
+ prompter.intro('\uD83D\uDD27 Berget Code Setup')
24
+
25
+ const ocState = await getOpencodeState(files, homeDir, cwd)
26
+ const piState = await getPiState(files, homeDir, cwd)
27
+
28
+ const tool = await prompter.select<'opencode' | 'pi'>({
29
+ message: 'How do you want to use Berget AI?',
30
+ options: [
31
+ {
32
+ value: 'opencode',
33
+ label: `OpenCode${getOpencodeLabel(ocState)}`,
34
+ hint: 'Open source AI coding agent',
35
+ },
36
+ {
37
+ value: 'pi',
38
+ label: `Pi${getPiLabel(piState)}`,
39
+ hint: 'Minimal terminal coding harness',
40
+ },
41
+ ],
42
+ })
43
+
44
+ const scope = await prompter.select<'project' | 'global'>({
45
+ message: 'Where should the configuration apply?',
46
+ options: [
47
+ {
48
+ value: 'project',
49
+ label: 'This project only',
50
+ hint: tool === 'opencode'
51
+ ? (ocState.project ? 'Already configured' : 'opencode.json in current directory')
52
+ : (piState.project ? 'Already configured' : '.pi/settings.json in current directory'),
53
+ },
54
+ {
55
+ value: 'global',
56
+ label: 'Globally for all projects',
57
+ hint: tool === 'opencode'
58
+ ? (ocState.global ? 'Already configured' : '~/.config/opencode/opencode.json')
59
+ : (piState.global ? 'Already configured' : '~/.pi/agent/settings.json'),
60
+ },
61
+ ],
62
+ })
63
+
64
+ if (tool === 'opencode') {
65
+ await setupOpenCode({ prompter, files, commands, homeDir, cwd, scope })
66
+ prompter.note(`Next steps:\n\n1. Run: opencode\n2. Type: /connect\n3. Choose your auth method:\n \u2022 "Login with Berget" \u2014 Berget Code plan\n \u2022 "Enter Berget API Key manually"\n \u2022 (or set BERGET_API_KEY env var)\n4. Select model: /models\n\nFor more information, see official docs:\n\nhttps://github.com/berget-ai/opencode-berget-auth`, 'Successfully configured Berget AI for OpenCode')
67
+ } else {
68
+ await setupPi({ prompter, files, commands, homeDir, cwd, scope })
69
+ prompter.note(`Next steps:\n\n1. Restart Pi or run /reload\n2. Type: /login\n3. Choose your auth method:\n \u2022 "Use a subscription" \u2192 Berget AI\n \u2022 (or set BERGET_API_KEY env var)\n4. Select model: /model\n\nFor more information, see official docs:\n\nhttps://github.com/berget-ai/pi-provider`, 'Successfully configured Berget AI for Pi')
70
+ }
71
+
72
+ prompter.outro('Setup complete!')
73
+ }
74
+
75
+ // ─── OpenCode ────────────────────────────────────────────────────────────────
76
+
77
+ async function setupOpenCode(deps: {
78
+ prompter: Prompter
79
+ files: FileStore
80
+ commands: CommandRunner
81
+ homeDir: string
82
+ cwd: string
83
+ scope: 'project' | 'global'
84
+ }): Promise<void> {
85
+ const { prompter, files, commands, homeDir, cwd, scope } = deps
86
+
87
+ const installed = await commands.checkInstalled('opencode')
88
+ if (!installed) {
89
+ throw new PrerequisiteError('opencode')
90
+ }
91
+
92
+ const configPath = await resolveOpencodeConfigPath(files, homeDir, cwd, scope)
93
+ const existingContent = await files.readFile(configPath)
94
+ const newContent = generateModifiedContent(existingContent, configPath)
95
+
96
+ if (existingContent && existingContent === newContent) {
97
+ return
98
+ }
99
+
100
+ if (existingContent) {
101
+ prompter.note(generateDiff(existingContent, newContent, configPath), 'Changes to be written')
102
+ } else {
103
+ prompter.note(`New config at ${configPath}:\n\n${newContent}`, 'Config preview')
104
+ }
105
+
106
+ const shouldWrite = await prompter.confirm({
107
+ message: existingContent
108
+ ? `Write these changes to ${configPath}?`
109
+ : `Create ${configPath}?`,
110
+ initialValue: true,
111
+ })
112
+ if (!shouldWrite) throw new CancelledError()
113
+
114
+ const s = prompter.spinner()
115
+ s.start('Writing OpenCode configuration...')
116
+ await files.writeFile(configPath, newContent)
117
+ s.stop(`Wrote configuration to ${configPath}.`)
118
+ }
119
+
120
+ // ─── Pi ────────────────────────────────────────────────────────────────────────
121
+
122
+ async function setupPi(deps: {
123
+ prompter: Prompter
124
+ files: FileStore
125
+ commands: CommandRunner
126
+ homeDir: string
127
+ cwd: string
128
+ scope: 'project' | 'global'
129
+ }): Promise<void> {
130
+ const { prompter, files, commands, homeDir, cwd, scope } = deps
131
+ const s = prompter.spinner()
132
+
133
+ const installed = await commands.checkInstalled('pi')
134
+ if (!installed) {
135
+ throw new PrerequisiteError('pi')
136
+ }
137
+
138
+ const installArgs = scope === 'project'
139
+ ? ['install', '-l', PI_PROVIDER]
140
+ : ['install', PI_PROVIDER]
141
+
142
+ s.start(`Installing Berget AI provider for Pi...`)
143
+ try {
144
+ await commands.run('pi', installArgs)
145
+ s.stop('Installed Pi provider.')
146
+ } catch (err: any) {
147
+ s.stop('Pi provider installation failed. Please try again or install manually.')
148
+ throw new CommandFailedError(`pi ${installArgs.join(' ')}`, 1)
149
+ }
150
+
151
+ const settingsPath = scope === 'project'
152
+ ? pathJoin(cwd, '.pi', 'settings.json')
153
+ : pathJoin(homeDir, '.pi', 'agent', 'settings.json')
154
+
155
+ let settings = await readJsonMaybe(files, settingsPath) || {}
156
+
157
+ if (settings.defaultProvider === 'berget') {
158
+ prompter.note('Berget AI is already set as your default provider.', 'Default provider already set')
159
+ } else {
160
+ if (settings.defaultProvider) {
161
+ const makeDefault = await prompter.confirm({
162
+ message: `Your default provider is ${settings.defaultProvider}. Switch to Berget AI instead?`,
163
+ initialValue: false,
164
+ })
165
+ if (makeDefault) {
166
+ settings.defaultProvider = 'berget'
167
+ await writeJsonFile(files, settingsPath, settings)
168
+ prompter.note('Berget AI is now your default provider.', 'Updated default provider')
169
+ }
170
+ } else {
171
+ settings.defaultProvider = 'berget'
172
+ await writeJsonFile(files, settingsPath, settings)
173
+ prompter.note('Berget AI is now your default provider.', 'Updated default provider')
174
+ }
175
+ }
176
+ }
177
+
178
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
179
+
180
+ function pathJoin(...parts: string[]): string {
181
+ // Simple path join that avoids importing 'path' module
182
+ // This is good enough for cross-platform testing since tests control the path format
183
+ return parts.join('/')
184
+ }
185
+
186
+ function stripJsoncComments(content: string): string {
187
+ content = content.replace(/\/\/.*$/gm, '')
188
+ content = content.replace(/\/\*[\s\S]*?\*\//g, '')
189
+ return content
190
+ }
191
+
192
+ function generateDiff(oldText: string, newText: string, filePath: string): string {
193
+ const oldLines = oldText.split('\n')
194
+ const newLines = newText.split('\n')
195
+ let result = `--- ${filePath}\n+++ ${filePath}\n`
196
+
197
+ const maxLen = Math.max(oldLines.length, newLines.length)
198
+ for (let i = 0; i < maxLen; i++) {
199
+ const oldLine = oldLines[i]
200
+ const newLine = newLines[i]
201
+ if (oldLine !== newLine) {
202
+ if (oldLine !== undefined) result += `- ${oldLine}\n`
203
+ if (newLine !== undefined) result += `+ ${newLine}\n`
204
+ }
205
+ }
206
+ return result.trimEnd()
207
+ }
208
+
209
+ async function readJsonMaybe(files: FileStore, filePath: string): Promise<any | null> {
210
+ const content = await files.readFile(filePath)
211
+ if (!content) return null
212
+ try {
213
+ return JSON.parse(content)
214
+ } catch {
215
+ try {
216
+ return JSON.parse(stripJsoncComments(content))
217
+ } catch {
218
+ return null
219
+ }
220
+ }
221
+ }
222
+
223
+ async function writeJsonFile(files: FileStore, filePath: string, data: Record<string, unknown>): Promise<void> {
224
+ await files.writeFile(filePath, JSON.stringify(data, null, 2) + '\n')
225
+ }
226
+
227
+ async function hasPluginInConfig(config: any): Promise<boolean> {
228
+ if (!config) return false
229
+ const plugins = config.plugin || config.plugins || []
230
+ return plugins.some((p: string) => p.includes(OPENCODE_PLUGIN_NAME))
231
+ }
232
+
233
+ async function hasPiProviderInSettings(settings: any): Promise<boolean> {
234
+ if (!settings) return false
235
+ const packages = settings.packages || []
236
+ return packages.some((p: any) => {
237
+ if (typeof p === 'string') return p.includes(PI_PROVIDER_NAME)
238
+ if (typeof p === 'object' && p.source) return p.source.includes(PI_PROVIDER_NAME)
239
+ return false
240
+ })
241
+ }
242
+
243
+ async function getOpencodeState(
244
+ files: FileStore,
245
+ homeDir: string,
246
+ cwd: string
247
+ ): Promise<{ project: boolean; global: boolean }> {
248
+ const projectJsonc = await readJsonMaybe(files, pathJoin(cwd, 'opencode.jsonc'))
249
+ const projectJson = await readJsonMaybe(files, pathJoin(cwd, 'opencode.json'))
250
+ const globalJsonc = await readJsonMaybe(files, pathJoin(homeDir, '.config', 'opencode', 'opencode.jsonc'))
251
+ const globalJson = await readJsonMaybe(files, pathJoin(homeDir, '.config', 'opencode', 'opencode.json'))
252
+
253
+ return {
254
+ project: await hasPluginInConfig(projectJsonc) || await hasPluginInConfig(projectJson),
255
+ global: await hasPluginInConfig(globalJsonc) || await hasPluginInConfig(globalJson),
256
+ }
257
+ }
258
+
259
+ async function getPiState(
260
+ files: FileStore,
261
+ homeDir: string,
262
+ cwd: string
263
+ ): Promise<{ project: boolean; global: boolean }> {
264
+ const projectSettings = await readJsonMaybe(files, pathJoin(cwd, '.pi', 'settings.json'))
265
+ const globalSettings = await readJsonMaybe(files, pathJoin(homeDir, '.pi', 'agent', 'settings.json'))
266
+
267
+ return {
268
+ project: await hasPiProviderInSettings(projectSettings),
269
+ global: await hasPiProviderInSettings(globalSettings),
270
+ }
271
+ }
272
+
273
+ function getOpencodeLabel(state: { project: boolean; global: boolean }): string {
274
+ if (state.project || state.global) return ' (already configured)'
275
+ return ''
276
+ }
277
+
278
+ function getPiLabel(state: { project: boolean; global: boolean }): string {
279
+ if (state.project || state.global) return ' (already configured)'
280
+ return ''
281
+ }
282
+
283
+ async function resolveOpencodeConfigPath(
284
+ files: FileStore,
285
+ homeDir: string,
286
+ cwd: string,
287
+ scope: 'project' | 'global'
288
+ ): Promise<string> {
289
+ if (scope === 'project') {
290
+ const jsoncPath = pathJoin(cwd, 'opencode.jsonc')
291
+ const jsonPath = pathJoin(cwd, 'opencode.json')
292
+ if (await files.exists(jsoncPath)) return jsoncPath
293
+ if (await files.exists(jsonPath)) return jsonPath
294
+ return jsonPath
295
+ } else {
296
+ const globalDir = pathJoin(homeDir, '.config', 'opencode')
297
+ const jsoncPath = pathJoin(globalDir, 'opencode.jsonc')
298
+ const jsonPath = pathJoin(globalDir, 'opencode.json')
299
+ if (await files.exists(jsoncPath)) return jsoncPath
300
+ if (await files.exists(jsonPath)) return jsonPath
301
+ return jsonPath
302
+ }
303
+ }
304
+
305
+ function generateModifiedContent(existingContent: string | null, configPath: string): string {
306
+ if (configPath.endsWith('.jsonc')) {
307
+ const content = existingContent || '{}'
308
+ const parseErrors: any[] = []
309
+ const parsed = parse(content, parseErrors, { allowTrailingComma: true, disallowComments: false })
310
+
311
+ let jsConfig: Record<string, any> = {}
312
+ const canModifyText =
313
+ parsed !== undefined &&
314
+ typeof parsed === 'object' &&
315
+ parsed !== null &&
316
+ !Array.isArray(parsed)
317
+
318
+ if (canModifyText) {
319
+ jsConfig = parsed as Record<string, any>
320
+ }
321
+
322
+ const pluginsKey = jsConfig.plugins !== undefined ? 'plugins' : 'plugin'
323
+ const existing: string[] = jsConfig[pluginsKey] || []
324
+ const filtered = existing.filter((p: string) => !p.includes(OPENCODE_PLUGIN_NAME))
325
+ filtered.push(OPENCODE_PLUGIN)
326
+
327
+ if (canModifyText) {
328
+ let modifiedContent = content
329
+ const pluginEdits = modify(modifiedContent, [pluginsKey], filtered, {
330
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
331
+ })
332
+ modifiedContent = applyEdits(modifiedContent, pluginEdits)
333
+
334
+ if (!jsConfig.$schema) {
335
+ const schemaEdits = modify(modifiedContent, ['$schema'], 'https://opencode.ai/config.json', {
336
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
337
+ })
338
+ modifiedContent = applyEdits(modifiedContent, schemaEdits)
339
+ }
340
+
341
+ return modifiedContent
342
+ }
343
+
344
+ // Malformed, empty, or non-object JSONC — write a clean config
345
+ const config: Record<string, any> = {
346
+ [pluginsKey]: filtered,
347
+ $schema: 'https://opencode.ai/config.json',
348
+ }
349
+ return JSON.stringify(config, null, 2) + '\n'
350
+ }
351
+
352
+ // Plain JSON
353
+ let config: Record<string, any> = {}
354
+ if (existingContent) {
355
+ try {
356
+ config = JSON.parse(existingContent)
357
+ } catch {
358
+ // ignore malformed, overwrite
359
+ }
360
+ }
361
+
362
+ const pluginsKey = config.plugins !== undefined ? 'plugins' : 'plugin'
363
+ const existing: string[] = config[pluginsKey] || []
364
+ const filtered = existing.filter((p: string) => !p.includes(OPENCODE_PLUGIN_NAME))
365
+ filtered.push(OPENCODE_PLUGIN)
366
+ config[pluginsKey] = filtered
367
+ config.$schema = config.$schema || 'https://opencode.ai/config.json'
368
+
369
+ return JSON.stringify(config, null, 2) + '\n'
370
+ }
371
+
372
+ // ─── Production CLI entry point ──────────────────────────────────────────────
373
+
374
+ import { ClackPrompter } from './adapters/clack-prompter.js'
375
+ import { FsFileStore } from './adapters/fs-file-store.js'
376
+ import { SpawnCommandRunner } from './adapters/spawn-command-runner.js'
377
+ import * as os from 'os'
378
+
379
+ export async function runSetupCommand(): Promise<void> {
380
+ try {
381
+ await runSetup({
382
+ prompter: new ClackPrompter(),
383
+ files: new FsFileStore(),
384
+ commands: new SpawnCommandRunner(),
385
+ homeDir: os.homedir(),
386
+ cwd: process.cwd(),
387
+ })
388
+ } catch (err) {
389
+ if (err instanceof CancelledError) {
390
+ process.exit(130)
391
+ }
392
+ if (err instanceof PrerequisiteError) {
393
+ console.error(`Missing required binary: ${err.binary}`)
394
+ process.exit(2)
395
+ }
396
+ if (err instanceof CommandFailedError) {
397
+ console.error(err.message)
398
+ process.exit(5)
399
+ }
400
+ throw err
401
+ }
402
+ }