zooid 0.6.1 → 0.7.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 (78) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1797 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-N5POSZX5.js +31000 -0
  6. package/dist/chunk-N5POSZX5.js.map +1 -0
  7. package/dist/index.d.ts +40 -0
  8. package/dist/index.js +4 -3790
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-BT_v3DKu.js +295 -0
  14. package/dist/web/assets/index-DJTghnF-.css +1 -0
  15. package/dist/web/assets/index-JZMMlqDP.js +118066 -0
  16. package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +42 -22
  20. package/src/bin.ts +112 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +142 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/commands/dev-cascade.test.ts +38 -0
  38. package/src/commands/dev-cascade.ts +35 -0
  39. package/src/commands/dev-data-layout.test.ts +55 -0
  40. package/src/commands/dev.ts +308 -0
  41. package/src/commands/init/generators.test.ts +97 -0
  42. package/src/commands/init/generators.ts +119 -0
  43. package/src/commands/init/prompts.ts +123 -0
  44. package/src/commands/init/registry.test.ts +33 -0
  45. package/src/commands/init/registry.ts +75 -0
  46. package/src/commands/init/sniff.test.ts +41 -0
  47. package/src/commands/init/sniff.ts +30 -0
  48. package/src/commands/init.test.ts +131 -0
  49. package/src/commands/init.ts +158 -0
  50. package/src/commands/logs.test.ts +105 -0
  51. package/src/commands/logs.ts +108 -0
  52. package/src/commands/start.ts +28 -0
  53. package/src/commands/status.test.ts +73 -0
  54. package/src/commands/status.ts +100 -0
  55. package/src/daemon/start-daemon.test.ts +67 -0
  56. package/src/daemon/start-daemon.ts +243 -0
  57. package/src/index.ts +2 -0
  58. package/src/observability/capture-agent.ts +58 -0
  59. package/src/observability/capture-tuwunel.test.ts +57 -0
  60. package/src/observability/capture-tuwunel.ts +24 -0
  61. package/src/observability/file-sink.test.ts +68 -0
  62. package/src/observability/file-sink.ts +78 -0
  63. package/src/observability/observability.integration.test.ts +162 -0
  64. package/src/observability/paths.test.ts +104 -0
  65. package/src/observability/paths.ts +94 -0
  66. package/src/services/tuwunel.test.ts +64 -0
  67. package/src/services/tuwunel.ts +91 -0
  68. package/src/web/resolve.test.ts +50 -0
  69. package/src/web/resolve.ts +31 -0
  70. package/src/web/static.test.ts +51 -0
  71. package/src/web/static.ts +61 -0
  72. package/src/web/watch.ts +103 -0
  73. package/dist/chunk-67ZRMVHO.js +0 -174
  74. package/dist/chunk-AR456MHY.js +0 -29
  75. package/dist/chunk-VBGU2NST.js +0 -139
  76. package/dist/client-4VMFEFDX.js +0 -22
  77. package/dist/config-2KK5GX42.js +0 -27
  78. package/dist/template-T5IB4YWC.js +0 -92
