berget 2.2.5 → 2.2.7

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 (148) hide show
  1. package/.github/workflows/publish.yml +8 -8
  2. package/.github/workflows/test.yml +12 -6
  3. package/.husky/pre-commit +1 -0
  4. package/.prettierignore +15 -0
  5. package/.prettierrc +5 -3
  6. package/CONTRIBUTING.md +38 -0
  7. package/README.md +2 -148
  8. package/dist/index.js +21 -21
  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 +54 -62
  20. package/dist/src/commands/api-keys.js +132 -140
  21. package/dist/src/commands/auth.js +9 -9
  22. package/dist/src/commands/autocomplete.js +9 -9
  23. package/dist/src/commands/billing.js +7 -9
  24. package/dist/src/commands/chat.js +90 -92
  25. package/dist/src/commands/clusters.js +12 -12
  26. package/dist/src/commands/code/__tests__/auth-sync.test.js +348 -0
  27. package/dist/src/commands/code/__tests__/fake-api-key-service.js +23 -0
  28. package/dist/src/commands/code/__tests__/fake-auth-service.js +55 -0
  29. package/dist/src/commands/code/__tests__/fake-command-runner.js +50 -0
  30. package/dist/src/commands/code/__tests__/fake-file-store.js +55 -0
  31. package/dist/src/commands/code/__tests__/fake-prompter.js +133 -0
  32. package/dist/src/commands/code/__tests__/setup-flow.test.js +505 -0
  33. package/dist/src/commands/code/adapters/clack-prompter.js +81 -0
  34. package/dist/src/commands/code/adapters/fs-file-store.js +80 -0
  35. package/dist/src/commands/code/adapters/spawn-command-runner.js +53 -0
  36. package/dist/src/commands/code/auth-sync.js +283 -0
  37. package/dist/src/commands/code/errors.js +27 -0
  38. package/dist/src/commands/code/ports/auth-services.js +2 -0
  39. package/dist/src/commands/code/ports/command-runner.js +2 -0
  40. package/dist/src/commands/code/ports/file-store.js +2 -0
  41. package/dist/src/commands/code/ports/prompter.js +2 -0
  42. package/dist/src/commands/code/setup.js +533 -0
  43. package/dist/src/commands/code.js +223 -779
  44. package/dist/src/commands/models.js +13 -15
  45. package/dist/src/commands/users.js +6 -8
  46. package/dist/src/constants/command-structure.js +116 -114
  47. package/dist/src/services/api-key-service.js +43 -48
  48. package/dist/src/services/auth-service.js +60 -299
  49. package/dist/src/services/browser-auth.js +278 -0
  50. package/dist/src/services/chat-service.js +78 -91
  51. package/dist/src/services/cluster-service.js +6 -6
  52. package/dist/src/services/collaborator-service.js +5 -8
  53. package/dist/src/services/flux-service.js +5 -8
  54. package/dist/src/services/helm-service.js +5 -8
  55. package/dist/src/services/kubectl-service.js +7 -10
  56. package/dist/src/utils/config-checker.js +5 -5
  57. package/dist/src/utils/config-loader.js +25 -25
  58. package/dist/src/utils/default-api-key.js +23 -23
  59. package/dist/src/utils/env-manager.js +7 -7
  60. package/dist/src/utils/error-handler.js +60 -61
  61. package/dist/src/utils/logger.js +7 -7
  62. package/dist/src/utils/markdown-renderer.js +2 -2
  63. package/dist/src/utils/opencode-validator.js +17 -20
  64. package/dist/src/utils/token-manager.js +38 -11
  65. package/dist/tests/commands/chat.test.js +24 -24
  66. package/dist/tests/commands/code.test.js +169 -138
  67. package/dist/tests/utils/config-loader.test.js +114 -114
  68. package/dist/tests/utils/env-manager.test.js +57 -57
  69. package/dist/tests/utils/opencode-validator.test.js +44 -43
  70. package/dist/vitest.config.js +1 -1
  71. package/eslint.config.mjs +47 -0
  72. package/index.ts +42 -48
  73. package/package.json +30 -2
  74. package/src/agents/app.ts +27 -0
  75. package/src/agents/backend.ts +24 -0
  76. package/src/agents/devops.ts +33 -0
  77. package/src/agents/frontend.ts +24 -0
  78. package/src/agents/fullstack.ts +24 -0
  79. package/src/agents/index.ts +71 -0
  80. package/src/agents/quality.ts +69 -0
  81. package/src/agents/security.ts +26 -0
  82. package/src/agents/types.ts +17 -0
  83. package/src/client.ts +125 -167
  84. package/src/commands/api-keys.ts +261 -358
  85. package/src/commands/auth.ts +24 -30
  86. package/src/commands/autocomplete.ts +12 -12
  87. package/src/commands/billing.ts +22 -27
  88. package/src/commands/chat.ts +230 -323
  89. package/src/commands/clusters.ts +33 -33
  90. package/src/commands/code/__tests__/auth-sync.test.ts +481 -0
  91. package/src/commands/code/__tests__/fake-api-key-service.ts +13 -0
  92. package/src/commands/code/__tests__/fake-auth-service.ts +50 -0
  93. package/src/commands/code/__tests__/fake-command-runner.ts +44 -0
  94. package/src/commands/code/__tests__/fake-file-store.ts +44 -0
  95. package/src/commands/code/__tests__/fake-prompter.ts +121 -0
  96. package/src/commands/code/__tests__/setup-flow.test.ts +628 -0
  97. package/src/commands/code/adapters/clack-prompter.ts +55 -0
  98. package/src/commands/code/adapters/fs-file-store.ts +37 -0
  99. package/src/commands/code/adapters/spawn-command-runner.ts +40 -0
  100. package/src/commands/code/auth-sync.ts +329 -0
  101. package/src/commands/code/errors.ts +23 -0
  102. package/src/commands/code/ports/auth-services.ts +14 -0
  103. package/src/commands/code/ports/command-runner.ts +10 -0
  104. package/src/commands/code/ports/file-store.ts +7 -0
  105. package/src/commands/code/ports/prompter.ts +29 -0
  106. package/src/commands/code/setup.ts +630 -0
  107. package/src/commands/code.ts +335 -1074
  108. package/src/commands/index.ts +19 -19
  109. package/src/commands/models.ts +32 -37
  110. package/src/commands/users.ts +15 -22
  111. package/src/constants/command-structure.ts +120 -140
  112. package/src/services/api-key-service.ts +96 -113
  113. package/src/services/auth-service.ts +92 -339
  114. package/src/services/browser-auth.ts +296 -0
  115. package/src/services/chat-service.ts +246 -279
  116. package/src/services/cluster-service.ts +29 -32
  117. package/src/services/collaborator-service.ts +13 -18
  118. package/src/services/flux-service.ts +16 -18
  119. package/src/services/helm-service.ts +16 -18
  120. package/src/services/kubectl-service.ts +12 -14
  121. package/src/types/api.d.ts +924 -926
  122. package/src/types/json.d.ts +3 -3
  123. package/src/utils/config-checker.ts +10 -10
  124. package/src/utils/config-loader.ts +110 -127
  125. package/src/utils/default-api-key.ts +81 -93
  126. package/src/utils/env-manager.ts +36 -40
  127. package/src/utils/error-handler.ts +83 -78
  128. package/src/utils/logger.ts +41 -41
  129. package/src/utils/markdown-renderer.ts +11 -11
  130. package/src/utils/opencode-validator.ts +51 -56
  131. package/src/utils/token-manager.ts +84 -64
  132. package/templates/agents/app.md +23 -0
  133. package/templates/agents/backend.md +23 -0
  134. package/templates/agents/devops.md +30 -0
  135. package/templates/agents/frontend.md +25 -0
  136. package/templates/agents/fullstack.md +23 -0
  137. package/templates/agents/quality.md +69 -0
  138. package/templates/agents/security.md +21 -0
  139. package/tests/commands/chat.test.ts +60 -70
  140. package/tests/commands/code.test.ts +346 -345
  141. package/tests/utils/config-loader.test.ts +260 -260
  142. package/tests/utils/env-manager.test.ts +127 -134
  143. package/tests/utils/opencode-validator.test.ts +65 -69
  144. package/tsconfig.json +2 -2
  145. package/vitest.config.ts +3 -3
  146. package/AGENTS.md +0 -374
  147. package/TODO.md +0 -19
  148. package/opencode.json +0 -146
