berget 2.2.6 → 2.2.8

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 (145) hide show
  1. package/.github/workflows/publish.yml +2 -2
  2. package/.github/workflows/test.yml +10 -4
  3. package/.husky/pre-commit +1 -0
  4. package/.prettierignore +15 -0
  5. package/.prettierrc +7 -3
  6. package/CONTRIBUTING.md +38 -0
  7. package/README.md +2 -148
  8. package/dist/index.js +10 -11
  9. package/dist/package.json +30 -2
  10. package/dist/src/agents/app.js +28 -0
  11. package/dist/src/agents/backend.js +25 -0
  12. package/dist/src/agents/devops.js +34 -0
  13. package/dist/src/agents/frontend.js +25 -0
  14. package/dist/src/agents/fullstack.js +25 -0
  15. package/dist/src/agents/index.js +61 -0
  16. package/dist/src/agents/quality.js +70 -0
  17. package/dist/src/agents/security.js +26 -0
  18. package/dist/src/agents/types.js +2 -0
  19. package/dist/src/client.js +97 -117
  20. package/dist/src/commands/api-keys.js +75 -90
  21. package/dist/src/commands/auth.js +7 -16
  22. package/dist/src/commands/autocomplete.js +1 -1
  23. package/dist/src/commands/billing.js +6 -17
  24. package/dist/src/commands/chat.js +68 -101
  25. package/dist/src/commands/clusters.js +9 -18
  26. package/dist/src/commands/code/__tests__/auth-sync.test.js +351 -0
  27. package/dist/src/commands/code/__tests__/fake-api-key-service.js +13 -0
  28. package/dist/src/commands/code/__tests__/fake-auth-service.js +47 -0
  29. package/dist/src/commands/code/__tests__/fake-command-runner.js +21 -34
  30. package/dist/src/commands/code/__tests__/fake-file-store.js +20 -33
  31. package/dist/src/commands/code/__tests__/fake-prompter.js +83 -57
  32. package/dist/src/commands/code/__tests__/setup-flow.test.js +359 -92
  33. package/dist/src/commands/code/adapters/clack-prompter.js +15 -22
  34. package/dist/src/commands/code/adapters/fs-file-store.js +26 -40
  35. package/dist/src/commands/code/adapters/spawn-command-runner.js +27 -37
  36. package/dist/src/commands/code/auth-sync.js +270 -0
  37. package/dist/src/commands/code/errors.js +12 -9
  38. package/dist/src/commands/code/ports/auth-services.js +2 -0
  39. package/dist/src/commands/code/setup.js +387 -281
  40. package/dist/src/commands/code.js +205 -332
  41. package/dist/src/commands/index.js +5 -5
  42. package/dist/src/commands/models.js +6 -17
  43. package/dist/src/commands/users.js +5 -16
  44. package/dist/src/constants/command-structure.js +104 -104
  45. package/dist/src/services/api-key-service.js +132 -157
  46. package/dist/src/services/auth-service.js +89 -342
  47. package/dist/src/services/browser-auth.js +268 -0
  48. package/dist/src/services/chat-service.js +371 -401
  49. package/dist/src/services/cluster-service.js +47 -62
  50. package/dist/src/services/collaborator-service.js +10 -25
  51. package/dist/src/services/flux-service.js +14 -29
  52. package/dist/src/services/helm-service.js +10 -25
  53. package/dist/src/services/kubectl-service.js +16 -33
  54. package/dist/src/utils/config-checker.js +3 -3
  55. package/dist/src/utils/config-loader.js +95 -95
  56. package/dist/src/utils/default-api-key.js +124 -134
  57. package/dist/src/utils/env-manager.js +55 -66
  58. package/dist/src/utils/error-handler.js +20 -21
  59. package/dist/src/utils/logger.js +72 -65
  60. package/dist/src/utils/markdown-renderer.js +27 -27
  61. package/dist/src/utils/opencode-validator.js +63 -68
  62. package/dist/src/utils/token-manager.js +74 -45
  63. package/dist/tests/commands/chat.test.js +16 -25
  64. package/dist/tests/commands/code.test.js +95 -104
  65. package/dist/tests/utils/config-loader.test.js +48 -48
  66. package/dist/tests/utils/env-manager.test.js +43 -52
  67. package/dist/tests/utils/opencode-validator.test.js +22 -21
  68. package/dist/vitest.config.js +1 -1
  69. package/eslint.config.mjs +67 -0
  70. package/index.ts +35 -42
  71. package/package.json +30 -2
  72. package/src/agents/app.ts +27 -0
  73. package/src/agents/backend.ts +24 -0
  74. package/src/agents/devops.ts +33 -0
  75. package/src/agents/frontend.ts +24 -0
  76. package/src/agents/fullstack.ts +24 -0
  77. package/src/agents/index.ts +73 -0
  78. package/src/agents/quality.ts +69 -0
  79. package/src/agents/security.ts +26 -0
  80. package/src/agents/types.ts +17 -0
  81. package/src/client.ts +118 -152
  82. package/src/commands/api-keys.ts +241 -333
  83. package/src/commands/auth.ts +22 -27
  84. package/src/commands/autocomplete.ts +9 -9
  85. package/src/commands/billing.ts +20 -24
  86. package/src/commands/chat.ts +248 -338
  87. package/src/commands/clusters.ts +27 -26
  88. package/src/commands/code/__tests__/auth-sync.test.ts +482 -0
  89. package/src/commands/code/__tests__/fake-api-key-service.ts +13 -0
  90. package/src/commands/code/__tests__/fake-auth-service.ts +50 -0
  91. package/src/commands/code/__tests__/fake-command-runner.ts +45 -42
  92. package/src/commands/code/__tests__/fake-file-store.ts +32 -23
  93. package/src/commands/code/__tests__/fake-prompter.ts +116 -77
  94. package/src/commands/code/__tests__/setup-flow.test.ts +624 -268
  95. package/src/commands/code/adapters/clack-prompter.ts +53 -39
  96. package/src/commands/code/adapters/fs-file-store.ts +32 -27
  97. package/src/commands/code/adapters/spawn-command-runner.ts +38 -29
  98. package/src/commands/code/auth-sync.ts +329 -0
  99. package/src/commands/code/errors.ts +18 -18
  100. package/src/commands/code/ports/auth-services.ts +14 -0
  101. package/src/commands/code/ports/command-runner.ts +8 -4
  102. package/src/commands/code/ports/file-store.ts +5 -4
  103. package/src/commands/code/ports/prompter.ts +24 -18
  104. package/src/commands/code/setup.ts +570 -340
  105. package/src/commands/code.ts +338 -539
  106. package/src/commands/index.ts +20 -19
  107. package/src/commands/models.ts +28 -32
  108. package/src/commands/users.ts +15 -21
  109. package/src/constants/command-structure.ts +134 -157
  110. package/src/services/api-key-service.ts +105 -122
  111. package/src/services/auth-service.ts +99 -345
  112. package/src/services/browser-auth.ts +296 -0
  113. package/src/services/chat-service.ts +265 -299
  114. package/src/services/cluster-service.ts +42 -45
  115. package/src/services/collaborator-service.ts +14 -19
  116. package/src/services/flux-service.ts +23 -25
  117. package/src/services/helm-service.ts +19 -21
  118. package/src/services/kubectl-service.ts +17 -19
  119. package/src/types/api.d.ts +1905 -1907
  120. package/src/types/json.d.ts +2 -2
  121. package/src/utils/config-checker.ts +10 -10
  122. package/src/utils/config-loader.ts +162 -178
  123. package/src/utils/default-api-key.ts +114 -125
  124. package/src/utils/env-manager.ts +53 -57
  125. package/src/utils/error-handler.ts +61 -56
  126. package/src/utils/logger.ts +79 -73
  127. package/src/utils/markdown-renderer.ts +31 -31
  128. package/src/utils/opencode-validator.ts +85 -89
  129. package/src/utils/token-manager.ts +108 -87
  130. package/templates/agents/app.md +1 -0
  131. package/templates/agents/backend.md +1 -0
  132. package/templates/agents/devops.md +2 -0
  133. package/templates/agents/frontend.md +1 -0
  134. package/templates/agents/fullstack.md +1 -0
  135. package/templates/agents/quality.md +45 -40
  136. package/templates/agents/security.md +1 -0
  137. package/tests/commands/chat.test.ts +53 -62
  138. package/tests/commands/code.test.ts +265 -310
  139. package/tests/utils/config-loader.test.ts +189 -188
  140. package/tests/utils/env-manager.test.ts +110 -113
  141. package/tests/utils/opencode-validator.test.ts +52 -56
  142. package/tsconfig.json +4 -3
  143. package/vitest.config.ts +3 -3
  144. package/AGENTS.md +0 -374
  145. package/TODO.md +0 -19