@@ -0,0 +1,308 @@
1
+ import chalk from 'chalk'
2
+ import { Listr } from 'listr2'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { serve, type ServerType } from '@hono/node-server'
7
+ import {
8
+ findConfigFile,
9
+ findMatrixTransport,
10
+ loadZooidConfig,
11
+ } from '@zooid/core'
12
+ import { ensureAdminUser } from '../bootstrap/admin.js'
13
+ import { writeBootstrapConfigs } from '../bootstrap/configs.js'
14
+ import { resolveDataLayout } from '../bootstrap/data-layout.js'
15
+ import { deriveHomeserverShape } from '../bootstrap/derive.js'
16
+ import { resolvePaths } from '../bootstrap/paths.js'
17
+ import { ensureTokens, type Tokens } from '../bootstrap/tokens.js'
18
+ import { startDaemon, type DaemonHandle } from '../daemon/start-daemon.js'
19
+ import { TuwunelService } from '../services/tuwunel.js'
20
+ import { resolveWebRoot, webSourcePackage } from '../web/resolve.js'
21
+ import { webStatic } from '../web/static.js'
22
+ import { startWebWatch, type WebWatchHandle } from '../web/watch.js'
23
+ import {
24
+ ensureDayFolder,
25
+ pruneOldDays,
26
+ resolveLogPaths,
27
+ type LogPaths,
28
+ } from '../observability/paths.js'
29
+ import {
30
+ wireAgentCapture,
31
+ type AgentCapture,
32
+ } from '../observability/capture-agent.js'
33
+ import { captureChildToFile } from '../observability/capture-tuwunel.js'
34
+ import { buildShutdown } from './dev-cascade.js'
35
+
36
+ // Walk up from this module until we find the @zooid/cli package.json. This
37
+ // is robust to the source/bundle distinction: src/commands/dev.ts and
38
+ // dist/bin.js end up at different depths, so a fixed `..` count breaks
39
+ // after `tsup`. The package's "name" field disambiguates from any parent.
40
+ const CLI_ROOT = (() => {
41
+ let dir = dirname(fileURLToPath(import.meta.url))
42
+ for (let i = 0; i < 8; i++) {
43
+ const pkgPath = resolve(dir, 'package.json')
44
+ if (existsSync(pkgPath)) {
45
+ try {
46
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string }
47
+ if (pkg.name === 'zooid' || pkg.name === '@zooid/cli') return dir
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+ const parent = dirname(dir)
53
+ if (parent === dir) break
54
+ dir = parent
55
+ }
56
+ throw new Error('cli root not found (no @zooid/cli package.json above ' + import.meta.url + ')')
57
+ })()
58
+
59
+ export interface DevFlags {
60
+ cwd?: string
61
+ dataDir: string
62
+ hostPort?: number
63
+ uiPort: number
64
+ engine: 'docker' | 'podman'
65
+ adminUser: string
66
+ adminPassword: string
67
+ installSignalHandlers?: boolean
68
+ foreground?: boolean
69
+ // When true, run `vite build --watch` against the in-tree @zoon/web package
70
+ // and serve its dist directly. Requires running from the monorepo source.
71
+ watchWeb?: boolean
72
+ }
73
+
74
+ export interface DevHandle {
75
+ stop: () => Promise<void>
76
+ }
77
+
78
+ interface DevCtx {
79
+ tokens?: Tokens
80
+ svc?: TuwunelService
81
+ daemon?: DaemonHandle
82
+ uiServer?: ServerType
83
+ webWatch?: WebWatchHandle
84
+ logPaths?: LogPaths
85
+ captures?: Record<string, AgentCapture>
86
+ tuwunelCaptureDone?: Promise<void>
87
+ }
88
+
89
+ export async function runDev(flags: DevFlags): Promise<DevHandle> {
90
+ const cwd = flags.cwd ?? process.cwd()
91
+ const found = findConfigFile(cwd)
92
+ if (!found) throw new Error(`zooid.yaml not found in ${cwd}`)
93
+ // Load .env files from the workforce dir before anything reads process.env.
94
+ // Order matches the convention used by Vite/Next: .env.local overrides .env,
95
+ // and real shell vars override both (loadEnvFile never overwrites existing
96
+ // process.env entries). Spawned agents inherit these via runtime-local.
97
+ loadEnvFiles(cwd)
98
+
99
+ // Resolve data paths up front — they only depend on cwd/flags, not the
100
+ // parsed config — so we can ensure tokens before any loadZooidConfig call.
101
+ const dataRoot = resolve(cwd, flags.dataDir)
102
+ const layout = resolveDataLayout(dataRoot)
103
+ const paths = resolvePaths(layout.matrixDir)
104
+ const logPaths = resolveLogPaths({ dataDir: layout.dataRoot })
105
+
106
+ // Bootstrap AS tokens and export to env BEFORE parsing zooid.yaml. The
107
+ // parser's MATRIX_AS_TOKEN / MATRIX_HS_TOKEN defaults (see [ZOD048]) and
108
+ // any explicit `${MATRIX_AS_TOKEN}` interpolations both need the vars set.
109
+ const tokens = ensureTokens(paths.envPath)
110
+ process.env.MATRIX_AS_TOKEN = tokens.asToken
111
+ process.env.MATRIX_HS_TOKEN = tokens.hsToken
112
+
113
+ const rawYaml = readFileSync(found.path, 'utf8')
114
+ const preview = loadZooidConfig(rawYaml)
115
+ const matrix = findMatrixTransport(preview)
116
+ if (!matrix) {
117
+ throw new Error('zooid.yaml: zooid dev requires at least one matrix transport')
118
+ }
119
+ const agentUserIds = Object.values(preview.agents)
120
+ .filter((a) => a.matrix?.transport === matrix.name)
121
+ .map((a) => a.matrix!.user_id)
122
+ const shape = deriveHomeserverShape(matrix.transport, agentUserIds)
123
+ const port = flags.hostPort ?? shape.port
124
+ const homeserver = `http://localhost:${port}`
125
+
126
+ await ensureDayFolder(logPaths)
127
+ await pruneOldDays({ dataDir: layout.dataRoot, retainDays: 14 })
128
+
129
+ const ctx: DevCtx = { logPaths, tokens }
130
+
131
+ const tasks = new Listr<DevCtx>(
132
+ [
133
+ {
134
+ title: 'Write tuwunel.toml + appservice.yaml',
135
+ task: () => {
136
+ if (!ctx.tokens) throw new Error('tokens not ready')
137
+ writeBootstrapConfigs({
138
+ paths,
139
+ serverName: shape.serverName,
140
+ asToken: ctx.tokens.asToken,
141
+ hsToken: ctx.tokens.hsToken,
142
+ senderLocalpart: matrix.transport.sender_localpart,
143
+ userNamespace: matrix.transport.user_namespace,
144
+ })
145
+ },
146
+ },
147
+ {
148
+ title: `Start Tuwunel container (${flags.engine})`,
149
+ task: () => {
150
+ ctx.svc = new TuwunelService({
151
+ name: 'zooid-tuwunel',
152
+ hostPort: port,
153
+ paths,
154
+ engine: flags.engine,
155
+ })
156
+ const child = ctx.svc.start()
157
+ ctx.tuwunelCaptureDone = captureChildToFile(child, logPaths.tuwunelLog)
158
+ },
159
+ },
160
+ {
161
+ title: 'Wait for Tuwunel /_matrix/client/versions',
162
+ task: async () => {
163
+ if (!ctx.svc) throw new Error('service not started')
164
+ await ctx.svc.waitHealthy({ url: homeserver, timeoutMs: 60_000 })
165
+ },
166
+ },
167
+ {
168
+ title: `Register admin user @${flags.adminUser}:${shape.serverName}`,
169
+ task: async (_, t) => {
170
+ const r = await ensureAdminUser({
171
+ homeserver,
172
+ username: flags.adminUser,
173
+ password: flags.adminPassword,
174
+ })
175
+ t.title = r.created
176
+ ? `Registered admin: ${r.userId}`
177
+ : `Admin already exists: ${r.userId}`
178
+ },
179
+ },
180
+ {
181
+ title: 'Start daemon',
182
+ task: async () => {
183
+ const captures: Record<string, AgentCapture> = {}
184
+ for (const name of Object.keys(preview.agents)) {
185
+ captures[name] = wireAgentCapture({
186
+ agent: name,
187
+ paths: logPaths,
188
+ verbosity: 'default',
189
+ // Matrix cross-link is a follow-up — wire to transport-matrix's
190
+ // most-recent (room_id, event_id) per session in a later cycle.
191
+ matrixContext: () => null,
192
+ })
193
+ }
194
+ ctx.captures = captures
195
+ ctx.daemon = await startDaemon({
196
+ configPath: found.path,
197
+ cwd,
198
+ installSignalHandlers: false,
199
+ adminUserId: `@${flags.adminUser}:${shape.serverName}`,
200
+ agentsDir: layout.agentsDir,
201
+ onTap: (agentName, event) => captures[agentName]?.onTap(event),
202
+ })
203
+ },
204
+ },
205
+ ...(flags.watchWeb
206
+ ? [
207
+ {
208
+ title: 'Start @zoon/web watcher (vite build --watch)',
209
+ task: async (): Promise<void> => {
210
+ const pkgDir = webSourcePackage(CLI_ROOT)
211
+ if (!pkgDir) {
212
+ throw new Error(
213
+ '--watch-web requires running from the monorepo source (no zoon/packages/web found).',
214
+ )
215
+ }
216
+ ctx.webWatch = await startWebWatch({ webPackageDir: pkgDir })
217
+ },
218
+ },
219
+ ]
220
+ : []),
221
+ {
222
+ title: `Serve @zoon/web on http://localhost:${flags.uiPort}`,
223
+ task: () => {
224
+ const webRoot = ctx.webWatch?.distPath ?? resolveWebRoot(CLI_ROOT)
225
+ const app = webStatic({ webRoot, homeserverUrl: homeserver })
226
+ ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
227
+ },
228
+ },
229
+ ],
230
+ { concurrent: false, exitOnError: true },
231
+ )
232
+
233
+ await tasks.run()
234
+
235
+ // Listr's interactive renderer is done — start streaming the watcher's
236
+ // output to our terminal so vite rebuild logs are visible.
237
+ ctx.webWatch?.attachStdio()
238
+
239
+ const shutdown = buildShutdown({
240
+ stopUi: async () => {
241
+ const s = ctx.uiServer
242
+ if (!s) return
243
+ await new Promise<void>((r) => s.close(() => r()))
244
+ },
245
+ stopDaemon: async () => {
246
+ await ctx.daemon?.stop()
247
+ },
248
+ stopTuwunel: async () => {
249
+ await ctx.svc?.stop()
250
+ },
251
+ stopWebWatch: async () => {
252
+ await ctx.webWatch?.stop()
253
+ },
254
+ stopCaptures: async () => {
255
+ if (!ctx.captures) return
256
+ await Promise.all(Object.values(ctx.captures).map((c) => c.close()))
257
+ },
258
+ finalizeTuwunelCapture: async () => {
259
+ if (ctx.tuwunelCaptureDone) await ctx.tuwunelCaptureDone
260
+ },
261
+ })
262
+
263
+ if (flags.installSignalHandlers !== false) {
264
+ const handler = async (): Promise<void> => {
265
+ process.stdout.write(chalk.dim('\nStopping…\n'))
266
+ await shutdown()
267
+ process.exit(0)
268
+ }
269
+ process.on('SIGINT', () => void handler())
270
+ process.on('SIGTERM', () => void handler())
271
+ }
272
+
273
+ process.stdout.write(
274
+ [
275
+ '',
276
+ chalk.bold('Tuwunel is up.') + ` ${homeserver}`,
277
+ chalk.bold('UI: ') + ` http://localhost:${flags.uiPort}`,
278
+ ` ${chalk.cyan('admin user:')} ${flags.adminUser} / ${flags.adminPassword}`,
279
+ ` ${chalk.cyan('data dir:')} ${layout.dataRoot}`,
280
+ ...(ctx.webWatch
281
+ ? [` ${chalk.cyan('web watcher:')} live (vite build --watch on @zoon/web)`]
282
+ : []),
283
+ '',
284
+ chalk.dim('Press Ctrl-C to stop.'),
285
+ '',
286
+ ].join('\n'),
287
+ )
288
+
289
+ if (flags.foreground !== false) {
290
+ await ctx.daemon!.whenStopped
291
+ }
292
+
293
+ return { stop: shutdown }
294
+ }
295
+
296
+ function loadEnvFiles(cwd: string): void {
297
+ // .env.local takes precedence over .env, but neither overrides shell-exported
298
+ // vars — process.loadEnvFile() refuses to overwrite already-set keys.
299
+ for (const name of ['.env.local', '.env']) {
300
+ const path = resolve(cwd, name)
301
+ if (!existsSync(path)) continue
302
+ try {
303
+ process.loadEnvFile(path)
304
+ } catch (err) {
305
+ console.warn(`[zooid dev] failed to load ${name}: ${(err as Error).message}`)
306
+ }
307
+ }
308
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ generateZooidYaml,
4
+ generateAgentsMd,
5
+ generateClaudeSettings,
6
+ generateOpencodeJson,
7
+ generateEnv,
8
+ generateGitignore,
9
+ } from './generators.js'
10
+
11
+ describe('generateZooidYaml', () => {
12
+ it('produces the short post-ZOD048 shape for claude + model', () => {
13
+ const out = generateZooidYaml({
14
+ preset: 'claude',
15
+ model: 'claude-sonnet-4-6',
16
+ })
17
+ expect(out).toContain('runtime: local')
18
+ expect(out).toContain('homeserver: http://localhost:8448')
19
+ expect(out).toContain('port: 9099')
20
+ expect(out).toContain('zooid-assistant:')
21
+ expect(out).toContain('preset: claude')
22
+ expect(out).toContain('model: claude-sonnet-4-6')
23
+ expect(out).toContain("display_name: 'Zooid Assistant'")
24
+ expect(out).toContain("rooms: ['#zooid']")
25
+ expect(out).not.toContain('user_id:')
26
+ expect(out).not.toContain('workdir:')
27
+ expect(out).not.toContain('transport: matrix')
28
+ })
29
+
30
+ it('omits model on the acp block for opencode (model lives in opencode.json)', () => {
31
+ const out = generateZooidYaml({ preset: 'opencode' })
32
+ expect(out).toContain('acp: { preset: opencode }')
33
+ expect(out).not.toContain('model:')
34
+ })
35
+ })
36
+
37
+ describe('generateClaudeMd', () => {
38
+ it('imports AGENTS.md via the @<path> directive Claude Code auto-loads', async () => {
39
+ const { generateClaudeMd } = await import('./generators.js')
40
+ expect(generateClaudeMd().trim()).toBe('@AGENTS.md')
41
+ })
42
+ })
43
+
44
+ describe('generateAgentsMd', () => {
45
+ it('names the assistant and points at the docs URL', () => {
46
+ const out = generateAgentsMd()
47
+ expect(out).toContain('zooid-assistant')
48
+ expect(out).toContain('https://zooid.dev/docs/')
49
+ expect(out).toContain('web fetch')
50
+ })
51
+ })
52
+
53
+ describe('generateClaudeSettings', () => {
54
+ it('emits a settings.json that allowlists WebFetch(domain:zooid.dev)', () => {
55
+ const out = generateClaudeSettings()
56
+ const parsed = JSON.parse(out)
57
+ expect(parsed.permissions.allow).toContain('WebFetch(domain:zooid.dev)')
58
+ })
59
+ })
60
+
61
+ describe('generateOpencodeJson', () => {
62
+ it('produces opencode-go/<model> with the OPENCODE_API_KEY env reference', () => {
63
+ const out = generateOpencodeJson({
64
+ provider: 'opencode-go',
65
+ model: 'kimi-k2.6',
66
+ apiKeyEnvVar: 'OPENCODE_API_KEY',
67
+ })
68
+ const parsed = JSON.parse(out)
69
+ expect(parsed.model).toBe('opencode-go/kimi-k2.6')
70
+ expect(parsed.provider['opencode-go'].options.apiKey).toBe('{env:OPENCODE_API_KEY}')
71
+ expect(parsed.permission.webfetch).toBe('allow')
72
+ })
73
+
74
+ it('produces a TODO stub for the custom provider', () => {
75
+ const out = generateOpencodeJson({ provider: 'custom' })
76
+ const parsed = JSON.parse(out)
77
+ expect(parsed.model).toMatch(/TODO/)
78
+ expect(parsed.provider).toHaveProperty('TODO')
79
+ })
80
+ })
81
+
82
+ describe('generateEnv', () => {
83
+ it('emits a single line with the right env var name', () => {
84
+ expect(generateEnv({ envVar: 'ANTHROPIC_API_KEY', value: 'sk-ant-x' })).toBe(
85
+ 'ANTHROPIC_API_KEY=sk-ant-x\n',
86
+ )
87
+ })
88
+ })
89
+
90
+ describe('generateGitignore', () => {
91
+ it('ignores .env, node_modules, and data/', () => {
92
+ const out = generateGitignore()
93
+ expect(out).toContain('.env')
94
+ expect(out).toContain('node_modules/')
95
+ expect(out).toContain('data/')
96
+ })
97
+ })
@@ -0,0 +1,119 @@
1
+ export interface ZooidYamlOpts {
2
+ preset: 'claude' | 'codex' | 'opencode'
3
+ /** Only set for claude / codex; opencode reads its own opencode.json. */
4
+ model?: string
5
+ }
6
+
7
+ export function generateZooidYaml(opts: ZooidYamlOpts): string {
8
+ const acpBlock =
9
+ opts.preset === 'opencode'
10
+ ? ` acp: { preset: opencode }`
11
+ : ` acp:\n preset: ${opts.preset}\n model: ${opts.model}`
12
+ return `runtime: local
13
+
14
+ transports:
15
+ matrix:
16
+ homeserver: http://localhost:8448
17
+ port: 9099
18
+
19
+ agents:
20
+ zooid-assistant:
21
+ ${acpBlock}
22
+ matrix:
23
+ display_name: 'Zooid Assistant'
24
+ rooms: ['#zooid']
25
+ `
26
+ }
27
+
28
+ export function generateAgentsMd(): string {
29
+ return `# zooid-assistant
30
+
31
+ You are the zooid setup assistant. Your job is to help the operator get a workforce running, add agents, and configure zooid for their use case.
32
+
33
+ ## Resources
34
+
35
+ - Zooid documentation: https://zooid.dev/docs/
36
+ - Use your web fetch tool to read documentation pages when answering questions.
37
+
38
+ ## Behavior
39
+
40
+ - Be terse. Give the answer; link to the doc page; stop.
41
+ - When the operator asks how to do something, prefer to fetch the relevant docs section over guessing from training data.
42
+ - When the operator asks you to *configure* something, propose a yaml edit and let them apply it. Don't write files directly unless they ask.
43
+ `
44
+ }
45
+
46
+ /**
47
+ * Claude Code auto-loads `CLAUDE.md`, not `AGENTS.md`. We keep `AGENTS.md`
48
+ * as the canonical content (codex / opencode read it directly) and ship a
49
+ * one-line `CLAUDE.md` that pulls it in via Claude Code's `@<path>` import.
50
+ */
51
+ export function generateClaudeMd(): string {
52
+ return `@AGENTS.md\n`
53
+ }
54
+
55
+ export function generateClaudeSettings(): string {
56
+ return (
57
+ JSON.stringify(
58
+ {
59
+ permissions: {
60
+ allow: ['WebFetch(domain:zooid.dev)'],
61
+ },
62
+ },
63
+ null,
64
+ 2,
65
+ ) + '\n'
66
+ )
67
+ }
68
+
69
+ export interface OpencodeJsonOpts {
70
+ provider: string
71
+ model?: string
72
+ apiKeyEnvVar?: string
73
+ }
74
+
75
+ export function generateOpencodeJson(opts: OpencodeJsonOpts): string {
76
+ if (opts.provider === 'custom') {
77
+ return (
78
+ JSON.stringify(
79
+ {
80
+ $schema: 'https://opencode.ai/config.json',
81
+ model: 'TODO/your-model',
82
+ provider: { TODO: { options: {} } },
83
+ },
84
+ null,
85
+ 2,
86
+ ) + '\n'
87
+ )
88
+ }
89
+ const cfg = {
90
+ $schema: 'https://opencode.ai/config.json',
91
+ model: `${opts.provider}/${opts.model}`,
92
+ provider: {
93
+ [opts.provider]: {
94
+ options: { apiKey: `{env:${opts.apiKeyEnvVar}}` },
95
+ },
96
+ },
97
+ permission: { webfetch: 'allow' as const },
98
+ }
99
+ return JSON.stringify(cfg, null, 2) + '\n'
100
+ }
101
+
102
+ export interface EnvOpts {
103
+ envVar: string
104
+ value: string
105
+ }
106
+
107
+ export function generateEnv(opts: EnvOpts): string {
108
+ return `${opts.envVar}=${opts.value}\n`
109
+ }
110
+
111
+ export function generateGitignore(): string {
112
+ return ['.env', 'node_modules/', 'data/', ''].join('\n')
113
+ }
114
+
115
+ export function generateOpencodeReadme(): string {
116
+ return `See https://opencode.ai/config for the full opencode.json schema, including
117
+ provider-specific options and per-tool permissions.
118
+ `
119
+ }
@@ -0,0 +1,123 @@
1
+ import { select, password } from '@inquirer/prompts'
2
+ import {
3
+ OPENCODE_PROVIDERS,
4
+ findOpencodeProvider,
5
+ findSimplePreset,
6
+ } from './registry.js'
7
+ import type { InitOptions } from '../init.js'
8
+
9
+ export interface PromptInput {
10
+ dir: string
11
+ preset?: string
12
+ auth?: string
13
+ model?: string
14
+ provider?: string
15
+ apiKey?: string
16
+ force?: boolean
17
+ overwrite?: boolean
18
+ interactive?: boolean
19
+ }
20
+
21
+ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
22
+ const interactive = flags.interactive !== false && Boolean(process.stdin.isTTY)
23
+ const ask = <T>(prompt: () => Promise<T>, fallback: () => string): Promise<T> => {
24
+ if (!interactive) return Promise.reject(new Error(fallback()))
25
+ return prompt()
26
+ }
27
+
28
+ const preset = (flags.preset as InitOptions['preset']) ?? (await ask(
29
+ () => select({
30
+ message: 'Which agent should zooid-assistant use?',
31
+ choices: [
32
+ { name: 'claude (Claude Code)', value: 'claude' as const },
33
+ { name: 'codex (OpenAI Codex)', value: 'codex' as const },
34
+ { name: 'opencode', value: 'opencode' as const },
35
+ ],
36
+ }),
37
+ () => '--preset is required (claude | codex | opencode)',
38
+ ))
39
+
40
+ if (preset === 'opencode') {
41
+ const provider = flags.provider ?? (await ask(
42
+ () => select({
43
+ message: 'Which provider?',
44
+ choices: [
45
+ ...OPENCODE_PROVIDERS.map((p) => ({
46
+ name: `${p.label} (${p.description})`,
47
+ value: p.id,
48
+ })),
49
+ { name: 'custom (write your own opencode.json block)', value: 'custom' },
50
+ ],
51
+ }),
52
+ () => '--provider is required for opencode',
53
+ ))
54
+ if (provider === 'custom') {
55
+ return {
56
+ dir: flags.dir,
57
+ preset,
58
+ provider,
59
+ force: flags.force,
60
+ overwrite: flags.overwrite,
61
+ }
62
+ }
63
+ const meta = findOpencodeProvider(provider)
64
+ if (!meta) throw new Error(`unknown opencode provider: ${provider}`)
65
+ const model = flags.model ?? (await ask(
66
+ () => select({
67
+ message: 'Which model?',
68
+ choices: meta.models.map((m) => ({ name: m, value: m })),
69
+ }),
70
+ () => '--model is required',
71
+ ))
72
+ const apiKey = flags.apiKey ?? (await ask(
73
+ () => password({ message: `${meta.label} API key:` }),
74
+ () => '--api-key is required',
75
+ ))
76
+ return {
77
+ dir: flags.dir,
78
+ preset,
79
+ provider,
80
+ model,
81
+ apiKey,
82
+ force: flags.force,
83
+ overwrite: flags.overwrite,
84
+ }
85
+ }
86
+
87
+ const meta = findSimplePreset(preset)
88
+ if (!meta) throw new Error(`unknown preset: ${preset}`)
89
+ const auth = (flags.auth as 'subscription' | 'api-key') ?? (await ask(
90
+ () => select({
91
+ message: 'How do you authenticate?',
92
+ choices: [
93
+ { name: `My ${meta.subscriptionLabel}`, value: 'subscription' as const },
94
+ { name: `I'll provide an API key`, value: 'api-key' as const },
95
+ ],
96
+ }),
97
+ () => '--auth is required (subscription | api-key)',
98
+ ))
99
+ const model = flags.model ?? (await ask(
100
+ () => select({
101
+ message: 'Which model?',
102
+ choices: meta.models.map((m) => ({ name: m, value: m })),
103
+ }),
104
+ () => '--model is required',
105
+ ))
106
+ const apiKey =
107
+ auth === 'api-key'
108
+ ? flags.apiKey ?? (await ask(
109
+ () => password({ message: `${meta.apiKeyPromptLabel} API key:` }),
110
+ () => '--api-key is required when --auth=api-key',
111
+ ))
112
+ : undefined
113
+
114
+ return {
115
+ dir: flags.dir,
116
+ preset,
117
+ auth,
118
+ model,
119
+ apiKey,
120
+ force: flags.force,
121
+ overwrite: flags.overwrite,
122
+ }
123
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ SIMPLE_PRESETS,
4
+ OPENCODE_PROVIDERS,
5
+ findSimplePreset,
6
+ findOpencodeProvider,
7
+ } from './registry.js'
8
+
9
+ describe('init registry', () => {
10
+ it('SIMPLE_PRESETS contains claude and codex with non-empty model lists', () => {
11
+ const names = SIMPLE_PRESETS.map((p) => p.preset)
12
+ expect(names).toEqual(['claude', 'codex'])
13
+ for (const p of SIMPLE_PRESETS) {
14
+ expect(p.models.length).toBeGreaterThan(0)
15
+ expect(p.apiKeyEnvVar).toMatch(/^[A-Z_]+_API_KEY$/)
16
+ expect(p.credentialDir.length).toBeGreaterThan(0)
17
+ }
18
+ })
19
+
20
+ it('OPENCODE_PROVIDERS lists opencode-go first as the recommended default', () => {
21
+ expect(OPENCODE_PROVIDERS[0]?.id).toBe('opencode-go')
22
+ })
23
+
24
+ it('findSimplePreset returns the entry by name', () => {
25
+ expect(findSimplePreset('claude')?.preset).toBe('claude')
26
+ expect(findSimplePreset('nope')).toBeUndefined()
27
+ })
28
+
29
+ it('findOpencodeProvider returns the entry by id', () => {
30
+ expect(findOpencodeProvider('opencode-go')?.id).toBe('opencode-go')
31
+ expect(findOpencodeProvider('nope')).toBeUndefined()
32
+ })
33
+ })