@bprp/flockcode 0.0.2

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.
@@ -0,0 +1,355 @@
1
+ // Event discovery driver: sends a real prompt and logs every event to understand
2
+ // how events map to messages, tool calls, and file changes.
3
+ //
4
+ // Usage: bun src/event-discovery.ts
5
+ //
6
+ // Requires: opencode server running on localhost:4096
7
+
8
+ import { createOpencodeClient, type Event as OpencodeEvent } from "@opencode-ai/sdk/v2"
9
+ import { mapMessage } from "./opencode"
10
+
11
+ const client = createOpencodeClient({ baseUrl: "http://localhost:4096" })
12
+
13
+ // ─── Event collection ────────────────────────────────────────────────
14
+ type CollectedEvent = {
15
+ index: number
16
+ timestamp: number
17
+ type: string
18
+ sessionId: string | undefined
19
+ raw: any
20
+ }
21
+
22
+ const events: CollectedEvent[] = []
23
+ let eventIndex = 0
24
+
25
+ // ─── Start event subscription ────────────────────────────────────────
26
+ async function subscribeToEvents(targetSessionId: string): Promise<() => void> {
27
+ const subscription = await client.event.subscribe()
28
+ let running = true
29
+
30
+ const loop = async () => {
31
+ for await (const event of subscription.stream) {
32
+ if (!running) break
33
+ const props = event.properties as any
34
+ const sessionId: string | undefined =
35
+ props.sessionID ??
36
+ props.info?.sessionID ??
37
+ props.info?.id ??
38
+ props.part?.sessionID ??
39
+ undefined
40
+
41
+ // Only collect events for our target session
42
+ if (sessionId !== targetSessionId) continue
43
+
44
+ const collected: CollectedEvent = {
45
+ index: eventIndex++,
46
+ timestamp: Date.now(),
47
+ type: event.type,
48
+ sessionId,
49
+ raw: event,
50
+ }
51
+ events.push(collected)
52
+
53
+ // Print event in real-time
54
+ const summary = summarizeEvent(event)
55
+ console.log(`[EVENT ${collected.index}] ${event.type} ${summary}`)
56
+ }
57
+ }
58
+ loop().catch(() => {})
59
+
60
+ return () => { running = false }
61
+ }
62
+
63
+ function summarizeEvent(event: OpencodeEvent): string {
64
+ const props = event.properties as any
65
+ switch (event.type) {
66
+ case "message.updated": {
67
+ const info = props.info
68
+ return `role=${info?.role} msgId=${info?.id?.slice(0, 8)}`
69
+ }
70
+ case "message.part.updated": {
71
+ const part = props.part
72
+ return `partType=${part?.type} tool=${part?.tool ?? "-"} status=${part?.state?.status ?? "-"} partId=${part?.id?.slice(0, 8)}`
73
+ }
74
+ case "session.updated":
75
+ return `title="${props.info?.title ?? ""}"`
76
+ case "session.status":
77
+ return `status=${props.status}`
78
+ case "session.idle":
79
+ return ""
80
+ case "session.diff":
81
+ return `files=${JSON.stringify(props.files?.map((f: any) => f.file) ?? [])}`
82
+ case "session.error":
83
+ return `error=${JSON.stringify(props.error)}`
84
+ case "message.removed":
85
+ return `msgId=${props.messageID?.slice(0, 8)}`
86
+ case "message.part.removed":
87
+ return `partId=${props.partID?.slice(0, 8)}`
88
+ case "todo.updated":
89
+ return `todo=${JSON.stringify(props.todo?.title ?? "")}`
90
+ case "command.executed":
91
+ return `cmd=${props.command}`
92
+ default:
93
+ return JSON.stringify(props).slice(0, 120)
94
+ }
95
+ }
96
+
97
+ // ─── Run a prompt and collect everything ─────────────────────────────
98
+
99
+ async function runPrompt(sessionId: string, text: string) {
100
+ console.log(`\n${"=".repeat(70)}`)
101
+ console.log(`PROMPTING: "${text}"`)
102
+ console.log(`${"=".repeat(70)}\n`)
103
+
104
+ const res = await client.session.prompt({
105
+ sessionID: sessionId,
106
+ parts: [{ type: "text", text }],
107
+ })
108
+ if (res.error) {
109
+ console.error("Prompt error:", res.error)
110
+ return
111
+ }
112
+ console.log(`\nPrompt returned. Response message role=${(res.data as any)?.info?.role}`)
113
+ }
114
+
115
+ async function fetchMessages(sessionId: string) {
116
+ const res = await client.session.messages({ sessionID: sessionId })
117
+ if (res.error) throw new Error("Failed to fetch messages")
118
+ return (res.data ?? []).map(mapMessage)
119
+ }
120
+
121
+ async function fetchDiffs(sessionId: string) {
122
+ const res = await client.session.diff({ sessionID: sessionId })
123
+ if (res.error) throw new Error("Failed to fetch diffs")
124
+ return res.data ?? []
125
+ }
126
+
127
+ // ─── Analysis ────────────────────────────────────────────────────────
128
+
129
+ function analyzeEvents() {
130
+ console.log(`\n${"=".repeat(70)}`)
131
+ console.log("EVENT ANALYSIS")
132
+ console.log(`${"=".repeat(70)}`)
133
+
134
+ // Event type frequencies
135
+ const typeCounts = new Map<string, number>()
136
+ for (const e of events) {
137
+ typeCounts.set(e.type, (typeCounts.get(e.type) ?? 0) + 1)
138
+ }
139
+ console.log("\nEvent type frequencies:")
140
+ for (const [type, count] of [...typeCounts].sort((a, b) => b[1] - a[1])) {
141
+ console.log(` ${type}: ${count}`)
142
+ }
143
+
144
+ // Message part events breakdown
145
+ const partEvents = events.filter(e => e.type === "message.part.updated")
146
+ const partTypeBreakdown = new Map<string, number>()
147
+ const toolEvents: CollectedEvent[] = []
148
+ for (const e of partEvents) {
149
+ const part = (e.raw.properties as any).part
150
+ const key = part.type === "tool"
151
+ ? `tool:${part.tool}:${part.state?.status}`
152
+ : part.type
153
+ partTypeBreakdown.set(key, (partTypeBreakdown.get(key) ?? 0) + 1)
154
+ if (part.type === "tool") toolEvents.push(e)
155
+ }
156
+ console.log("\nPart event breakdown:")
157
+ for (const [key, count] of [...partTypeBreakdown].sort()) {
158
+ console.log(` ${key}: ${count}`)
159
+ }
160
+
161
+ // Tool lifecycle analysis
162
+ if (toolEvents.length > 0) {
163
+ console.log("\nTool call lifecycle (events in order):")
164
+ const toolCalls = new Map<string, { tool: string; events: { status: string; index: number; title?: string }[] }>()
165
+ for (const e of toolEvents) {
166
+ const part = (e.raw.properties as any).part
167
+ const callId = part.callID ?? part.id
168
+ if (!toolCalls.has(callId)) {
169
+ toolCalls.set(callId, { tool: part.tool, events: [] })
170
+ }
171
+ toolCalls.get(callId)!.events.push({
172
+ status: part.state?.status,
173
+ index: e.index,
174
+ title: part.state?.title,
175
+ })
176
+ }
177
+ for (const [callId, info] of toolCalls) {
178
+ console.log(` ${info.tool} (${callId.slice(0, 8)}):`)
179
+ for (const ev of info.events) {
180
+ console.log(` [${ev.index}] ${ev.status}${ev.title ? ` "${ev.title}"` : ""}`)
181
+ }
182
+ }
183
+ }
184
+
185
+ // Text streaming analysis
186
+ const textEvents = partEvents.filter(e => (e.raw.properties as any).part.type === "text")
187
+ if (textEvents.length > 0) {
188
+ console.log(`\nText part updates: ${textEvents.length} events`)
189
+ // Show first and last text content to understand streaming
190
+ const firstText = (textEvents[0].raw.properties as any).part.text
191
+ const lastText = (textEvents[textEvents.length - 1].raw.properties as any).part.text
192
+ console.log(` First text length: ${firstText?.length ?? 0}`)
193
+ console.log(` Last text length: ${lastText?.length ?? 0}`)
194
+ console.log(` First 100 chars: ${(firstText ?? "").slice(0, 100)}`)
195
+ }
196
+
197
+ // Session status events
198
+ const statusEvents = events.filter(e => e.type === "session.status" || e.type === "session.idle")
199
+ if (statusEvents.length > 0) {
200
+ console.log("\nSession status timeline:")
201
+ for (const e of statusEvents) {
202
+ const props = e.raw.properties as any
203
+ console.log(` [${e.index}] ${e.type} ${props.status ?? ""}`)
204
+ }
205
+ }
206
+
207
+ // Diff events
208
+ const diffEvents = events.filter(e => e.type === "session.diff")
209
+ if (diffEvents.length > 0) {
210
+ console.log("\nDiff events:")
211
+ for (const e of diffEvents) {
212
+ const props = e.raw.properties as any
213
+ console.log(` [${e.index}]`, JSON.stringify(props).slice(0, 200))
214
+ }
215
+ }
216
+ }
217
+
218
+ // ─── Main ────────────────────────────────────────────────────────────
219
+
220
+ async function main() {
221
+ // Create a fresh session
222
+ console.log("Creating session...")
223
+ const createRes = await client.session.create({
224
+ title: "event-discovery-test",
225
+ })
226
+ if (createRes.error || !createRes.data) {
227
+ console.error("Failed to create session:", createRes.error)
228
+ process.exit(1)
229
+ }
230
+ const sessionId = createRes.data.id
231
+ console.log(`Session created: ${sessionId}`)
232
+
233
+ // Start listening for events
234
+ const stopListening = await subscribeToEvents(sessionId)
235
+
236
+ // Wait a beat for subscription to be ready
237
+ await Bun.sleep(500)
238
+
239
+ // ─── Test 1: Simple grep task ───────────────────────────────────────
240
+ await runPrompt(
241
+ sessionId,
242
+ `Use the Grep tool to search for "export function" in the file src/opencode.ts. Then briefly tell me what you found. Do NOT create or modify any files.`,
243
+ )
244
+
245
+ // Wait for events to settle
246
+ await Bun.sleep(3000)
247
+
248
+ // Fetch final state
249
+ console.log("\n--- Final messages state ---")
250
+ const messages1 = await fetchMessages(sessionId)
251
+ for (const m of messages1) {
252
+ console.log(`Message ${m.id.slice(0, 8)} role=${m.role} parts=${m.parts.length}`)
253
+ for (const p of m.parts) {
254
+ if (p.type === "text") {
255
+ console.log(` text: ${p.text.slice(0, 100)}...`)
256
+ } else if (p.type === "tool") {
257
+ console.log(` tool: ${p.tool} status=${p.state.status} title=${p.state.title ?? "-"}`)
258
+ } else {
259
+ console.log(` ${p.type}`)
260
+ }
261
+ }
262
+ }
263
+
264
+ // Analyze events from test 1
265
+ analyzeEvents()
266
+
267
+ // ─── Test 2: File creation task ─────────────────────────────────────
268
+ const test2StartIndex = events.length
269
+ console.log(`\n\n${"#".repeat(70)}`)
270
+ console.log("TEST 2: File creation")
271
+ console.log(`${"#".repeat(70)}`)
272
+
273
+ await runPrompt(
274
+ sessionId,
275
+ `Create a new file called /tmp/event-discovery-test.txt with the content "hello from event discovery". Use the write tool.`,
276
+ )
277
+
278
+ await Bun.sleep(3000)
279
+
280
+ // Check diffs
281
+ console.log("\n--- Diffs after file creation ---")
282
+ const diffs = await fetchDiffs(sessionId)
283
+ console.log("Diffs:", JSON.stringify(diffs, null, 2).slice(0, 500))
284
+
285
+ // Analyze new events
286
+ const test2Events = events.slice(test2StartIndex)
287
+ console.log(`\nTest 2 events (${test2Events.length}):`)
288
+ for (const e of test2Events) {
289
+ console.log(` [${e.index}] ${e.type} ${summarizeEvent(e.raw)}`)
290
+ }
291
+
292
+ // Final full analysis
293
+ analyzeEvents()
294
+
295
+ // ─── Dump raw events for offline analysis ─────────────────────────
296
+ const dumpPath = "/tmp/event-discovery-dump.json"
297
+ await Bun.write(dumpPath, JSON.stringify(events.map(e => ({
298
+ index: e.index,
299
+ type: e.type,
300
+ sessionId: e.sessionId,
301
+ properties: e.raw.properties,
302
+ })), null, 2))
303
+ console.log(`\nRaw events dumped to ${dumpPath}`)
304
+
305
+ // Summary of findings
306
+ console.log(`\n${"=".repeat(70)}`)
307
+ console.log("RECONSTRUCTION GUIDE")
308
+ console.log(`${"=".repeat(70)}`)
309
+ console.log(`
310
+ Key findings for reconstructing session state from events:
311
+
312
+ 1. MESSAGE UPDATES:
313
+ - "message.updated" fires when a message is created/modified
314
+ - properties.info contains full message metadata (id, role, sessionID, etc.)
315
+ - Use message.info.id to track which message is being updated
316
+
317
+ 2. MESSAGE PARTS (streaming):
318
+ - "message.part.updated" fires for each part creation/update
319
+ - properties.part contains: { type, id, sessionID, messageID, ... }
320
+ - Text parts: text field grows as content streams in
321
+ - Tool parts: state transitions through pending → running → completed/error
322
+
323
+ 3. TOOL CALL LIFECYCLE:
324
+ - part.type === "tool" with part.state.status tracking:
325
+ pending → running → completed (or error)
326
+ - part.tool = tool name, part.callID = unique call ID
327
+ - part.state.input = tool arguments
328
+ - part.state.output = tool result (on completed)
329
+ - part.state.title = human-readable description (on running/completed)
330
+
331
+ 4. FILE CHANGES:
332
+ - "session.diff" event fires when files are modified
333
+ - Use session.diff API to get full before/after content
334
+ - Part type "patch" in messages tracks which files were affected
335
+
336
+ 5. SESSION STATUS:
337
+ - "session.status" = session is busy (running)
338
+ - "session.idle" = session is done processing
339
+
340
+ 6. RECONSTRUCTION STRATEGY:
341
+ - Listen to all events for a session
342
+ - On "message.part.updated": update the specific part in your local state
343
+ - On "message.updated": update message metadata
344
+ - On "session.diff": refresh file changes
345
+ - On "session.idle": mark session as idle, do final state sync
346
+ `)
347
+
348
+ stopListening()
349
+ process.exit(0)
350
+ }
351
+
352
+ main().catch((err) => {
353
+ console.error("Fatal:", err)
354
+ process.exit(1)
355
+ })
@@ -0,0 +1,72 @@
1
+ // End-to-end test: validates that the StateStream correctly reconstructs
2
+ // messages from the OpenCode event stream during a live prompt.
3
+ //
4
+ // Tests the durable stream state sync directly.
5
+ //
6
+ // Usage: bun src/event-driven-test.ts
7
+ // Requires: opencode server on localhost:4096
8
+
9
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
10
+ import { Opencode, mapMessage } from "./opencode"
11
+ import type { OpencodeEventCallback } from "./opencode"
12
+
13
+ const OPENCODE_URL = "http://localhost:4096"
14
+
15
+ async function main() {
16
+ const client = createOpencodeClient({ baseUrl: OPENCODE_URL })
17
+ const opencode = new Opencode(OPENCODE_URL)
18
+
19
+ // Create a fresh session
20
+ console.log("Creating session...")
21
+ const createRes = await client.session.create({ title: "event-driven-test" })
22
+ if (createRes.error || !createRes.data) throw new Error("Failed to create session")
23
+ const sessionId = createRes.data.id
24
+ console.log(`Session: ${sessionId}`)
25
+
26
+ // Track events received
27
+ let eventCount = 0
28
+ const onEvent: OpencodeEventCallback = (event) => {
29
+ eventCount++
30
+ console.log(`[Event #${eventCount}] ${event.type}`)
31
+ }
32
+ await opencode.spawnListener(onEvent, OPENCODE_URL)
33
+
34
+ // Send prompt directly via SDK
35
+ console.log("\n--- Sending prompt ---")
36
+ const promptRes = await client.session.prompt({
37
+ sessionID: sessionId,
38
+ parts: [{ type: "text", text: 'Respond with just the word "hi". Nothing else.' }],
39
+ })
40
+ if (promptRes.error) throw new Error(`Prompt failed: ${JSON.stringify(promptRes.error)}`)
41
+ console.log(`Prompt returned: role=${(promptRes.data as any)?.info?.role}`)
42
+
43
+ // Wait for trailing events
44
+ await Bun.sleep(3000)
45
+
46
+ // Fetch final messages via SDK
47
+ const msgsRes = await client.session.messages({ sessionID: sessionId })
48
+ const messages = (msgsRes.data ?? []).map(mapMessage)
49
+
50
+ console.log(`\n${"=".repeat(60)}`)
51
+ console.log("RESULTS")
52
+ console.log(`${"=".repeat(60)}`)
53
+ console.log(`Total events received: ${eventCount}`)
54
+ console.log(`Final messages: ${messages.length}`)
55
+
56
+ for (const msg of messages) {
57
+ const partsSummary = msg.parts.map((p: any) => {
58
+ if (p.type === "text") return `text(${p.text.length}ch)`
59
+ if (p.type === "tool") return `tool:${p.tool}:${p.state.status}`
60
+ return p.type
61
+ }).join(", ")
62
+ console.log(` ${msg.role}[${msg.id.slice(0, 8)}]: ${partsSummary}`)
63
+ }
64
+
65
+ console.log("\nDone.")
66
+ process.exit(0)
67
+ }
68
+
69
+ main().catch((err) => {
70
+ console.error("Fatal:", err)
71
+ process.exit(1)
72
+ })
package/src/index.ts ADDED
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI entrypoint for the flockcode tool.
4
+ *
5
+ * Usage:
6
+ * flock start [--opencode-url <url>] [--port <port>] — start the HTTP server
7
+ * flock attach — attach to the managed opencode server
8
+ * flock sprite sync [--opencode-url <url>] [--dry-run] — sync projects to Fly Sprite
9
+ * flock sprite configure-services [--dry-run] — configure services & env on Sprite
10
+ * [--opencode-port <port>] [--opencode-dir <dir>]
11
+ * [--flock-server-port <port>]
12
+ * [--flock-auth-token <token>] [--gemini-api-key <key>]
13
+ * [--transcription-model <model>]
14
+ */
15
+
16
+ import { Crust } from "@crustjs/core"
17
+ import { helpPlugin, versionPlugin } from "@crustjs/plugins"
18
+ import { flag, commandValidator } from "@crustjs/validate/zod"
19
+ import { z } from "zod/v4"
20
+ import { createClient } from "./opencode"
21
+ import { ensureOpenCode, opencodeStore } from "./spawn-opencode"
22
+ import { createSpriteClientFromEnv } from "./sprites"
23
+ import { sync } from "./sprite-sync"
24
+ import { configureServices, type ServiceResult } from "./sprite-configure-services"
25
+ import { startServer } from "./start-server"
26
+ import { env } from "./env"
27
+
28
+ /** Format a service result as a human-readable status string. */
29
+ function serviceStatus(r: ServiceResult): string {
30
+ if (r.created) return "created"
31
+ if (r.updated) return "updated"
32
+ return "unchanged"
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // start — launch the Bun HTTP server
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const start = new Crust("start")
40
+ .meta({ description: "Start the HTTP server" })
41
+ .flags({
42
+ "opencode-url": flag(
43
+ z.string().url().optional().describe("OpenCode server URL"),
44
+ { short: "u" },
45
+ ),
46
+ port: flag(
47
+ z.coerce.number().int().positive().optional().describe("Server port"),
48
+ { short: "p" },
49
+ ),
50
+ })
51
+ .run(commandValidator(async ({ flags }) => {
52
+ const opencodeUrl = flags["opencode-url"] || undefined
53
+ const port = flags.port ?? env.PORT
54
+
55
+ await startServer({ opencodeUrl, port })
56
+
57
+ // Keep the process alive — Bun.serve runs in the background
58
+ await new Promise(() => {})
59
+ }))
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // attach — connect to a flock-managed opencode server
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const attach = new Crust("attach")
66
+ .meta({ description: "Attach to the flock-managed opencode server" })
67
+ .run(commandValidator(async () => {
68
+ const { port } = await opencodeStore.read()
69
+ if (!port) {
70
+ console.error("No managed opencode server found. Start one with `flock start` first.")
71
+ process.exit(1)
72
+ }
73
+
74
+ const url = `http://localhost:${port}`
75
+ const cwd = process.cwd()
76
+ const child = Bun.spawn(["opencode", "attach", url, "--dir", cwd], {
77
+ stdin: "inherit",
78
+ stdout: "inherit",
79
+ stderr: "inherit",
80
+ })
81
+
82
+ const exitCode = await child.exited
83
+ process.exit(exitCode)
84
+ }))
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // sprite sync — sync projects to Fly Sprite
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const spriteSync = new Crust("sync")
91
+ .meta({ description: "Sync projects to Fly Sprite" })
92
+ .flags({
93
+ "opencode-url": flag(
94
+ z.string().url().optional().describe("OpenCode server URL"),
95
+ { short: "u" },
96
+ ),
97
+ "dry-run": flag(
98
+ z.boolean().default(false).describe("Show what would happen without making changes"),
99
+ { short: "n" },
100
+ ),
101
+ })
102
+ .run(commandValidator(async ({ flags }) => {
103
+ const opencodeUrl = flags["opencode-url"] || env.OPENCODE_URL || undefined
104
+ const { url, child } = await ensureOpenCode(opencodeUrl)
105
+ const dryRun = flags["dry-run"]
106
+
107
+ const opencode = createClient(url)
108
+ const sprite = createSpriteClientFromEnv()
109
+
110
+ if (dryRun) {
111
+ console.log("Dry run — no changes will be made.\n")
112
+ }
113
+
114
+ try {
115
+ const result = await sync(sprite, opencode, { dryRun })
116
+
117
+ console.log("\n--- Summary ---")
118
+ console.log(`Cloned: ${result.cloned.length}`)
119
+ console.log(`Existing: ${result.alreadyExists.length}`)
120
+ console.log(`Uploaded: ${result.filesUploaded.length}`)
121
+ if (result.filesSkipped.length > 0) {
122
+ console.log(`Skipped: ${result.filesSkipped.length}`)
123
+ }
124
+ if (result.warnings.length > 0) {
125
+ console.log(`Warnings: ${result.warnings.length}`)
126
+ }
127
+ } catch (err: any) {
128
+ console.error("Sync failed:", err.message ?? err)
129
+ } finally {
130
+ if (child) {
131
+ try { child.kill() } catch {}
132
+ }
133
+ }
134
+ }))
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // sprite configure-services — configure services & env on Sprite
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const spriteConfigure = new Crust("configure-services")
141
+ .meta({ description: "Configure services and environment on Sprite" })
142
+ .flags({
143
+ "dry-run": flag(
144
+ z.boolean().default(false).describe("Show what would happen without making changes"),
145
+ { short: "n" },
146
+ ),
147
+ "opencode-port": flag(
148
+ z.coerce.number().int().positive().optional().describe("Port for opencode serve on Sprite"),
149
+ ),
150
+ "opencode-dir": flag(
151
+ z.string().optional().describe("Working directory for opencode serve on Sprite"),
152
+ ),
153
+ "flock-server-port": flag(
154
+ z.coerce.number().int().positive().optional().describe("Port for the flock server on Sprite"),
155
+ ),
156
+ "flock-auth-token": flag(
157
+ z.string().optional().describe("Bearer token for mobile client auth (written to .flockenv)"),
158
+ ),
159
+ "gemini-api-key": flag(
160
+ z.string().optional().describe("Gemini API key for transcription (written to .flockenv)"),
161
+ ),
162
+ "transcription-model": flag(
163
+ z.string().optional().describe("Gemini model for transcription (gemini-3-flash-preview or gemini-3.1-flash-lite-preview)"),
164
+ ),
165
+ })
166
+ .run(commandValidator(async ({ flags }) => {
167
+ const dryRun = flags["dry-run"]
168
+ const opencodePort = flags["opencode-port"]
169
+ const opencodeDir = flags["opencode-dir"]
170
+ const flockServerPort = flags["flock-server-port"]
171
+ const flockAuthToken = flags["flock-auth-token"] ?? (env.FLOCK_AUTH_TOKEN || undefined)
172
+ const geminiApiKey = flags["gemini-api-key"] ?? (env.GEMINI_API_KEY || undefined)
173
+ const transcriptionModel = flags["transcription-model"] ?? (env.TRANSCRIPTION_MODEL || undefined)
174
+
175
+ const sprite = createSpriteClientFromEnv()
176
+
177
+ if (dryRun) {
178
+ console.log("Dry run — no changes will be made.\n")
179
+ }
180
+
181
+ try {
182
+ const result = await configureServices(sprite, {
183
+ dryRun,
184
+ opencodePort,
185
+ opencodeDir,
186
+ flockServerPort,
187
+ flockAuthToken,
188
+ geminiApiKey,
189
+ transcriptionModel,
190
+ })
191
+
192
+ console.log("\n--- Summary ---")
193
+ console.log(`Env file: .flockenv ${result.flockenvWritten ? "written" : "unchanged"}`)
194
+ console.log(`Service: opencode-serve ${serviceStatus(result.opencodeServe)}`)
195
+ console.log(`Service: flock-server ${serviceStatus(result.flockServer)}`)
196
+ } catch (err: any) {
197
+ console.error("Configure-services failed:", err.message ?? err)
198
+ process.exit(1)
199
+ }
200
+ }))
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // sprite — container command grouping Sprite operations
204
+ // ---------------------------------------------------------------------------
205
+
206
+ const sprite = new Crust("sprite")
207
+ .meta({ description: "Fly Sprite management commands" })
208
+ .command(spriteSync)
209
+ .command(spriteConfigure)
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // root — flockcode CLI
213
+ // ---------------------------------------------------------------------------
214
+
215
+ const main = new Crust("flock")
216
+ .meta({ description: "Mobile AI coding agent server" })
217
+ .use(versionPlugin("0.0.1"))
218
+ .use(helpPlugin())
219
+ .command(start)
220
+ .command(attach)
221
+ .command(sprite)
222
+
223
+ await main.execute()