berget 2.2.6 → 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 (144) hide show
  1. package/.github/workflows/publish.yml +6 -6
  2. package/.github/workflows/test.yml +11 -5
  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 +28 -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 +5 -7
  30. package/dist/src/commands/code/__tests__/fake-file-store.js +9 -0
  31. package/dist/src/commands/code/__tests__/fake-prompter.js +60 -18
  32. package/dist/src/commands/code/__tests__/setup-flow.test.js +374 -107
  33. package/dist/src/commands/code/adapters/clack-prompter.js +10 -0
  34. package/dist/src/commands/code/adapters/fs-file-store.js +8 -3
  35. package/dist/src/commands/code/adapters/spawn-command-runner.js +15 -11
  36. package/dist/src/commands/code/auth-sync.js +283 -0
  37. package/dist/src/commands/code/errors.js +4 -4
  38. package/dist/src/commands/code/ports/auth-services.js +2 -0
  39. package/dist/src/commands/code/setup.js +234 -93
  40. package/dist/src/commands/code.js +139 -251
  41. package/dist/src/commands/models.js +13 -15
  42. package/dist/src/commands/users.js +6 -8
  43. package/dist/src/constants/command-structure.js +116 -116
  44. package/dist/src/services/api-key-service.js +43 -48
  45. package/dist/src/services/auth-service.js +60 -299
  46. package/dist/src/services/browser-auth.js +278 -0
  47. package/dist/src/services/chat-service.js +78 -91
  48. package/dist/src/services/cluster-service.js +6 -6
  49. package/dist/src/services/collaborator-service.js +5 -8
  50. package/dist/src/services/flux-service.js +5 -8
  51. package/dist/src/services/helm-service.js +5 -8
  52. package/dist/src/services/kubectl-service.js +7 -10
  53. package/dist/src/utils/config-checker.js +5 -5
  54. package/dist/src/utils/config-loader.js +25 -25
  55. package/dist/src/utils/default-api-key.js +23 -23
  56. package/dist/src/utils/env-manager.js +7 -7
  57. package/dist/src/utils/error-handler.js +60 -61
  58. package/dist/src/utils/logger.js +7 -7
  59. package/dist/src/utils/markdown-renderer.js +2 -2
  60. package/dist/src/utils/opencode-validator.js +17 -20
  61. package/dist/src/utils/token-manager.js +38 -11
  62. package/dist/tests/commands/chat.test.js +24 -24
  63. package/dist/tests/commands/code.test.js +147 -147
  64. package/dist/tests/utils/config-loader.test.js +114 -114
  65. package/dist/tests/utils/env-manager.test.js +57 -57
  66. package/dist/tests/utils/opencode-validator.test.js +33 -33
  67. package/dist/vitest.config.js +1 -1
  68. package/eslint.config.mjs +47 -0
  69. package/index.ts +42 -48
  70. package/package.json +28 -2
  71. package/src/agents/app.ts +27 -0
  72. package/src/agents/backend.ts +24 -0
  73. package/src/agents/devops.ts +33 -0
  74. package/src/agents/frontend.ts +24 -0
  75. package/src/agents/fullstack.ts +24 -0
  76. package/src/agents/index.ts +71 -0
  77. package/src/agents/quality.ts +69 -0
  78. package/src/agents/security.ts +26 -0
  79. package/src/agents/types.ts +17 -0
  80. package/src/client.ts +125 -167
  81. package/src/commands/api-keys.ts +261 -358
  82. package/src/commands/auth.ts +24 -30
  83. package/src/commands/autocomplete.ts +12 -12
  84. package/src/commands/billing.ts +22 -27
  85. package/src/commands/chat.ts +230 -323
  86. package/src/commands/clusters.ts +33 -33
  87. package/src/commands/code/__tests__/auth-sync.test.ts +481 -0
  88. package/src/commands/code/__tests__/fake-api-key-service.ts +13 -0
  89. package/src/commands/code/__tests__/fake-auth-service.ts +50 -0
  90. package/src/commands/code/__tests__/fake-command-runner.ts +39 -42
  91. package/src/commands/code/__tests__/fake-file-store.ts +32 -23
  92. package/src/commands/code/__tests__/fake-prompter.ts +107 -69
  93. package/src/commands/code/__tests__/setup-flow.test.ts +624 -270
  94. package/src/commands/code/adapters/clack-prompter.ts +50 -38
  95. package/src/commands/code/adapters/fs-file-store.ts +31 -27
  96. package/src/commands/code/adapters/spawn-command-runner.ts +33 -29
  97. package/src/commands/code/auth-sync.ts +329 -0
  98. package/src/commands/code/errors.ts +15 -15
  99. package/src/commands/code/ports/auth-services.ts +14 -0
  100. package/src/commands/code/ports/command-runner.ts +8 -4
  101. package/src/commands/code/ports/file-store.ts +5 -4
  102. package/src/commands/code/ports/prompter.ts +24 -18
  103. package/src/commands/code/setup.ts +545 -317
  104. package/src/commands/code.ts +271 -473
  105. package/src/commands/index.ts +19 -19
  106. package/src/commands/models.ts +32 -37
  107. package/src/commands/users.ts +15 -22
  108. package/src/constants/command-structure.ts +119 -142
  109. package/src/services/api-key-service.ts +96 -113
  110. package/src/services/auth-service.ts +92 -339
  111. package/src/services/browser-auth.ts +296 -0
  112. package/src/services/chat-service.ts +246 -279
  113. package/src/services/cluster-service.ts +29 -32
  114. package/src/services/collaborator-service.ts +13 -18
  115. package/src/services/flux-service.ts +16 -18
  116. package/src/services/helm-service.ts +16 -18
  117. package/src/services/kubectl-service.ts +12 -14
  118. package/src/types/api.d.ts +924 -926
  119. package/src/types/json.d.ts +3 -3
  120. package/src/utils/config-checker.ts +10 -10
  121. package/src/utils/config-loader.ts +110 -127
  122. package/src/utils/default-api-key.ts +81 -93
  123. package/src/utils/env-manager.ts +36 -40
  124. package/src/utils/error-handler.ts +83 -78
  125. package/src/utils/logger.ts +41 -41
  126. package/src/utils/markdown-renderer.ts +11 -11
  127. package/src/utils/opencode-validator.ts +51 -56
  128. package/src/utils/token-manager.ts +84 -64
  129. package/templates/agents/app.md +1 -0
  130. package/templates/agents/backend.md +1 -0
  131. package/templates/agents/devops.md +2 -0
  132. package/templates/agents/frontend.md +1 -0
  133. package/templates/agents/fullstack.md +1 -0
  134. package/templates/agents/quality.md +45 -40
  135. package/templates/agents/security.md +1 -0
  136. package/tests/commands/chat.test.ts +60 -70
  137. package/tests/commands/code.test.ts +330 -376
  138. package/tests/utils/config-loader.test.ts +260 -260
  139. package/tests/utils/env-manager.test.ts +127 -134
  140. package/tests/utils/opencode-validator.test.ts +58 -63
  141. package/tsconfig.json +2 -2
  142. package/vitest.config.ts +3 -3
  143. package/AGENTS.md +0 -374
  144. package/TODO.md +0 -19