@@ -0,0 +1,628 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { runSetup } from "../setup";
3
+ import { CancelledError, CommandFailedError, PrerequisiteError } from "../errors";
4
+ import { FakePrompter, CANCEL, select, confirm, multiselect } from "./fake-prompter";
5
+ import { FakeFileStore } from "./fake-file-store";
6
+ import { FakeCommandRunner } from "./fake-command-runner";
7
+ import { FakeAuthService } from "./fake-auth-service";
8
+ import { FakeApiKeyService } from "./fake-api-key-service";
9
+ import type { AuthServicePort, ApiKeyServicePort } from "../ports/auth-services";
10
+
11
+ const makeDeps = (
12
+ overrides: Partial<Parameters<typeof runSetup>[0]> = {}
13
+ ): Parameters<typeof runSetup>[0] => {
14
+ return {
15
+ prompter: overrides.prompter ?? new FakePrompter([]),
16
+ files: overrides.files ?? new FakeFileStore(),
17
+ commands:
18
+ overrides.commands ??
19
+ new FakeCommandRunner()
20
+ .handle("opencode --version", "mocked")
21
+ .handle("pi --version", "mocked"),
22
+ authService: (overrides.authService as AuthServicePort) ?? new FakeAuthService(false),
23
+ apiKeyService:
24
+ (overrides.apiKeyService as ApiKeyServicePort) ?? new FakeApiKeyService("sk_ber_test"),
25
+ homeDir: "/home/user",
26
+ cwd: "/home/user/project",
27
+ ...Object.fromEntries(
28
+ Object.entries(overrides).filter(
29
+ ([k]) =>
30
+ k !== "prompter" &&
31
+ k !== "files" &&
32
+ k !== "commands" &&
33
+ k !== "authService" &&
34
+ k !== "apiKeyService"
35
+ )
36
+ ),
37
+ };
38
+ };
39
+
40
+ function base64urlEncode(data: string): string {
41
+ return Buffer.from(data).toString("base64url");
42
+ }
43
+
44
+ function makeJwt(payload: Record<string, unknown>): string {
45
+ const header = base64urlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
46
+ const body = base64urlEncode(JSON.stringify(payload));
47
+ return `${header}.${body}.signature`;
48
+ }
49
+
50
+ describe("runSetup", () => {
51
+ describe("happy path", () => {
52
+ it("sets up opencode project without existing config", async () => {
53
+ const deps = makeDeps({
54
+ prompter: new FakePrompter([
55
+ select("opencode"),
56
+ select("project"),
57
+ confirm(true, "Create"), // Config write
58
+ multiselect([]), // No agents selected
59
+ ]),
60
+ });
61
+
62
+ await runSetup(deps);
63
+
64
+ const files = deps.files as FakeFileStore;
65
+ const written = files.getWrittenFiles();
66
+ expect(written.has("/home/user/project/opencode.json")).toBe(true);
67
+ const config = JSON.parse(written.get("/home/user/project/opencode.json")!);
68
+ expect(config.plugin).toContain("@bergetai/opencode-auth@1.0.16");
69
+ });
70
+
71
+ it("sets up opencode globally without existing config", async () => {
72
+ const deps = makeDeps({
73
+ prompter: new FakePrompter([
74
+ select("opencode"),
75
+ select("global"),
76
+ confirm(true, "Create"), // Config write
77
+ multiselect([]), // No agents selected
78
+ ]),
79
+ });
80
+
81
+ await runSetup(deps);
82
+
83
+ const files = deps.files as FakeFileStore;
84
+ const written = files.getWrittenFiles();
85
+ expect(written.has("/home/user/.config/opencode/opencode.json")).toBe(true);
86
+ });
87
+
88
+ it("sets up pi project with fresh install", async () => {
89
+ const deps = makeDeps({
90
+ prompter: new FakePrompter([
91
+ select("pi"),
92
+ select("project"),
93
+ select("fullstack"), // Agent selection
94
+ confirm(true, "Create"),
95
+ ]),
96
+ commands: new FakeCommandRunner()
97
+ .handle("pi --version", "mocked") // For checkInstalled
98
+ .handle("pi install", ""), // For actual install
99
+ });
100
+
101
+ await runSetup(deps);
102
+
103
+ const commands = deps.commands as FakeCommandRunner;
104
+ expect(commands.calls.length).toBeGreaterThan(0);
105
+ const installCall = commands.calls.find(c => c.command === "pi");
106
+ expect(installCall?.args).toContain("npm:@bergetai/pi-provider");
107
+ });
108
+
109
+ it("skips agent selection for pi project", async () => {
110
+ const deps = makeDeps({
111
+ prompter: new FakePrompter([
112
+ select("pi"),
113
+ select("project"),
114
+ select("__skip__"), // Skip agent selection
115
+ ]),
116
+ commands: new FakeCommandRunner()
117
+ .handle("pi --version", "mocked") // For checkInstalled
118
+ .handle("pi install", ""), // For actual install
119
+ });
120
+
121
+ await runSetup(deps);
122
+
123
+ const files = deps.files as FakeFileStore;
124
+ const written = files.getWrittenFiles();
125
+ // Should not create any agent files
126
+ for (const path of written.keys()) {
127
+ expect(path).not.toContain("SYSTEM.md");
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("prerequisites", () => {
133
+ it("throws PrerequisiteError when opencode is not installed", async () => {
134
+ const deps = makeDeps({
135
+ prompter: new FakePrompter([select("opencode"), select("project")]),
136
+ commands: new FakeCommandRunner(),
137
+ });
138
+
139
+ // Simulate opencode not being installed
140
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(PrerequisiteError);
141
+ });
142
+ });
143
+
144
+ describe("cancellation", () => {
145
+ it("throws CancelledError when user cancels at tool selection", async () => {
146
+ const deps = makeDeps({
147
+ prompter: new FakePrompter([select(CANCEL)]),
148
+ });
149
+
150
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
151
+ });
152
+
153
+ it("throws CancelledError when user cancels at write confirmation", async () => {
154
+ const deps = makeDeps({
155
+ prompter: new FakePrompter([
156
+ select("opencode"),
157
+ select("project"),
158
+ confirm(false, "Create"),
159
+ ]),
160
+ });
161
+
162
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
163
+ });
164
+
165
+ it("throws CancelledError when user cancels at agent write confirmation (opencode)", async () => {
166
+ const deps = makeDeps({
167
+ prompter: new FakePrompter([
168
+ select("opencode"),
169
+ select("project"),
170
+ confirm(true, "Create"),
171
+ multiselect(["backend", "frontend"]),
172
+ confirm(false, "agent"),
173
+ ]),
174
+ });
175
+
176
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
177
+ });
178
+
179
+ it("throws CancelledError when user cancels at agent write confirmation (pi)", async () => {
180
+ const deps = makeDeps({
181
+ prompter: new FakePrompter([
182
+ select("pi"),
183
+ select("project"),
184
+ select("fullstack"),
185
+ confirm(false, /Create|Overwrite/),
186
+ ]),
187
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
188
+ });
189
+
190
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CancelledError);
191
+ });
192
+ });
193
+
194
+ describe("file operations", () => {
195
+ it("preserves existing configuration keys when updating", async () => {
196
+ const deps = makeDeps({
197
+ prompter: new FakePrompter([
198
+ select("opencode"),
199
+ select("project"),
200
+ confirm(true, "Write"),
201
+ multiselect([]),
202
+ ]),
203
+ });
204
+
205
+ const files = deps.files as FakeFileStore;
206
+ files.seed(
207
+ "/home/user/project/opencode.json",
208
+ JSON.stringify({
209
+ customField: "should-preserve",
210
+ plugin: ["other-plugin"],
211
+ })
212
+ );
213
+
214
+ await runSetup(deps);
215
+
216
+ const written = files.getWrittenFiles();
217
+ const config = JSON.parse(written.get("/home/user/project/opencode.json")!);
218
+ expect(config.customField).toBe("should-preserve");
219
+ expect(config.plugin).toContain("other-plugin");
220
+ expect(config.plugin).toContain("@bergetai/opencode-auth@1.0.16");
221
+ });
222
+
223
+ it("preserves jsonc comments when updating", async () => {
224
+ const deps = makeDeps({
225
+ prompter: new FakePrompter([
226
+ select("opencode"),
227
+ select("project"),
228
+ confirm(true, "Write"),
229
+ multiselect([]),
230
+ ]),
231
+ });
232
+
233
+ const files = deps.files as FakeFileStore;
234
+ files.seed(
235
+ "/home/user/project/opencode.jsonc",
236
+ `{
237
+ // This is my custom config
238
+ "customField": "should-preserve",
239
+ /* block comment explaining plugin */
240
+ "plugin": ["other-plugin"]
241
+ }`
242
+ );
243
+
244
+ await runSetup(deps);
245
+
246
+ const written = files.getWrittenFiles();
247
+ const content = written.get("/home/user/project/opencode.jsonc")!;
248
+ expect(content).toContain("// This is my custom config");
249
+ expect(content).toContain("/* block comment explaining plugin */");
250
+ expect(content).toContain('"customField": "should-preserve"');
251
+ expect(content).toContain("@bergetai/opencode-auth@1.0.16");
252
+ });
253
+
254
+ it("shows no changes needed when config is already up to date", async () => {
255
+ const deps = makeDeps({
256
+ prompter: new FakePrompter([select("opencode"), select("project"), multiselect([])]),
257
+ });
258
+
259
+ const files = deps.files as FakeFileStore;
260
+ // Already has the exact same plugin version
261
+ files.seed(
262
+ "/home/user/project/opencode.json",
263
+ JSON.stringify(
264
+ {
265
+ $schema: "https://opencode.ai/config.json",
266
+ plugin: ["@bergetai/opencode-auth@1.0.16"],
267
+ },
268
+ null,
269
+ 2
270
+ ) + "\n"
271
+ );
272
+
273
+ await runSetup(deps);
274
+
275
+ // Check that no write happened — content should be unchanged
276
+ const written = files.getWrittenFiles();
277
+ const content = written.get("/home/user/project/opencode.json")!;
278
+ const config = JSON.parse(content);
279
+ expect(config.plugin).toEqual(["@bergetai/opencode-auth@1.0.16"]);
280
+ expect(content).toContain("$schema");
281
+ });
282
+
283
+ it("preserves existing Pi settings when setting defaultProvider", async () => {
284
+ const deps = makeDeps({
285
+ prompter: new FakePrompter([
286
+ select("pi"),
287
+ select("project"),
288
+ select("fullstack"),
289
+ confirm(true, "Create"),
290
+ ]),
291
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
292
+ });
293
+
294
+ const files = deps.files as FakeFileStore;
295
+ files.seed(
296
+ "/home/user/project/.pi/settings.json",
297
+ JSON.stringify({
298
+ existingKey: "should-preserve",
299
+ anotherSetting: true,
300
+ })
301
+ );
302
+
303
+ await runSetup(deps);
304
+
305
+ const written = files.getWrittenFiles();
306
+ const settings = JSON.parse(written.get("/home/user/project/.pi/settings.json")!);
307
+ expect(settings.existingKey).toBe("should-preserve");
308
+ expect(settings.anotherSetting).toBe(true);
309
+ expect(settings.defaultProvider).toBe("berget");
310
+ });
311
+
312
+ it("creates parent directories when writing files", async () => {
313
+ const deps = makeDeps({
314
+ prompter: new FakePrompter([
315
+ select("opencode"),
316
+ select("global"),
317
+ confirm(true, "Create"),
318
+ multiselect([]),
319
+ ]),
320
+ });
321
+
322
+ await runSetup(deps);
323
+
324
+ const files = deps.files as FakeFileStore;
325
+ const written = files.getWrittenFiles();
326
+ expect(written.has("/home/user/.config/opencode/opencode.json")).toBe(true);
327
+ });
328
+ });
329
+
330
+ describe("command execution", () => {
331
+ it("passes arguments as array (no shell injection)", async () => {
332
+ const deps = makeDeps({
333
+ prompter: new FakePrompter([
334
+ select("pi"),
335
+ select("project"),
336
+ select("fullstack"),
337
+ confirm(true, "Create"),
338
+ ]),
339
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
340
+ });
341
+
342
+ await runSetup(deps);
343
+
344
+ const commands = deps.commands as FakeCommandRunner;
345
+ const installCall = commands.calls.find(c => c.command === "pi");
346
+ expect(installCall?.args).toContain("npm:@bergetai/pi-provider");
347
+ expect(installCall?.args).toContain("-l");
348
+ });
349
+ });
350
+
351
+ describe("error handling", () => {
352
+ it("throws CommandFailedError when pi install fails", async () => {
353
+ const deps = makeDeps({
354
+ prompter: new FakePrompter([select("pi"), select("project")]),
355
+ commands: new FakeCommandRunner()
356
+ .handle("pi --version", "mocked")
357
+ .handle("pi install", new Error("npm error")),
358
+ });
359
+
360
+ await expect(runSetup(deps)).rejects.toBeInstanceOf(CommandFailedError);
361
+ });
362
+ });
363
+
364
+ describe("auth integration", () => {
365
+ it("already authenticated shows simplified message", async () => {
366
+ const files = new FakeFileStore();
367
+ files.seed(
368
+ "/home/user/.local/share/opencode/auth.json",
369
+ JSON.stringify({ berget: { type: "oauth" } })
370
+ );
371
+
372
+ const deps = makeDeps({
373
+ prompter: new FakePrompter([
374
+ select("opencode"),
375
+ select("project"),
376
+ select("keep"), // New: keep existing auth
377
+ confirm(true, "Create"), // Config write
378
+ multiselect([]),
379
+ ]),
380
+ files,
381
+ });
382
+
383
+ await runSetup(deps);
384
+
385
+ const prompter = deps.prompter as FakePrompter;
386
+ const notes = prompter.calls.filter(c => c.method === "note");
387
+ const lastNote = notes[notes.length - 1];
388
+ expect(JSON.stringify(lastNote)).toContain("Run: opencode");
389
+ expect(JSON.stringify(lastNote)).not.toContain("/connect");
390
+ });
391
+
392
+ it("login failure shows manual auth instructions", async () => {
393
+ const deps = makeDeps({
394
+ prompter: new FakePrompter([
395
+ select("pi"),
396
+ select("project"),
397
+ select("fullstack"),
398
+ confirm(true, "Create"),
399
+ ]),
400
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
401
+ authService: new FakeAuthService(false),
402
+ files: new FakeFileStore(), // No pre-seeded auth → auth flow runs
403
+ });
404
+
405
+ await runSetup(deps);
406
+
407
+ const prompter = deps.prompter as FakePrompter;
408
+ const notes = prompter.calls.filter(c => c.method === "note");
409
+ const lastNote = notes[notes.length - 1];
410
+ expect(JSON.stringify(lastNote)).toContain("/login");
411
+ });
412
+
413
+ it("creates api key for pi when no seat", async () => {
414
+ const files = new FakeFileStore();
415
+
416
+ const deps = makeDeps({
417
+ prompter: new FakePrompter([
418
+ select("pi"),
419
+ select("project"),
420
+ confirm(true), // API key creation prompt
421
+ select("fullstack"),
422
+ confirm(true, "Create"),
423
+ ]),
424
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
425
+ authService: new FakeAuthService(true, false), // succeed, no seat
426
+ files,
427
+ });
428
+
429
+ await runSetup(deps);
430
+
431
+ const written = files.getWrittenFiles();
432
+ expect(written.has("/home/user/.pi/agent/auth.json")).toBe(true);
433
+ const parsed = JSON.parse(written.get("/home/user/.pi/agent/auth.json")!);
434
+ expect(parsed.berget.type).toBe("api_key");
435
+ });
436
+
437
+ it("uses subscription when berget_code_seat present", async () => {
438
+ const files = new FakeFileStore();
439
+ const farFuture = Math.floor(Date.now() / 1000) + 3600 * 24 * 365; // 1 year from now in seconds
440
+ files.seed(
441
+ "/home/user/.berget/auth.json",
442
+ JSON.stringify({
443
+ access_token: makeJwt({ realm_access: { roles: ["berget_code_seat"] }, exp: farFuture }),
444
+ refresh_token: "ref",
445
+ expires_at: farFuture * 1000,
446
+ })
447
+ );
448
+
449
+ const deps = makeDeps({
450
+ prompter: new FakePrompter([
451
+ select("opencode"),
452
+ select("project"),
453
+ select("subscription"),
454
+ confirm(true, "Create"),
455
+ multiselect([]),
456
+ ]),
457
+ files,
458
+ });
459
+
460
+ await runSetup(deps);
461
+
462
+ const written = files.getWrittenFiles();
463
+ const parsed = JSON.parse(written.get("/home/user/.local/share/opencode/auth.json")!);
464
+ expect(parsed.berget.type).toBe("oauth");
465
+ });
466
+ });
467
+
468
+ describe("agent configuration", () => {
469
+ it("sets up multiple agents for opencode project", async () => {
470
+ const deps = makeDeps({
471
+ prompter: new FakePrompter([
472
+ select("opencode"),
473
+ select("project"),
474
+ confirm(true, "Create"),
475
+ multiselect(["backend", "frontend"]),
476
+ confirm(true, "agent"),
477
+ ]),
478
+ });
479
+
480
+ await runSetup(deps);
481
+
482
+ const files = deps.files as FakeFileStore;
483
+ const written = files.getWrittenFiles();
484
+ expect(written.has("/home/user/project/.opencode/agents/backend.md")).toBe(true);
485
+ expect(written.has("/home/user/project/.opencode/agents/frontend.md")).toBe(true);
486
+ });
487
+
488
+ it("sets up no agents for opencode when none selected", async () => {
489
+ const deps = makeDeps({
490
+ prompter: new FakePrompter([
491
+ select("opencode"),
492
+ select("project"),
493
+ confirm(true, "Create"),
494
+ multiselect([]),
495
+ ]),
496
+ });
497
+
498
+ await runSetup(deps);
499
+
500
+ const files = deps.files as FakeFileStore;
501
+ const written = files.getWrittenFiles();
502
+ for (const path of written.keys()) {
503
+ expect(path).not.toMatch(/agents\/\w+\.md$/);
504
+ }
505
+ });
506
+
507
+ it("sets up agent globally for opencode", async () => {
508
+ const deps = makeDeps({
509
+ prompter: new FakePrompter([
510
+ select("opencode"),
511
+ select("global"),
512
+ confirm(true, "Create"),
513
+ multiselect(["fullstack"]),
514
+ confirm(true, "agent"),
515
+ ]),
516
+ });
517
+
518
+ await runSetup(deps);
519
+
520
+ const files = deps.files as FakeFileStore;
521
+ const written = files.getWrittenFiles();
522
+ expect(written.has("/home/user/.config/opencode/agents/fullstack.md")).toBe(true);
523
+ });
524
+
525
+ it("sets up agent for pi project", async () => {
526
+ const deps = makeDeps({
527
+ prompter: new FakePrompter([
528
+ select("pi"),
529
+ select("project"),
530
+ select("fullstack"),
531
+ confirm(true, "Create"),
532
+ ]),
533
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
534
+ });
535
+
536
+ await runSetup(deps);
537
+
538
+ const files = deps.files as FakeFileStore;
539
+ const written = files.getWrittenFiles();
540
+ expect(written.has("/home/user/project/.pi/SYSTEM.md")).toBe(true);
541
+ });
542
+
543
+ it("sets up agent for pi globally", async () => {
544
+ const deps = makeDeps({
545
+ prompter: new FakePrompter([
546
+ select("pi"),
547
+ select("global"),
548
+ select("backend"),
549
+ confirm(true, "Create"),
550
+ ]),
551
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
552
+ });
553
+
554
+ await runSetup(deps);
555
+
556
+ const files = deps.files as FakeFileStore;
557
+ const written = files.getWrittenFiles();
558
+ expect(written.has("/home/user/.pi/agent/SYSTEM.md")).toBe(true);
559
+ });
560
+
561
+ it("skips writing identical opencode agent files", async () => {
562
+ const deps = makeDeps({
563
+ prompter: new FakePrompter([
564
+ select("opencode"),
565
+ select("project"),
566
+ confirm(true, "Create"),
567
+ multiselect(["backend", "frontend"]),
568
+ confirm(true, "agent"),
569
+ ]),
570
+ });
571
+
572
+ // First run writes the files
573
+ await runSetup(deps);
574
+
575
+ const files = deps.files as FakeFileStore;
576
+ const firstBackend = files
577
+ .getWrittenFiles()
578
+ .get("/home/user/project/.opencode/agents/backend.md");
579
+ const firstFrontend = files
580
+ .getWrittenFiles()
581
+ .get("/home/user/project/.opencode/agents/frontend.md");
582
+
583
+ // Second run with exact same content should not prompt for overwrite
584
+ const deps2 = makeDeps({
585
+ files,
586
+ prompter: new FakePrompter([
587
+ select("opencode"),
588
+ select("project"),
589
+ multiselect(["backend", "frontend"]),
590
+ ]),
591
+ });
592
+
593
+ await runSetup(deps2);
594
+
595
+ // Content should be unchanged
596
+ expect(files.getWrittenFiles().get("/home/user/project/.opencode/agents/backend.md")).toBe(
597
+ firstBackend
598
+ );
599
+ expect(files.getWrittenFiles().get("/home/user/project/.opencode/agents/frontend.md")).toBe(
600
+ firstFrontend
601
+ );
602
+ });
603
+
604
+ it("overwrites pi SYSTEM.md when content differs", async () => {
605
+ const files = new FakeFileStore();
606
+ files.seed("/home/user/project/.pi/SYSTEM.md", "old agent content");
607
+
608
+ const deps = makeDeps({
609
+ prompter: new FakePrompter([
610
+ select("pi"),
611
+ select("project"),
612
+ select("fullstack"),
613
+ confirm(true, "Overwrite"),
614
+ ]),
615
+ files,
616
+ commands: new FakeCommandRunner().handle("pi --version", "mocked").handle("pi install", ""),
617
+ });
618
+
619
+ await runSetup(deps);
620
+
621
+ const written = files.getWrittenFiles();
622
+ const content = written.get("/home/user/project/.pi/SYSTEM.md");
623
+ expect(content).not.toBe("old agent content");
624
+ // Pi doesn't use front matter, so check for system prompt content
625
+ expect(content).toContain("Fullstack Agent");
626
+ });
627
+ });
628
+ });
@@ -0,0 +1,55 @@
1
+ import * as p from "@clack/prompts";
2
+ import { CancelledError } from "../errors";
3
+ import type { Prompter, Spinner } from "../ports/prompter";
4
+
5
+ const unwrap = <T>(v: T | symbol): T => {
6
+ if (p.isCancel(v)) throw new CancelledError();
7
+ return v as T;
8
+ };
9
+
10
+ export class ClackPrompter implements Prompter {
11
+ intro(message: string): void {
12
+ p.intro(message);
13
+ }
14
+ outro(message: string): void {
15
+ p.outro(message);
16
+ }
17
+ note(message: string, title?: string): void {
18
+ p.note(message, title);
19
+ }
20
+ spinner(): Spinner {
21
+ const s = p.spinner();
22
+ return {
23
+ start: (msg: string) => s.start(msg),
24
+ stop: (msg: string) => s.stop(msg),
25
+ };
26
+ }
27
+ async select<T>(opts: {
28
+ message: string;
29
+ options: ReadonlyArray<{
30
+ value: T;
31
+ label: string;
32
+ hint?: string;
33
+ }>;
34
+ }): Promise<T> {
35
+ return unwrap(await p.select(opts as any));
36
+ }
37
+ async confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean> {
38
+ return unwrap(await p.confirm(opts));
39
+ }
40
+
41
+ async text(opts: { message: string; placeholder?: string }): Promise<string> {
42
+ return unwrap(await p.text(opts));
43
+ }
44
+
45
+ async multiselect<T>(opts: {
46
+ message: string;
47
+ options: ReadonlyArray<{
48
+ value: T;
49
+ label: string;
50
+ hint?: string;
51
+ }>;
52
+ }): Promise<T[]> {
53
+ return unwrap(await p.multiselect(opts as any));
54
+ }
55
+ }