@@ -1,402 +1,632 @@
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'
1
+ import { applyEdits, modify, parse } from 'jsonc-parser';
2
+ import * as os from 'node:os';
3
+
4
+ import type { ApiKeyServicePort, AuthServicePort } from './ports/auth-services';
5
+ import type { CommandRunner } from './ports/command-runner';
6
+ import type { FileStore } from './ports/file-store';
7
+ import type { Prompter } from './ports/prompter';
8
+
9
+ import { getAllAgents, toMarkdown, toPiPrompt } from '../../agents/index.js';
10
+ import { ApiKeyService } from '../../services/api-key-service.js';
11
+ import { AuthService } from '../../services/auth-service.js';
12
+ import { ClackPrompter } from './adapters/clack-prompter.js';
13
+ import { FsFileStore } from './adapters/fs-file-store.js';
14
+ import { SpawnCommandRunner } from './adapters/spawn-command-runner.js';
15
+ import { configureAuth } from './auth-sync.js';
16
+ import { CancelledError, CommandFailedError, PrerequisiteError } from './errors';
17
+
18
+ const OPENCODE_PLUGIN = '@bergetai/opencode-auth';
19
+ const PI_PROVIDER = 'npm:@bergetai/pi-provider';
20
+ const OPENCODE_PLUGIN_NAME = '@bergetai/opencode-auth';
21
+ const PI_PROVIDER_NAME = '@bergetai/pi-provider';
11
22
 