@@ -1,43 +1,55 @@
1
- import * as p from '@clack/prompts'
2
- import { CancelledError } from '../errors'
3
- import type { Prompter, Spinner } from '../ports/prompter'
1
+ import * as p from "@clack/prompts";
2
+ import { CancelledError } from "../errors";
3
+ import type { Prompter, Spinner } from "../ports/prompter";
4
4
 
5
5
  const unwrap = <T>(v: T | symbol): T => {
6
- if (p.isCancel(v)) throw new CancelledError()
7
- return v as T
8
- }
6
+ if (p.isCancel(v)) throw new CancelledError();
7
+ return v as T;
8
+ };
9
9
 
10
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: {
38
- message: string
39
- initialValue?: boolean
40
- }): Promise<boolean> {
41
- return unwrap(await p.confirm(opts))
42
- }
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
+ }
43
55
  }
@@ -1,33 +1,37 @@
1
- import { promises as fs } from 'node:fs'
2
- import * as path from 'node:path'
3
- import type { FileStore } from '../ports/file-store'
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { FileStore } from "../ports/file-store";
4
4
 
5
5
  export class FsFileStore implements FileStore {
6
- async exists(filePath: string): Promise<boolean> {
7
- try {
8
- await fs.access(filePath)
9
- return true
10
- } catch {
11
- return false
12
- }
13
- }
6
+ async exists(filePath: string): Promise<boolean> {
7
+ try {
8
+ await fs.access(filePath);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
14
 
15
- async readFile(filePath: string): Promise<string | null> {
16
- try {
17
- return await fs.readFile(filePath, 'utf8')
18
- } catch (err: any) {
19
- if (err.code === 'ENOENT') return null
20
- throw err
21
- }
22
- }
15
+ async readFile(filePath: string): Promise<string | null> {
16
+ try {
17
+ return await fs.readFile(filePath, "utf8");
18
+ } catch (err: any) {
19
+ if (err.code === "ENOENT") return null;
20
+ throw err;
21
+ }
22
+ }
23
23
 
24
- async writeFile(filePath: string, content: string): Promise<void> {
25
- const dir = path.dirname(filePath)
26
- await fs.mkdir(dir, { recursive: true })
27
- await fs.writeFile(filePath, content, 'utf8')
28
- }
24
+ async writeFile(filePath: string, content: string): Promise<void> {
25
+ const dir = path.dirname(filePath);
26
+ await fs.mkdir(dir, { recursive: true });
27
+ await fs.writeFile(filePath, content, "utf8");
28
+ }
29
29
 
30
- async mkdir(dir: string): Promise<void> {
31
- await fs.mkdir(dir, { recursive: true })
32
- }
30
+ async mkdir(dir: string): Promise<void> {
31
+ await fs.mkdir(dir, { recursive: true });
32
+ }
33
+
34
+ async chmod(filePath: string, mode: number): Promise<void> {
35
+ await fs.chmod(filePath, mode);
36
+ }
33
37
  }
@@ -1,36 +1,40 @@
1
- import { spawn } from 'node:child_process'
2
- import type { CommandRunner } from '../ports/command-runner'
1
+ import { spawn } from "node:child_process";
2
+ import type { CommandRunner } from "../ports/command-runner";
3
3
 
4
4
  export class SpawnCommandRunner implements CommandRunner {
5
- async checkInstalled(binary: string): Promise<boolean> {
6
- return new Promise((resolve) => {
7
- const child = spawn('which', [binary], { stdio: 'pipe' })
8
- child.on('close', (code) => resolve(code === 0))
9
- child.on('error', () => resolve(false))
10
- })
11
- }
5
+ async checkInstalled(binary: string): Promise<boolean> {
6
+ return new Promise(resolve => {
7
+ const child = spawn("which", [binary], { stdio: "pipe" });
8
+ child.on("close", code => resolve(code === 0));
9
+ child.on("error", () => resolve(false));
10
+ });
11
+ }
12
12
 
13
- async run(command: string, args: readonly string[], options?: { cwd?: string }): Promise<string> {
14
- return new Promise<string>((resolve, reject) => {
15
- const child = spawn(command, args as string[], {
16
- stdio: 'pipe',
17
- cwd: options?.cwd || process.cwd(),
18
- })
13
+ async run(command: string, args: readonly string[], options?: { cwd?: string }): Promise<string> {
14
+ return new Promise<string>((resolve, reject) => {
15
+ const child = spawn(command, args as string[], {
16
+ stdio: "pipe",
17
+ cwd: options?.cwd || process.cwd(),
18
+ });
19
19
 
20
- let stdout = ''
21
- let stderr = ''
20
+ let stdout = "";
21
+ let stderr = "";
22
22
 
23
- child.stdout?.on('data', (d) => { stdout += d.toString() })
24
- child.stderr?.on('data', (d) => { stderr += d.toString() })
23
+ child.stdout?.on("data", d => {
24
+ stdout += d.toString();
25
+ });
26
+ child.stderr?.on("data", d => {
27
+ stderr += d.toString();
28
+ });
25
29
 
26
- child.on('close', (code) => {
27
- if (code === 0) {
28
- resolve(stdout)
29
- } else {
30
- reject(new Error(stderr.trim() || `Command failed with exit code ${code}`))
31
- }
32
- })
33
- child.on('error', (err) => reject(err))
34
- })
35
- }
30
+ child.on("close", code => {
31
+ if (code === 0) {
32
+ resolve(stdout);
33
+ } else {
34
+ reject(new Error(stderr.trim() || `Command failed with exit code ${code}`));
35
+ }
36
+ });
37
+ child.on("error", err => reject(err));
38
+ });
39
+ }
36
40
  }
@@ -0,0 +1,329 @@
1
+ import type { FileStore } from "./ports/file-store";
2
+ import type { Prompter } from "./ports/prompter";
3
+ import type { AuthServicePort, ApiKeyServicePort } from "./ports/auth-services";
4
+
5
+ export interface CliAuth {
6
+ access_token: string;
7
+ refresh_token: string;
8
+ expires_at: number;
9
+ }
10
+
11
+ export interface AuthResult {
12
+ authenticated: boolean;
13
+ }
14
+
15
+ export interface AuthDeps {
16
+ prompter: Prompter;
17
+ files: FileStore;
18
+ authService: AuthServicePort;
19
+ apiKeyService: ApiKeyServicePort;
20
+ homeDir: string;
21
+ }
22
+
23
+ const CLI_AUTH_PATH = (homeDir: string) => homeDir + "/.berget/auth.json";
24
+
25
+ const TOOL_AUTH_PATHS = {
26
+ opencode: (homeDir: string) => homeDir + "/.local/share/opencode/auth.json",
27
+ pi: (homeDir: string) => homeDir + "/.pi/agent/auth.json",
28
+ } as const;
29
+
30
+ const TOOL_API_KEY_TYPES: Record<"opencode" | "pi", string> = {
31
+ opencode: "api",
32
+ pi: "api_key",
33
+ };
34
+
35
+ function extractJwtExpiresAt(accessToken: string): number {
36
+ try {
37
+ const parts = accessToken.split(".");
38
+ if (parts.length !== 3) return 0;
39
+ const payload = Buffer.from(parts[1], "base64url").toString("utf8");
40
+ const decoded = JSON.parse(payload);
41
+ if (typeof decoded.exp === "number") {
42
+ return decoded.exp * 1000; // JWT exp is in seconds, convert to milliseconds
43
+ }
44
+ } catch {
45
+ // If decoding fails, return 0 (treated as expired)
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ export async function readCliAuth(files: FileStore, homeDir: string): Promise<CliAuth | null> {
51
+ const content = await files.readFile(CLI_AUTH_PATH(homeDir));
52
+ if (!content) return null;
53
+ try {
54
+ const parsed = JSON.parse(content);
55
+ if (parsed.access_token && parsed.refresh_token) {
56
+ // Extract the actual expiry time from the JWT token instead of using the stored expires_at
57
+ const jwtExpiresAt = extractJwtExpiresAt(parsed.access_token);
58
+ if (jwtExpiresAt === 0) {
59
+ // Invalid token, return null
60
+ return null;
61
+ }
62
+ return {
63
+ access_token: parsed.access_token,
64
+ refresh_token: parsed.refresh_token,
65
+ expires_at: jwtExpiresAt,
66
+ };
67
+ }
68
+ return null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export async function isToolAuthenticated(
75
+ files: FileStore,
76
+ homeDir: string,
77
+ tool: "opencode" | "pi"
78
+ ): Promise<boolean> {
79
+ const content = await files.readFile(TOOL_AUTH_PATHS[tool](homeDir));
80
+ if (!content) return false;
81
+ try {
82
+ const parsed = JSON.parse(content);
83
+ return typeof parsed.berget === "object" && parsed.berget !== null;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ export function decodeJwtPayload(token: string): unknown | null {
90
+ try {
91
+ const parts = token.split(".");
92
+ if (parts.length !== 3) return null;
93
+ const payload = Buffer.from(parts[1], "base64url").toString("utf8");
94
+ return JSON.parse(payload);
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export function hasBergetCodeSeat(accessToken: string): boolean {
101
+ const payload = decodeJwtPayload(accessToken);
102
+ if (!payload || typeof payload !== "object") return false;
103
+ const p = payload as Record<string, unknown>;
104
+ const realmAccess = p.realm_access as Record<string, unknown> | undefined;
105
+ if (!realmAccess) return false;
106
+ const roles = realmAccess.roles as string[] | undefined;
107
+ if (!Array.isArray(roles)) return false;
108
+ return roles.includes("berget_code_seat");
109
+ }
110
+
111
+ export async function syncOAuthToTool(
112
+ files: FileStore,
113
+ homeDir: string,
114
+ tool: "opencode" | "pi",
115
+ cliAuth: CliAuth
116
+ ): Promise<void> {
117
+ const authPath = TOOL_AUTH_PATHS[tool](homeDir);
118
+ let existing: Record<string, unknown> = {};
119
+
120
+ const content = await files.readFile(authPath);
121
+ if (content) {
122
+ try {
123
+ existing = JSON.parse(content) as Record<string, unknown>;
124
+ } catch {
125
+ existing = {};
126
+ }
127
+ }
128
+
129
+ // Use the JWT's actual expiry time for consistency
130
+ const jwtExpiresAt = extractJwtExpiresAt(cliAuth.access_token);
131
+
132
+ const updated = {
133
+ ...existing,
134
+ berget: {
135
+ type: "oauth",
136
+ access: cliAuth.access_token,
137
+ refresh: cliAuth.refresh_token,
138
+ expires: jwtExpiresAt,
139
+ },
140
+ };
141
+
142
+ await files.writeFile(authPath, JSON.stringify(updated, null, 2) + "\n");
143
+ await files.chmod(authPath, 0o600);
144
+ }
145
+
146
+ export async function syncApiKeyToTool(
147
+ files: FileStore,
148
+ homeDir: string,
149
+ tool: "opencode" | "pi",
150
+ apiKey: string
151
+ ): Promise<void> {
152
+ const authPath = TOOL_AUTH_PATHS[tool](homeDir);
153
+ let existing: Record<string, unknown> = {};
154
+
155
+ const content = await files.readFile(authPath);
156
+ if (content) {
157
+ try {
158
+ existing = JSON.parse(content) as Record<string, unknown>;
159
+ } catch {
160
+ existing = {};
161
+ }
162
+ }
163
+
164
+ const updated = {
165
+ ...existing,
166
+ berget: {
167
+ type: TOOL_API_KEY_TYPES[tool],
168
+ key: apiKey,
169
+ },
170
+ };
171
+
172
+ await files.writeFile(authPath, JSON.stringify(updated, null, 2) + "\n");
173
+ await files.chmod(authPath, 0o600);
174
+ }
175
+
176
+ export function isTokenExpired(expiresAt: number): boolean {
177
+ const now = Date.now();
178
+ const timeUntilExpiry = expiresAt - now;
179
+ const buffer = Math.min(30 * 1000, timeUntilExpiry * 0.1);
180
+ return now + buffer >= expiresAt;
181
+ }
182
+
183
+ export async function configureAuth(deps: AuthDeps, tool: "opencode" | "pi"): Promise<AuthResult> {
184
+ const { prompter, files, authService, apiKeyService, homeDir } = deps;
185
+
186
+ const alreadyAuth = await isToolAuthenticated(files, homeDir, tool);
187
+
188
+ if (alreadyAuth) {
189
+ const choice = await prompter.select<"keep" | "reconfigure">({
190
+ message: `Account is already connected to Berget AI (${tool === "opencode" ? "OpenCode" : "Pi"}). How do you want to proceed?`,
191
+ options: [
192
+ { value: "keep", label: "Keep existing authentication" },
193
+ { value: "reconfigure", label: "Reconfigure — choose a different method" },
194
+ ],
195
+ });
196
+
197
+ if (choice === "keep") {
198
+ return { authenticated: true };
199
+ }
200
+ // Fall through to reconfigure
201
+ } else {
202
+ prompter.note("Authentication required to use Berget AI.", "Connect your account");
203
+ }
204
+
205
+ // Try to reuse existing CLI tokens (from ~/.berget/auth.json)
206
+ let cliAuth: CliAuth | null = await readCliAuth(files, homeDir);
207
+
208
+ if (!cliAuth || isTokenExpired(cliAuth.expires_at)) {
209
+ // No valid tokens → full browser login
210
+ const s = prompter.spinner();
211
+ s.start("Waiting for browser login...");
212
+
213
+ const loginResult = await authService.loginInteractive();
214
+ if (!loginResult.success) {
215
+ s.stop("Login failed.");
216
+ prompter.note(
217
+ `${loginResult.error || "Login timed out or was cancelled."}\n\nPlease run \`berget auth login\` manually, then run \`berget code setup\` again.`,
218
+ "Authentication Failed"
219
+ );
220
+ return { authenticated: false };
221
+ }
222
+
223
+ s.stop("Successfully logged in to Berget.");
224
+
225
+ const jwtExpiresAt = extractJwtExpiresAt(loginResult.accessToken!);
226
+ if (jwtExpiresAt === 0) {
227
+ s.stop("Login succeeded but received invalid token.");
228
+ prompter.note("Please try logging in again or contact support.", "Authentication Error");
229
+ return { authenticated: false };
230
+ }
231
+
232
+ cliAuth = {
233
+ access_token: loginResult.accessToken!,
234
+ refresh_token: loginResult.refreshToken!,
235
+ expires_at: jwtExpiresAt,
236
+ };
237
+ }
238
+
239
+ // Check Berget Code seat
240
+ const jwtPayload = decodeJwtPayload(cliAuth.access_token);
241
+ const hasSeat = jwtPayload ? hasBergetCodeSeat(cliAuth.access_token) : true;
242
+
243
+ // If we can't decode the JWT, sync OAuth anyway — the tokens are valid even if
244
+ // we can't verify the subscription role. Let the tool handle authorization.
245
+ if (!jwtPayload) {
246
+ const s = prompter.spinner();
247
+ s.start("Authenticating with Berget AI...");
248
+ await syncOAuthToTool(files, homeDir, tool, cliAuth);
249
+ s.stop("Authenticated.");
250
+ prompter.note(
251
+ "Warning: Could not verify Berget Code subscription status.\nIf you do not have a subscription, the tool may show an authorization error.",
252
+ "Authentication"
253
+ );
254
+ return { authenticated: true };
255
+ }
256
+
257
+ if (hasSeat) {
258
+ // Case B: Has seat — ask how to authenticate
259
+ const method = await prompter.select<"subscription" | "api_key">({
260
+ message: "You have a Berget Code subscription. How do you want to authenticate?",
261
+ options: [
262
+ { value: "subscription", label: "Use my Berget Code subscription" },
263
+ { value: "api_key", label: "Use an API key instead" },
264
+ ],
265
+ });
266
+
267
+ if (method === "subscription") {
268
+ const s = prompter.spinner();
269
+ s.start("Authenticating with Berget AI via subscription...");
270
+ await syncOAuthToTool(files, homeDir, tool, cliAuth);
271
+ s.stop("Authenticated.");
272
+ return { authenticated: true };
273
+ }
274
+
275
+ // Create API key instead
276
+ const s = prompter.spinner();
277
+ s.start("Creating API key...");
278
+ try {
279
+ const { key } = await apiKeyService.create({
280
+ name: `${tool === "opencode" ? "OpenCode" : "Pi"} (created by berget CLI)`,
281
+ description: "Created by berget code setup",
282
+ });
283
+ await syncApiKeyToTool(files, homeDir, tool, key);
284
+ s.stop("API key created and saved.");
285
+ return { authenticated: true };
286
+ } catch {
287
+ s.stop("API key creation failed.");
288
+ prompter.note(
289
+ "Could not create API key. Please create one manually with `berget api-keys create`.",
290
+ "Error"
291
+ );
292
+ return { authenticated: false };
293
+ }
294
+ }
295
+
296
+ // No Berget Code seat — prompt for API key creation
297
+ const shouldCreate = await prompter.confirm({
298
+ message: "You do not have a Berget Code subscription. Would you like to create a new API key?",
299
+ initialValue: true,
300
+ });
301
+
302
+ if (shouldCreate) {
303
+ const s = prompter.spinner();
304
+ s.start("Creating API key...");
305
+ try {
306
+ const { key } = await apiKeyService.create({
307
+ name: `${tool === "opencode" ? "OpenCode" : "Pi"} (created by berget CLI)`,
308
+ description: "Created by berget code setup",
309
+ });
310
+ await syncApiKeyToTool(files, homeDir, tool, key);
311
+ s.stop("API key created and saved.");
312
+ return { authenticated: true };
313
+ } catch {
314
+ s.stop("API key creation failed.");
315
+ prompter.note(
316
+ "Could not create API key. Please create one manually with `berget api-keys create`.",
317
+ "Error"
318
+ );
319
+ return { authenticated: false };
320
+ }
321
+ }
322
+
323
+ // Case D: Declined
324
+ prompter.note(
325
+ 'Authentication skipped. You\'ll need to set up authentication manually:\n1. Run: berget api-keys create --name "My Key"\n2. Set BERGET_API_KEY environment variable, or\n3. Run `berget auth login` and try again',
326
+ "Authentication"
327
+ );
328
+ return { authenticated: false };
329
+ }
@@ -1,23 +1,23 @@
1
1
  export class PrerequisiteError extends Error {
2
- constructor(public readonly binary: string) {
3
- super(`Required binary not found: ${binary}`)
4
- this.name = 'PrerequisiteError'
5
- }
2
+ constructor(public readonly binary: string) {
3
+ super(`Required binary not found: ${binary}`);
4
+ this.name = "PrerequisiteError";
5
+ }
6
6
  }
7
7
 
8
8
  export class CancelledError extends Error {
9
- constructor() {
10
- super('Wizard cancelled')
11
- this.name = 'CancelledError'
12
- }
9
+ constructor() {
10
+ super("Wizard cancelled");
11
+ this.name = "CancelledError";
12
+ }
13
13
  }
14
14
 
15
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
- }
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
23
  }
@@ -0,0 +1,14 @@
1
+ export interface AuthServicePort {
2
+ login(): Promise<boolean>;
3
+ loginInteractive(): Promise<{
4
+ success: boolean;
5
+ accessToken?: string;
6
+ refreshToken?: string;
7
+ expiresIn?: number;
8
+ error?: string;
9
+ }>;
10
+ }
11
+
12
+ export interface ApiKeyServicePort {
13
+ create(options: { name: string; description?: string }): Promise<{ key: string }>;
14
+ }
@@ -1,6 +1,10 @@
1
1
  export interface CommandRunner {
2
- checkInstalled(binary: string): Promise<boolean>
3
- run(command: string, args: readonly string[], options?: {
4
- cwd?: string
5
- }): Promise<string>
2
+ checkInstalled(binary: string): Promise<boolean>;
3
+ run(
4
+ command: string,
5
+ args: readonly string[],
6
+ options?: {
7
+ cwd?: string;
8
+ }
9
+ ): Promise<string>;
6
10
  }
@@ -1,6 +1,7 @@
1
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>
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
+ chmod(path: string, mode: number): Promise<void>;
6
7
  }