kaizenai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. package/src/shared/types.ts +1028 -0
@@ -0,0 +1,525 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+ import { randomUUID } from "node:crypto"
3
+ import { writeFileSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { createInterface } from "node:readline"
7
+ import type { TranscriptEntry } from "../shared/types"
8
+ import { normalizeToolCall } from "../shared/tools"
9
+ import type { HarnessEvent, HarnessTurn } from "./harness-types"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface StartGeminiTurnArgs {
16
+ content: string
17
+ localPath: string
18
+ model: string
19
+ sessionToken: string | null
20
+ }
21
+
22
+ /** Shape of each JSONL line emitted by `gemini --output-format stream-json`. */
23
+ interface GeminiStreamEvent {
24
+ type: "init" | "message" | "tool_use" | "tool_result" | "error" | "result"
25
+ [key: string]: unknown
26
+ }
27
+
28
+ const GEMINI_STDIO_NOISE = new Set([
29
+ "YOLO mode is enabled. All tool calls will be automatically approved.",
30
+ "Loaded cached credentials.",
31
+ ])
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
38
+ entry: T,
39
+ createdAt = Date.now(),
40
+ ): TranscriptEntry {
41
+ return {
42
+ _id: randomUUID(),
43
+ createdAt,
44
+ ...entry,
45
+ } as TranscriptEntry
46
+ }
47
+
48
+ function parseJsonLine(line: string): GeminiStreamEvent | null {
49
+ try {
50
+ const parsed = JSON.parse(line)
51
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
52
+ return parsed as GeminiStreamEvent
53
+ }
54
+ return null
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ function stringifyPayload(value: unknown): string {
61
+ if (typeof value === "string") return value
62
+ if (value == null) return ""
63
+ try {
64
+ return JSON.stringify(value, null, 2)
65
+ } catch {
66
+ return String(value)
67
+ }
68
+ }
69
+
70
+ function parseObjectPayload(value: unknown): Record<string, unknown> {
71
+ if (value && typeof value === "object" && !Array.isArray(value)) {
72
+ return value as Record<string, unknown>
73
+ }
74
+ if (typeof value === "string") {
75
+ try {
76
+ const parsed = JSON.parse(value)
77
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
78
+ return parsed as Record<string, unknown>
79
+ }
80
+ } catch {
81
+ // Ignore invalid JSON payloads and fall back to an empty record.
82
+ }
83
+ }
84
+ return {}
85
+ }
86
+
87
+ function extractResultMessage(event: GeminiStreamEvent): string {
88
+ const directMessage = typeof event.message === "string" ? event.message.trim() : ""
89
+ if (directMessage) return directMessage
90
+
91
+ const errorText = stringifyPayload(event.error).trim()
92
+ if (errorText) return errorText
93
+
94
+ const detailsText = stringifyPayload(event.details).trim()
95
+ if (detailsText) return detailsText
96
+
97
+ return "Turn failed"
98
+ }
99
+
100
+ function normalizeDiagnosticLine(line: string): string | null {
101
+ const trimmed = line.trim()
102
+ if (!trimmed || GEMINI_STDIO_NOISE.has(trimmed)) return null
103
+ return trimmed
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Event normalisation — maps Gemini stream-json events to TranscriptEntry[]
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function normalizeGeminiStreamEvent(event: GeminiStreamEvent, model: string): {
111
+ entries: TranscriptEntry[]
112
+ sessionId?: string
113
+ /** assistant text delta to accumulate (emitted as one entry on result) */
114
+ textDelta?: string
115
+ } {
116
+ const debugRaw = JSON.stringify(event)
117
+
118
+ switch (event.type) {
119
+ case "init": {
120
+ // Actual field is session_id (snake_case)
121
+ const sessionId = typeof event.session_id === "string" ? event.session_id : undefined
122
+ const eventModel = typeof event.model === "string" ? event.model : model
123
+ return {
124
+ sessionId,
125
+ entries: [
126
+ timestamped({
127
+ kind: "system_init",
128
+ provider: "gemini",
129
+ model: eventModel,
130
+ tools: [],
131
+ agents: [],
132
+ slashCommands: [],
133
+ mcpServers: [],
134
+ debugRaw,
135
+ }),
136
+ ],
137
+ }
138
+ }
139
+
140
+ case "message": {
141
+ const role = typeof event.role === "string" ? event.role : "assistant"
142
+ const content = typeof event.content === "string" ? event.content : ""
143
+ // All assistant messages come with delta:true (streaming chunks).
144
+ // Accumulate them so we can emit one complete entry at result time.
145
+ if (role === "assistant" && content) {
146
+ return { entries: [], textDelta: content }
147
+ }
148
+ return { entries: [] }
149
+ }
150
+
151
+ case "tool_use": {
152
+ // Actual fields: tool_name, tool_id, parameters
153
+ const toolName = typeof event.tool_name === "string"
154
+ ? event.tool_name
155
+ : typeof event.name === "string"
156
+ ? event.name
157
+ : "unknown"
158
+ const toolId = typeof event.tool_id === "string" ? event.tool_id : randomUUID()
159
+ const args = parseObjectPayload(event.parameters ?? event.args)
160
+
161
+ if (toolName === "codebase_investigator" || toolName === "cli_help") {
162
+ const tool = normalizeToolCall({
163
+ toolName,
164
+ toolId,
165
+ input: {
166
+ ...args,
167
+ subagent_type: toolName,
168
+ },
169
+ })
170
+
171
+ return {
172
+ entries: [
173
+ timestamped({
174
+ kind: "tool_call",
175
+ tool,
176
+ debugRaw,
177
+ }),
178
+ ],
179
+ }
180
+ }
181
+
182
+ const mapped = mapGeminiToolName(toolName)
183
+ const tool = normalizeToolCall({ toolName: mapped, toolId, input: args })
184
+
185
+ return {
186
+ entries: [
187
+ timestamped({
188
+ kind: "tool_call",
189
+ tool,
190
+ debugRaw,
191
+ }),
192
+ ],
193
+ }
194
+ }
195
+
196
+ case "tool_result": {
197
+ // Actual fields: tool_id, status, output, error
198
+ const toolId = typeof event.tool_id === "string" ? event.tool_id : randomUUID()
199
+ const output = stringifyPayload(event.output)
200
+ const errorText = stringifyPayload(event.error)
201
+ const isError = event.status === "error"
202
+ return {
203
+ entries: [
204
+ timestamped({
205
+ kind: "tool_result",
206
+ toolId,
207
+ content: isError ? errorText || output : output,
208
+ isError,
209
+ debugRaw,
210
+ }),
211
+ ],
212
+ }
213
+ }
214
+
215
+ case "error": {
216
+ const message = typeof event.message === "string" ? event.message : "Unknown error"
217
+ return {
218
+ entries: [
219
+ timestamped({
220
+ kind: "status",
221
+ status: `Error: ${message}`,
222
+ debugRaw,
223
+ }),
224
+ ],
225
+ }
226
+ }
227
+
228
+ case "result": {
229
+ // Actual fields: status ('success'|'error'), stats.duration_ms
230
+ const success = event.status === "success"
231
+ const stats = (event.stats && typeof event.stats === "object" ? event.stats : {}) as Record<string, unknown>
232
+ const durationMs = typeof stats.duration_ms === "number" ? stats.duration_ms : 0
233
+
234
+ return {
235
+ entries: [
236
+ timestamped({
237
+ kind: "result",
238
+ subtype: success ? "success" : "error",
239
+ isError: !success,
240
+ durationMs,
241
+ result: success ? "Turn completed" : extractResultMessage(event),
242
+ costUsd: undefined,
243
+ }),
244
+ ],
245
+ }
246
+ }
247
+
248
+ default:
249
+ return { entries: [] }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Maps Gemini CLI tool names to the tool names that `normalizeToolCall()` expects.
255
+ * Names sourced from @google/gemini-cli-core ALL_BUILTIN_TOOL_NAMES.
256
+ */
257
+ function mapGeminiToolName(geminiName: string): string {
258
+ switch (geminiName) {
259
+ case "read_file":
260
+ return "Read"
261
+ case "read_many_files":
262
+ return "Read"
263
+ case "write_file":
264
+ return "Write"
265
+ case "replace": // Gemini's edit/replace tool
266
+ return "Edit"
267
+ case "run_shell_command":
268
+ return "Bash"
269
+ case "google_web_search":
270
+ return "WebSearch"
271
+ case "web_fetch":
272
+ return "WebFetch"
273
+ case "glob":
274
+ return "Glob"
275
+ case "grep_search":
276
+ return "Grep"
277
+ case "list_directory":
278
+ case "list_directory_legacy": // legacy alias
279
+ return "LS"
280
+ default:
281
+ return geminiName
282
+ }
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // AsyncQueue — minimal async-iterable queue (same pattern as codex-app-server)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ class AsyncQueue<T> implements AsyncIterable<T> {
290
+ private values: T[] = []
291
+ private resolvers: Array<(value: IteratorResult<T>) => void> = []
292
+ private done = false
293
+
294
+ push(value: T) {
295
+ if (this.done) return
296
+ const resolver = this.resolvers.shift()
297
+ if (resolver) {
298
+ resolver({ value, done: false })
299
+ return
300
+ }
301
+ this.values.push(value)
302
+ }
303
+
304
+ finish() {
305
+ if (this.done) return
306
+ this.done = true
307
+ while (this.resolvers.length > 0) {
308
+ const resolver = this.resolvers.shift()
309
+ resolver?.({ value: undefined as T, done: true })
310
+ }
311
+ }
312
+
313
+ [Symbol.asyncIterator](): AsyncIterator<T> {
314
+ return {
315
+ next: () => {
316
+ if (this.values.length > 0) {
317
+ return Promise.resolve({ value: this.values.shift() as T, done: false })
318
+ }
319
+ if (this.done) {
320
+ return Promise.resolve({ value: undefined as T, done: true })
321
+ }
322
+ return new Promise<IteratorResult<T>>((resolve) => {
323
+ this.resolvers.push(resolve)
324
+ })
325
+ },
326
+ }
327
+ }
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // GeminiCliManager
332
+ // ---------------------------------------------------------------------------
333
+
334
+ export class GeminiCliManager {
335
+ private activeProcesses = new Map<string, ChildProcess>()
336
+ private readonly systemSettingsPath: string
337
+
338
+ constructor() {
339
+ this.systemSettingsPath = join(tmpdir(), `kaizen-gemini-settings-${process.pid}.json`)
340
+ writeFileSync(this.systemSettingsPath, JSON.stringify({
341
+ agents: {
342
+ overrides: {
343
+ codebase_investigator: {
344
+ enabled: false,
345
+ },
346
+ },
347
+ },
348
+ }))
349
+ }
350
+
351
+ /**
352
+ * Start a new turn by spawning `gemini` in headless mode with stream-json output.
353
+ * Returns a HarnessTurn compatible with AgentCoordinator.runTurn().
354
+ */
355
+ async startTurn(args: StartGeminiTurnArgs): Promise<HarnessTurn> {
356
+ const cliArgs = [
357
+ "-p", args.content,
358
+ "--output-format", "stream-json",
359
+ "--model", args.model,
360
+ "--approval-mode", "yolo",
361
+ ]
362
+
363
+ // Resume previous session if we have a token
364
+ if (args.sessionToken) {
365
+ cliArgs.push("--resume", args.sessionToken)
366
+ }
367
+
368
+ const child = spawn("gemini", cliArgs, {
369
+ cwd: args.localPath,
370
+ stdio: ["pipe", "pipe", "pipe"],
371
+ env: {
372
+ ...process.env,
373
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: this.systemSettingsPath,
374
+ },
375
+ })
376
+
377
+ const queue = new AsyncQueue<HarnessEvent>()
378
+ let sessionId: string | null = null
379
+ const stderrChunks: string[] = []
380
+ let assistantTextAccum = ""
381
+ let sawFinalResult = false
382
+ let lastDiagnosticMessage: string | null = null
383
+
384
+ // Track the process for interrupt/close
385
+ const processId = randomUUID()
386
+ this.activeProcesses.set(processId, child)
387
+
388
+ // Read stderr for diagnostics
389
+ if (child.stderr) {
390
+ const stderrRl = createInterface({ input: child.stderr })
391
+ stderrRl.on("line", (line: string) => {
392
+ stderrChunks.push(`${line}\n`)
393
+ const diagnostic = normalizeDiagnosticLine(line)
394
+ if (!diagnostic) return
395
+ lastDiagnosticMessage = diagnostic
396
+ queue.push({
397
+ type: "transcript",
398
+ entry: timestamped({
399
+ kind: "status",
400
+ status: diagnostic,
401
+ }),
402
+ })
403
+ })
404
+ }
405
+
406
+ // Parse stdout line-by-line as JSONL
407
+ if (child.stdout) {
408
+ const rl = createInterface({ input: child.stdout })
409
+
410
+ rl.on("line", (line: string) => {
411
+ const trimmed = line.trim()
412
+ if (!trimmed) return
413
+
414
+ const event = parseJsonLine(trimmed)
415
+ if (!event) return
416
+
417
+ const result = normalizeGeminiStreamEvent(event, args.model)
418
+
419
+ // Capture session ID for resume capability
420
+ if (result.sessionId) {
421
+ sessionId = result.sessionId
422
+ queue.push({ type: "session_token", sessionToken: sessionId })
423
+ }
424
+
425
+ // Accumulate assistant text deltas — Gemini streams all text as delta:true chunks
426
+ if (result.textDelta) {
427
+ assistantTextAccum += result.textDelta
428
+ }
429
+
430
+ // Before emitting the result entry, flush the accumulated assistant text
431
+ if (event.type === "result" && assistantTextAccum) {
432
+ queue.push({
433
+ type: "transcript",
434
+ entry: timestamped({ kind: "assistant_text", text: assistantTextAccum }),
435
+ })
436
+ assistantTextAccum = ""
437
+ }
438
+
439
+ if (event.type === "result") {
440
+ sawFinalResult = true
441
+ }
442
+
443
+ for (const entry of result.entries) {
444
+ queue.push({ type: "transcript", entry })
445
+ }
446
+ })
447
+
448
+ rl.on("close", () => {
449
+ // Flush any remaining accumulated text if process ends without a result event
450
+ if (assistantTextAccum) {
451
+ queue.push({
452
+ type: "transcript",
453
+ entry: timestamped({ kind: "assistant_text", text: assistantTextAccum }),
454
+ })
455
+ assistantTextAccum = ""
456
+ }
457
+ })
458
+ }
459
+
460
+ // Handle process exit
461
+ child.on("close", (code) => {
462
+ this.activeProcesses.delete(processId)
463
+
464
+ // If we haven't received a "result" event, synthesise one from the exit code
465
+ if (!sawFinalResult && code !== 0 && code !== null) {
466
+ const errorMessage = stderrChunks.join("").trim() || lastDiagnosticMessage || `Gemini CLI exited with code ${code}`
467
+ queue.push({
468
+ type: "transcript",
469
+ entry: timestamped({
470
+ kind: "result",
471
+ subtype: "error",
472
+ isError: true,
473
+ durationMs: 0,
474
+ result: errorMessage,
475
+ }),
476
+ })
477
+ }
478
+
479
+ queue.finish()
480
+ })
481
+
482
+ child.on("error", (error) => {
483
+ this.activeProcesses.delete(processId)
484
+ queue.push({
485
+ type: "transcript",
486
+ entry: timestamped({
487
+ kind: "result",
488
+ subtype: "error",
489
+ isError: true,
490
+ durationMs: 0,
491
+ result: error.message.includes("ENOENT")
492
+ ? "Gemini CLI not found. Install it with: npm install -g @google/gemini-cli"
493
+ : `Gemini CLI error: ${error.message}`,
494
+ }),
495
+ })
496
+ queue.finish()
497
+ })
498
+
499
+ return {
500
+ provider: "gemini",
501
+ stream: queue,
502
+ interrupt: async () => {
503
+ if (!child.killed) {
504
+ child.kill("SIGINT")
505
+ }
506
+ },
507
+ close: () => {
508
+ if (!child.killed) {
509
+ child.kill("SIGTERM")
510
+ }
511
+ this.activeProcesses.delete(processId)
512
+ },
513
+ }
514
+ }
515
+
516
+ /** Stop all active Gemini processes (e.g. on server shutdown). */
517
+ stopAll() {
518
+ for (const [id, child] of this.activeProcesses) {
519
+ if (!child.killed) {
520
+ child.kill("SIGTERM")
521
+ }
522
+ this.activeProcesses.delete(id)
523
+ }
524
+ }
525
+ }
@@ -0,0 +1,36 @@
1
+ import { QuickResponseAdapter } from "./quick-response"
2
+
3
+ const TITLE_SCHEMA = {
4
+ type: "object",
5
+ properties: {
6
+ title: { type: "string" },
7
+ },
8
+ required: ["title"],
9
+ additionalProperties: false,
10
+ } as const
11
+
12
+ function normalizeGeneratedTitle(value: unknown): string | null {
13
+ if (typeof value !== "string") return null
14
+ const normalized = value.replace(/\s+/g, " ").trim().slice(0, 80)
15
+ if (!normalized || normalized === "New Chat") return null
16
+ return normalized
17
+ }
18
+
19
+ export async function generateTitleForChat(
20
+ messageContent: string,
21
+ cwd: string,
22
+ adapter = new QuickResponseAdapter()
23
+ ): Promise<string | null> {
24
+ const result = await adapter.generateStructured<string>({
25
+ cwd,
26
+ task: "conversation title generation",
27
+ prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
28
+ schema: TITLE_SCHEMA,
29
+ parse: (value) => {
30
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
31
+ return normalizeGeneratedTitle(output.title)
32
+ },
33
+ })
34
+
35
+ return result
36
+ }
@@ -0,0 +1,79 @@
1
+ import path from "node:path"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import type { GitBranchesResult, GitCreateBranchResult, GitSwitchBranchResult } from "../shared/protocol"
4
+ import { CLI_COMMAND, PROJECT_METADATA_DIR_NAME } from "../shared/branding"
5
+
6
+ export class GitManager {
7
+ private async runGit(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
8
+ const proc = Bun.spawn(["git", "-C", cwd, ...args], {
9
+ stdout: "pipe",
10
+ stderr: "pipe",
11
+ })
12
+ const [exitCode, stdout, stderr] = await Promise.all([
13
+ proc.exited,
14
+ new Response(proc.stdout).text(),
15
+ new Response(proc.stderr).text(),
16
+ ])
17
+ return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }
18
+ }
19
+
20
+ async getBranches(localPath: string): Promise<GitBranchesResult> {
21
+ const [branchResult, headResult] = await Promise.all([
22
+ this.runGit(localPath, ["branch"]),
23
+ this.runGit(localPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
24
+ ])
25
+
26
+ if (branchResult.exitCode !== 0 || headResult.exitCode !== 0) {
27
+ return { isRepo: false, currentBranch: null, branches: [] }
28
+ }
29
+
30
+ const branches = branchResult.stdout
31
+ .split("\n")
32
+ .filter((line) => line.trim().length > 0)
33
+ .map((line) => line.slice(2)) // strip "* " or " " prefix
34
+ .sort((a, b) => a.localeCompare(b))
35
+
36
+ const currentBranch = headResult.stdout === "HEAD" ? null : headResult.stdout
37
+
38
+ return { isRepo: true, currentBranch, branches }
39
+ }
40
+
41
+ async switchBranch(localPath: string, branchName: string): Promise<GitSwitchBranchResult> {
42
+ const result = await this.runGit(localPath, ["switch", branchName])
43
+ if (result.exitCode !== 0) {
44
+ throw new Error(result.stderr || `Failed to switch to branch "${branchName}"`)
45
+ }
46
+ const head = await this.runGit(localPath, ["rev-parse", "--abbrev-ref", "HEAD"])
47
+ return { currentBranch: head.stdout }
48
+ }
49
+
50
+ async createBranch(localPath: string, branchName: string, checkout: boolean): Promise<GitCreateBranchResult> {
51
+ const args = checkout ? ["switch", "-c", branchName] : ["branch", branchName]
52
+ const result = await this.runGit(localPath, args)
53
+ if (result.exitCode !== 0) {
54
+ throw new Error(result.stderr || `Failed to create branch "${branchName}"`)
55
+ }
56
+ const head = await this.runGit(localPath, ["rev-parse", "--abbrev-ref", "HEAD"])
57
+ return { currentBranch: head.stdout }
58
+ }
59
+
60
+ async setProjectMetadataDirectoryCommitMode(localPath: string, commitProjectMetadata: boolean) {
61
+ // Never allow the app repo itself to commit its metadata directory.
62
+ const effectiveCommitMetadata = path.basename(localPath).toLowerCase() === CLI_COMMAND
63
+ ? false
64
+ : commitProjectMetadata
65
+ const gitignorePath = `${localPath}/.gitignore`
66
+ const existing = await readFile(gitignorePath, "utf8").catch(() => "")
67
+ const lines = existing ? existing.split(/\r?\n/) : []
68
+ const filtered = lines.filter((line) => {
69
+ const trimmed = line.trim()
70
+ return trimmed !== PROJECT_METADATA_DIR_NAME
71
+ && trimmed !== `${PROJECT_METADATA_DIR_NAME}/`
72
+ })
73
+
74
+ const nextLines = effectiveCommitMetadata ? filtered : [...filtered, `${PROJECT_METADATA_DIR_NAME}/`]
75
+ const normalized = nextLines.join("\n").replace(/\n{3,}/g, "\n\n")
76
+ const nextContent = normalized.trim().length > 0 ? `${normalized.replace(/\n+$/g, "")}\n` : ""
77
+ await writeFile(gitignorePath, nextContent, "utf8")
78
+ }
79
+ }