12
23
  export interface WizardDeps {
13
- prompter: Prompter
14
- files: FileStore
15
- commands: CommandRunner
16
- homeDir: string
17
- cwd: string
24
+ apiKeyService: ApiKeyServicePort;
25
+ authService: AuthServicePort;
26
+ commands: CommandRunner;
27
+ cwd: string;
28
+ files: FileStore;
29
+ homeDir: string;
30
+ prompter: Prompter;
18
31
  }
19
32
 
20
33
  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')
34
+ const { apiKeyService, authService, commands, cwd, files, homeDir, prompter } = deps;
35
+
36
+ prompter.intro('\uD83D\uDD27 Berget Code Setup');
37
+
38
+ const ocState = await getOpencodeState(files, homeDir, cwd);
39
+ const piState = await getPiState(files, homeDir, cwd);
40
+
41
+ const tool = await prompter.select<'opencode' | 'pi'>({
42
+ message: 'How do you want to use Berget AI?',
43
+ options: [
44
+ {
45
+ hint: 'Open source AI coding agent',
46
+ label: `OpenCode${getOpencodeLabel(ocState)}`,
47
+ value: 'opencode',
48
+ },
49
+ {
50
+ hint: 'Minimal terminal coding harness',
51
+ label: `Pi${getPiLabel(piState)}`,
52
+ value: 'pi',
53
+ },
54
+ ],
55
+ });
56
+
57
+ const scope = await prompter.select<'global' | 'project'>({
58
+ message: 'Where should the configuration apply?',
59
+ options: [
60
+ {
61
+ hint:
62
+ tool === 'opencode'
63
+ ? ocState.project
64
+ ? 'Already configured'
65
+ : 'opencode.json in current directory'
66
+ : piState.project
67
+ ? 'Already configured'
68
+ : '.pi/settings.json in current directory',
69
+ label: 'This project only',
70
+ value: 'project',
71
+ },
72
+ {
73
+ hint:
74
+ tool === 'opencode'
75
+ ? ocState.global
76
+ ? 'Already configured'
77
+ : '~/.config/opencode/opencode.json'
78
+ : piState.global
79
+ ? 'Already configured'
80
+ : '~/.pi/agent/settings.json',
81
+ label: 'Globally for all projects',
82
+ value: 'global',
83
+ },
84
+ ],
85
+ });
86
+
87
+ const authResult = await configureAuth(
88
+ { apiKeyService, authService, files, homeDir, prompter },
89
+ tool,
90
+ );
91
+
92
+ if (tool === 'opencode') {
93
+ await setupOpenCode({ commands, cwd, files, homeDir, prompter, scope });
94
+ await setupOpenCodeAgents({ cwd, files, homeDir, prompter, scope });
95
+
96
+ if (authResult.authenticated) {
97
+ prompter.note(
98
+ `You're all set!\n\n1. Run: opencode\n2. Select model: /models\n\nFor more information, see official docs:\n\nhttps://github.com/berget-ai/opencode-berget-auth`,
99
+ 'Successfully configured Berget AI for OpenCode',
100
+ );
101
+ } else {
102
+ prompter.note(
103
+ `Next steps:\n\n1. Run: opencode\n2. Type: /connect\n3. Choose your auth method:\n • "Login with Berget" — Berget Code plan\n • "Enter Berget API Key manually"\n • (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`,
104
+ 'Successfully configured Berget AI for OpenCode',
105
+ );
106
+ }
107
+ } else {
108
+ await setupPi({ commands, cwd, files, homeDir, prompter, scope });
109
+ await setupPiAgent({ cwd, files, homeDir, prompter, scope });
110
+
111
+ if (authResult.authenticated) {
112
+ prompter.note(
113
+ `You're all set!\n\n1. Restart Pi or run /reload\n2. Select model: /model\n\nFor more information, see official docs:\n\nhttps://github.com/berget-ai/pi-provider`,
114
+ 'Successfully configured Berget AI for Pi',
115
+ );
67
116
  } 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')
117
+ prompter.note(
118
+ `Next steps:\n\n1. Restart Pi or run /reload\n2. Type: /login\n3. Choose your auth method:\n "Use a subscription" Berget AI\n (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`,
119
+ 'Successfully configured Berget AI for Pi',
120
+ );
70
121
  }
122
+ }
71
123
 
72
- prompter.outro('Setup complete!')
124
+ prompter.outro('Setup complete!');
73
125
  }
74
126
 
75
127
  // ─── OpenCode ────────────────────────────────────────────────────────────────
76
128
 
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')
129
+ export async function runSetupCommand(): Promise<void> {
130
+ try {
131
+ await runSetup({
132
+ apiKeyService: ApiKeyService.getInstance(),
133
+ authService: AuthService.getInstance(),
134
+ commands: new SpawnCommandRunner(),
135
+ cwd: process.cwd(),
136
+ files: new FsFileStore(),
137
+ homeDir: os.homedir(),
138
+ prompter: new ClackPrompter(),
139
+ });
140
+ process.exit(0);
141
+ } catch (error) {
142
+ if (error instanceof CancelledError) {
143
+ process.exit(130);
90
144
  }
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
145
+ if (error instanceof PrerequisiteError) {
146
+ console.error(`Missing required binary: ${error.binary}`);
147
+ process.exit(2);
98
148
  }
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')
149
+ if (error instanceof CommandFailedError) {
150
+ console.error(error.message);
151
+ process.exit(5);
104
152
  }
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}.`)
153
+ throw error;
154
+ }
118
155
  }
119
156
 
120
157
  // ─── Pi ────────────────────────────────────────────────────────────────────────
121
158
 
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()
159
+ function generateDiff(oldText: string, newText: string, filePath: string): string {
160
+ const oldLines = oldText.split('\n');
161
+ const newLines = newText.split('\n');
162
+ let result = `--- ${filePath}\n+++ ${filePath}\n`;
163
+
164
+ const maxLength = Math.max(oldLines.length, newLines.length);
165
+ for (let index = 0; index < maxLength; index++) {
166
+ const oldLine = oldLines[index];
167
+ const newLine = newLines[index];
168
+ if (oldLine !== newLine) {
169
+ if (oldLine !== undefined) result += `- ${oldLine}\n`;
170
+ if (newLine !== undefined) result += `+ ${newLine}\n`;
171
+ }
172
+ }
173
+ return result.trimEnd();
174
+ }
132
175
 
133
- const installed = await commands.checkInstalled('pi')
134
- if (!installed) {
135
- throw new PrerequisiteError('pi')
176
+ function generateModifiedContent(existingContent: null | string, configPath: string): string {
177
+ if (configPath.endsWith('.jsonc')) {
178
+ const content = existingContent || '{}';
179
+ const parseErrors: any[] = [];
180
+ const parsed = parse(content, parseErrors, {
181
+ allowTrailingComma: true,
182
+ disallowComments: false,
183
+ });
184
+
185
+ let jsConfig: Record<string, any> = {};
186
+ const canModifyText =
187
+ parsed !== undefined &&
188
+ typeof parsed === 'object' &&
189
+ parsed !== null &&
190
+ !Array.isArray(parsed);
191
+
192
+ if (canModifyText) {
193
+ jsConfig = parsed as Record<string, any>;
136
194
  }
137
195
 
138
- const installArgs = scope === 'project'
139
- ? ['install', '-l', PI_PROVIDER]
140
- : ['install', PI_PROVIDER]
196
+ const pluginsKey = jsConfig.plugins === undefined ? 'plugin' : 'plugins';
197
+ const existing: string[] = jsConfig[pluginsKey] || [];
198
+ const filtered = existing.filter((p: string) => !p.includes(OPENCODE_PLUGIN_NAME));
199
+ filtered.push(OPENCODE_PLUGIN);
200
+
201
+ if (canModifyText) {
202
+ let modifiedContent = content;
203
+ const pluginEdits = modify(modifiedContent, [pluginsKey], filtered, {
204
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
205
+ });
206
+ modifiedContent = applyEdits(modifiedContent, pluginEdits);
207
+
208
+ if (!jsConfig.$schema) {
209
+ const schemaEdits = modify(
210
+ modifiedContent,
211
+ ['$schema'],
212
+ 'https://opencode.ai/config.json',
213
+ {
214
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
215
+ },
216
+ );
217
+ modifiedContent = applyEdits(modifiedContent, schemaEdits);
218
+ }
219
+
220
+ return modifiedContent;
221
+ }
141
222
 
142
- s.start(`Installing Berget AI provider for Pi...`)
223
+ // Malformed, empty, or non-object JSONC — write a clean config
224
+ const config: Record<string, any> = {
225
+ $schema: 'https://opencode.ai/config.json',
226
+ [pluginsKey]: filtered,
227
+ };
228
+ return JSON.stringify(config, null, 2) + '\n';
229
+ }
230
+
231
+ // Plain JSON
232
+ let config: Record<string, any> = {};
233
+ if (existingContent) {
143
234
  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)
235
+ config = JSON.parse(existingContent);
236
+ } catch {
237
+ // ignore malformed, overwrite
149
238
  }
239
+ }
150
240
 
151
- const settingsPath = scope === 'project'
152
- ? pathJoin(cwd, '.pi', 'settings.json')
153
- : pathJoin(homeDir, '.pi', 'agent', 'settings.json')
241
+ const pluginsKey = config.plugins === undefined ? 'plugin' : 'plugins';
242
+ const existing: string[] = config[pluginsKey] || [];
243
+ const filtered = existing.filter((p: string) => !p.includes(OPENCODE_PLUGIN_NAME));
244
+ filtered.push(OPENCODE_PLUGIN);
245
+ config[pluginsKey] = filtered;
246
+ config.$schema = config.$schema || 'https://opencode.ai/config.json';
154
247
 
155
- let settings = await readJsonMaybe(files, settingsPath) || {}
248
+ return JSON.stringify(config, null, 2) + '\n';
249
+ }
156
250
 
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
- }
251
+ function getOpencodeLabel(state: { global: boolean; project: boolean }): string {
252
+ if (state.project || state.global) return ' (already configured)';
253
+ return '';
176
254
  }
177
255
 
178
256
  // ─── Helpers ─────────────────────────────────────────────────────────────────
179
257
 
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
258
+ async function getOpencodeState(
259
+ files: FileStore,
260
+ homeDir: string,
261
+ cwd: string,
262
+ ): Promise<{ global: boolean; project: boolean }> {
263
+ const projectJsonc = await readJsonMaybe(files, pathJoin(cwd, 'opencode.jsonc'));
264
+ const projectJson = await readJsonMaybe(files, pathJoin(cwd, 'opencode.json'));
265
+ const globalJsonc = await readJsonMaybe(
266
+ files,
267
+ pathJoin(homeDir, '.config', 'opencode', 'opencode.jsonc'),
268
+ );
269
+ const globalJson = await readJsonMaybe(
270
+ files,
271
+ pathJoin(homeDir, '.config', 'opencode', 'opencode.json'),
272
+ );
273
+
274
+ return {
275
+ global: (await hasPluginInConfig(globalJsonc)) || (await hasPluginInConfig(globalJson)),
276
+ project: (await hasPluginInConfig(projectJsonc)) || (await hasPluginInConfig(projectJson)),
277
+ };
190
278
  }
191
279
 
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()
280
+ function getPiLabel(state: { global: boolean; project: boolean }): string {
281
+ if (state.project || state.global) return ' (already configured)';
282
+ return '';
207
283
  }
208
284
 
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
- }
285
+ async function getPiState(
286
+ files: FileStore,
287
+ homeDir: string,
288
+ cwd: string,
289
+ ): Promise<{ global: boolean; project: boolean }> {
290
+ const projectSettings = await readJsonMaybe(files, pathJoin(cwd, '.pi', 'settings.json'));
291
+ const globalSettings = await readJsonMaybe(
292
+ files,
293
+ pathJoin(homeDir, '.pi', 'agent', 'settings.json'),
294
+ );
295
+
296
+ return {
297
+ global: await hasPiProviderInSettings(globalSettings),
298
+ project: await hasPiProviderInSettings(projectSettings),
299
+ };
221
300
  }
222
301
 
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')
302
+ async function hasPiProviderInSettings(settings: any): Promise<boolean> {
303
+ if (!settings) return false;
304
+ const packages = settings.packages || [];
305
+ return packages.some((p: any) => {
306
+ if (typeof p === 'string') return p.includes(PI_PROVIDER_NAME);
307
+ if (typeof p === 'object' && p.source) return p.source.includes(PI_PROVIDER_NAME);
308
+ return false;
309
+ });
225
310
  }
226
311
 
227
312
  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))
313
+ if (!config) return false;
314
+ const plugins = config.plugin || config.plugins || [];
315
+ return plugins.some((p: string) => p.includes(OPENCODE_PLUGIN_NAME));
231
316
  }
232
317
 
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
- })
318
+ function pathJoin(...parts: string[]): string {
319
+ // Simple path join that avoids importing 'path' module
320
+ // This is good enough for cross-platform testing since tests control the path format
321
+ return parts.join('/');
241
322
  }
242
323
 
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),
324
+ async function readJsonMaybe(files: FileStore, filePath: string): Promise<any | null> {
325
+ const content = await files.readFile(filePath);
326
+ if (!content) return null;
327
+ try {
328
+ return JSON.parse(content);
329
+ } catch {
330
+ try {
331
+ return JSON.parse(stripJsoncComments(content));
332
+ } catch {
333
+ return null;
256
334
  }
335
+ }
257
336
  }
258
337
 
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
- }
338
+ async function resolveOpencodeConfigPath(
339
+ files: FileStore,
340
+ homeDir: string,
341
+ cwd: string,
342
+ scope: 'global' | 'project',
343
+ ): Promise<string> {
344
+ if (scope === 'project') {
345
+ const jsoncPath = pathJoin(cwd, 'opencode.jsonc');
346
+ const jsonPath = pathJoin(cwd, 'opencode.json');
347
+ if (await files.exists(jsoncPath)) return jsoncPath;
348
+ if (await files.exists(jsonPath)) return jsonPath;
349
+ return jsonPath;
350
+ } else {
351
+ const globalDir = pathJoin(homeDir, '.config', 'opencode');
352
+ const jsoncPath = pathJoin(globalDir, 'opencode.jsonc');
353
+ const jsonPath = pathJoin(globalDir, 'opencode.json');
354
+ if (await files.exists(jsoncPath)) return jsoncPath;
355
+ if (await files.exists(jsonPath)) return jsonPath;
356
+ return jsonPath;
357
+ }
271
358
  }
272
359
 
273
- function getOpencodeLabel(state: { project: boolean; global: boolean }): string {
274
- if (state.project || state.global) return ' (already configured)'
275
- return ''
360
+ async function setupOpenCode(deps: {
361
+ commands: CommandRunner;
362
+ cwd: string;
363
+ files: FileStore;
364
+ homeDir: string;
365
+ prompter: Prompter;
366
+ scope: 'global' | 'project';
367
+ }): Promise<void> {
368
+ const { commands, cwd, files, homeDir, prompter, scope } = deps;
369
+
370
+ const installed = await commands.checkInstalled('opencode');
371
+ if (!installed) {
372
+ throw new PrerequisiteError('opencode');
373
+ }
374
+
375
+ const configPath = await resolveOpencodeConfigPath(files, homeDir, cwd, scope);
376
+ const existingContent = await files.readFile(configPath);
377
+ const newContent = generateModifiedContent(existingContent, configPath);
378
+
379
+ if (existingContent && existingContent === newContent) {
380
+ return;
381
+ }
382
+
383
+ if (existingContent) {
384
+ prompter.note(generateDiff(existingContent, newContent, configPath), 'Changes to be written');
385
+ } else {
386
+ prompter.note(`New config at ${configPath}:\n\n${newContent}`, 'Config preview');
387
+ }
388
+
389
+ const shouldWrite = await prompter.confirm({
390
+ initialValue: true,
391
+ message: existingContent ? `Write these changes to ${configPath}?` : `Create ${configPath}?`,
392
+ });
393
+ if (!shouldWrite) throw new CancelledError();
394
+
395
+ const s = prompter.spinner();
396
+ s.start('Writing OpenCode configuration...');
397
+ await files.writeFile(configPath, newContent);
398
+ s.stop(`Wrote configuration to ${configPath}.`);
276
399
  }
277
400
 
278
- function getPiLabel(state: { project: boolean; global: boolean }): string {
279
- if (state.project || state.global) return ' (already configured)'
280
- return ''
401
+ async function setupOpenCodeAgents(deps: {
402
+ cwd: string;
403
+ files: FileStore;
404
+ homeDir: string;
405
+ prompter: Prompter;
406
+ scope: 'global' | 'project';
407
+ }): Promise<void> {
408
+ const { cwd, files, homeDir, prompter, scope } = deps;
409
+
410
+ const agents = getAllAgents().filter((a) => a.config.mode === 'primary');
411
+
412
+ if (agents.length === 0) {
413
+ return;
414
+ }
415
+
416
+ const selectedAgents = await prompter.multiselect({
417
+ message: 'Select agents to set up (optional - press enter to skip):',
418
+ options: agents.map((agent) => ({
419
+ hint: agent.config.description,
420
+ label: agent.config.name,
421
+ value: agent.config.name,
422
+ })),
423
+ });
424
+
425
+ if (selectedAgents.length === 0) {
426
+ return;
427
+ }
428
+
429
+ const agentsDir =
430
+ scope === 'project'
431
+ ? pathJoin(cwd, '.opencode', 'agents')
432
+ : pathJoin(homeDir, '.config', 'opencode', 'agents');
433
+
434
+ await files.mkdir(agentsDir);
435
+
436
+ const hasChanges = await Promise.all(
437
+ selectedAgents.map(async (agentName) => {
438
+ const agent = agents.find((a) => a.config.name === agentName);
439
+ if (!agent) return false;
440
+
441
+ const agentPath = pathJoin(agentsDir, `${agentName}.md`);
442
+ const existing = await files.readFile(agentPath);
443
+ const newContent = toMarkdown(agent);
444
+
445
+ if (existing === newContent) {
446
+ return false;
447
+ }
448
+
449
+ if (existing) {
450
+ prompter.note(
451
+ generateDiff(existing, newContent, agentPath),
452
+ `Changes to ${agentName} agent`,
453
+ );
454
+ }
455
+
456
+ return true;
457
+ }),
458
+ );
459
+
460
+ if (!hasChanges.some(Boolean)) {
461
+ prompter.note('Agent files are already up to date.', 'No changes needed');
462
+ return;
463
+ }
464
+
465
+ const shouldWrite = await prompter.confirm({
466
+ initialValue: true,
467
+ message: 'Write agent configuration files?',
468
+ });
469
+
470
+ if (!shouldWrite) {
471
+ throw new CancelledError();
472
+ }
473
+
474
+ const s = prompter.spinner();
475
+ s.start('Writing agent configurations...');
476
+
477
+ for (const agentName of selectedAgents) {
478
+ const agent = agents.find((a) => a.config.name === agentName);
479
+ if (!agent) continue;
480
+
481
+ const agentPath = pathJoin(agentsDir, `${agentName}.md`);
482
+ const content = toMarkdown(agent);
483
+ await files.writeFile(agentPath, content);
484
+ }
485
+
486
+ s.stop(`Wrote ${selectedAgents.length} agent(s) to ${agentsDir}`);
281
487
  }
282
488
 
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
489
+ async function setupPi(deps: {
490
+ commands: CommandRunner;
491
+ cwd: string;
492
+ files: FileStore;
493
+ homeDir: string;
494
+ prompter: Prompter;
495
+ scope: 'global' | 'project';
496
+ }): Promise<void> {
497
+ const { commands, cwd, files, homeDir, prompter, scope } = deps;
498
+ const s = prompter.spinner();
499
+
500
+ const installed = await commands.checkInstalled('pi');
501
+ if (!installed) {
502
+ throw new PrerequisiteError('pi');
503
+ }
504
+
505
+ const installArguments =
506
+ scope === 'project' ? ['install', '-l', PI_PROVIDER] : ['install', PI_PROVIDER];
507
+
508
+ s.start(`Installing Berget AI provider for Pi...`);
509
+ try {
510
+ await commands.run('pi', installArguments);
511
+ s.stop('Installed Pi provider.');
512
+ } catch {
513
+ s.stop('Pi provider installation failed. Please try again or install manually.');
514
+ throw new CommandFailedError(`pi ${installArguments.join(' ')}`, 1);
515
+ }
516
+
517
+ const settingsPath =
518
+ scope === 'project'
519
+ ? pathJoin(cwd, '.pi', 'settings.json')
520
+ : pathJoin(homeDir, '.pi', 'agent', 'settings.json');
521
+
522
+ const settings = (await readJsonMaybe(files, settingsPath)) || {};
523
+
524
+ if (settings.defaultProvider === 'berget') {
525
+ prompter.note(
526
+ 'Berget AI is already set as your default provider.',
527
+ 'Default provider already set',
528
+ );
529
+ } else {
530
+ if (settings.defaultProvider) {
531
+ const makeDefault = await prompter.confirm({
532
+ initialValue: false,
533
+ message: `Your default provider is ${settings.defaultProvider}. Switch to Berget AI instead?`,
534
+ });
535
+ if (makeDefault) {
536
+ settings.defaultProvider = 'berget';
537
+ await writeJsonFile(files, settingsPath, settings);
538
+ prompter.note('Berget AI is now your default provider.', 'Updated default provider');
539
+ }
295
540
  } 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
541
+ settings.defaultProvider = 'berget';
542
+ await writeJsonFile(files, settingsPath, settings);
543
+ prompter.note('Berget AI is now your default provider.', 'Updated default provider');
302
544
  }
545
+ }
303
546
  }
304
547
 
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'
548
+ async function setupPiAgent(deps: {
549
+ cwd: string;
550
+ files: FileStore;
551
+ homeDir: string;
552
+ prompter: Prompter;
553
+ scope: 'global' | 'project';
554
+ }): Promise<void> {
555
+ const { cwd, files, homeDir, prompter, scope } = deps;
556
+
557
+ const agents = getAllAgents().filter((a) => a.config.mode === 'primary');
558
+
559
+ if (agents.length === 0) {
560
+ return;
561
+ }
562
+
563
+ const selectedAgentName = await prompter.select({
564
+ message: 'Select an agent (optional - press enter to skip):',
565
+ options: [
566
+ { label: 'Skip agent setup', value: '__skip__' },
567
+ ...agents.map((agent) => ({
568
+ hint: agent.config.description,
569
+ label: agent.config.name,
570
+ value: agent.config.name,
571
+ })),
572
+ ],
573
+ });
574
+
575
+ if (selectedAgentName === '__skip__') {
576
+ return;
577
+ }
578
+
579
+ const agent = agents.find((a) => a.config.name === selectedAgentName);
580
+ if (!agent) return;
581
+
582
+ const systemPath =
583
+ scope === 'project'
584
+ ? pathJoin(cwd, '.pi', 'SYSTEM.md')
585
+ : pathJoin(homeDir, '.pi', 'agent', 'SYSTEM.md');
586
+
587
+ const existing = await files.readFile(systemPath);
588
+ const newContent = toPiPrompt(agent);
589
+
590
+ if (existing === newContent) {
591
+ prompter.note('Agent configuration is already up to date.', 'No changes needed');
592
+ return;
593
+ }
594
+
595
+ if (existing) {
596
+ prompter.note(generateDiff(existing, newContent, systemPath), 'Changes to agent configuration');
597
+ } else {
598
+ prompter.note(newContent, 'New agent configuration');
599
+ }
600
+
601
+ const shouldWrite = await prompter.confirm({
602
+ initialValue: true,
603
+ message: existing ? 'Overwrite existing agent configuration?' : 'Create agent configuration?',
604
+ });
605
+
606
+ if (!shouldWrite) {
607
+ throw new CancelledError();
608
+ }
609
+
610
+ const s = prompter.spinner();
611
+ s.start('Writing agent configuration...');
612
+
613
+ const systemDir = scope === 'project' ? pathJoin(cwd, '.pi') : pathJoin(homeDir, '.pi', 'agent');
614
+ await files.mkdir(systemDir);
615
+ await files.writeFile(systemPath, newContent);
616
+
617
+ s.stop(`Wrote agent configuration to ${systemPath}`);
370
618
  }
371
619
 
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'
620
+ function stripJsoncComments(content: string): string {
621
+ content = content.replaceAll(/\/\/.*$/gm, '');
622
+ content = content.replaceAll(/\/\*[\s\S]*?\*\//g, '');
623
+ return content;
624
+ }
378
625
 
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
- }
626
+ async function writeJsonFile(
627
+ files: FileStore,
628
+ filePath: string,
629
+ data: Record<string, unknown>,
630
+ ): Promise<void> {
631
+ await files.writeFile(filePath, JSON.stringify(data, null, 2) + '\n');
402
632
  }