zooid 0.6.1 → 0.7.1
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.
- package/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1891 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-SUPTPSN3.js +31434 -0
- package/dist/chunk-SUPTPSN3.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +4 -3790
- package/dist/index.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-C2MAIew3.css +1 -0
- package/dist/web/assets/index-CqfWXqvO.js +118066 -0
- package/dist/web/assets/index-Cz5fEZBc.js +295 -0
- package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +43 -22
- package/src/bin.ts +115 -0
- package/src/bootstrap/admin.test.ts +69 -0
- package/src/bootstrap/admin.ts +37 -0
- package/src/bootstrap/configs.test.ts +24 -0
- package/src/bootstrap/configs.ts +64 -0
- package/src/bootstrap/data-layout.test.ts +25 -0
- package/src/bootstrap/data-layout.ts +20 -0
- package/src/bootstrap/derive.test.ts +74 -0
- package/src/bootstrap/derive.ts +40 -0
- package/src/bootstrap/paths.test.ts +22 -0
- package/src/bootstrap/paths.ts +27 -0
- package/src/bootstrap/tokens.test.ts +44 -0
- package/src/bootstrap/tokens.ts +43 -0
- package/src/build-registry.context.test.ts +61 -0
- package/src/build-registry.test.ts +59 -0
- package/src/build-registry.ts +343 -0
- package/src/build-registry.zod043.test.ts +64 -0
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev-cascade.test.ts +38 -0
- package/src/commands/dev-cascade.ts +35 -0
- package/src/commands/dev-data-layout.test.ts +55 -0
- package/src/commands/dev.ts +316 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +124 -0
- package/src/commands/init/prompts.ts +123 -0
- package/src/commands/init/registry.test.ts +33 -0
- package/src/commands/init/registry.ts +75 -0
- package/src/commands/init/sniff.test.ts +41 -0
- package/src/commands/init/sniff.ts +30 -0
- package/src/commands/init.test.ts +131 -0
- package/src/commands/init.ts +158 -0
- package/src/commands/logs.test.ts +105 -0
- package/src/commands/logs.ts +108 -0
- package/src/commands/start.ts +28 -0
- package/src/commands/status.test.ts +73 -0
- package/src/commands/status.ts +105 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +270 -0
- package/src/index.ts +2 -0
- package/src/observability/capture-agent.ts +58 -0
- package/src/observability/capture-tuwunel.test.ts +57 -0
- package/src/observability/capture-tuwunel.ts +24 -0
- package/src/observability/file-sink.test.ts +68 -0
- package/src/observability/file-sink.ts +78 -0
- package/src/observability/observability.integration.test.ts +162 -0
- package/src/observability/paths.test.ts +104 -0
- package/src/observability/paths.ts +94 -0
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/src/services/tuwunel.test.ts +64 -0
- package/src/services/tuwunel.ts +91 -0
- package/src/web/resolve.test.ts +50 -0
- package/src/web/resolve.ts +31 -0
- package/src/web/static.test.ts +51 -0
- package/src/web/static.ts +61 -0
- package/src/web/watch.ts +103 -0
- package/dist/chunk-67ZRMVHO.js +0 -174
- package/dist/chunk-AR456MHY.js +0 -29
- package/dist/chunk-VBGU2NST.js +0 -139
- package/dist/client-4VMFEFDX.js +0 -22
- package/dist/config-2KK5GX42.js +0 -27
- package/dist/template-T5IB4YWC.js +0 -92
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
// Run `vite build --watch` against a @zoon/web package and serve its dist.
|
|
70
|
+
// true = auto-detect (sibling ../zoon/packages/web or in-monorepo); string = explicit path.
|
|
71
|
+
watchWeb?: string | 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, { configDir: dirname(found.path) })
|
|
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 (_ctx, t) => {
|
|
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
|
+
prepullLog: (line) => {
|
|
203
|
+
t.output = line.replace(/^\[zooid\]\s+/, '').trim()
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
...(flags.watchWeb
|
|
209
|
+
? [
|
|
210
|
+
{
|
|
211
|
+
title: 'Start @zoon/web watcher (vite build --watch)',
|
|
212
|
+
task: async (): Promise<void> => {
|
|
213
|
+
const pkgDir =
|
|
214
|
+
typeof flags.watchWeb === 'string'
|
|
215
|
+
? resolve(flags.watchWeb)
|
|
216
|
+
: webSourcePackage(CLI_ROOT)
|
|
217
|
+
if (!pkgDir) {
|
|
218
|
+
const defaultPath = dirname(dirname(dirname(CLI_ROOT))) + '/zoon/packages/web'
|
|
219
|
+
throw new Error(
|
|
220
|
+
`--watch-web: @zoon/web not found at ${defaultPath}.\n` +
|
|
221
|
+
`Pass an explicit path: --watch-web=/path/to/zoon/packages/web`,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
ctx.webWatch = await startWebWatch({ webPackageDir: pkgDir })
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
]
|
|
228
|
+
: []),
|
|
229
|
+
{
|
|
230
|
+
title: `Serve @zoon/web on http://localhost:${flags.uiPort}`,
|
|
231
|
+
task: () => {
|
|
232
|
+
const webRoot = ctx.webWatch?.distPath ?? resolveWebRoot(CLI_ROOT)
|
|
233
|
+
const app = webStatic({ webRoot, homeserverUrl: homeserver })
|
|
234
|
+
ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
{ concurrent: false, exitOnError: true },
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
await tasks.run()
|
|
242
|
+
|
|
243
|
+
// Listr's interactive renderer is done — start streaming the watcher's
|
|
244
|
+
// output to our terminal so vite rebuild logs are visible.
|
|
245
|
+
ctx.webWatch?.attachStdio()
|
|
246
|
+
|
|
247
|
+
const shutdown = buildShutdown({
|
|
248
|
+
stopUi: async () => {
|
|
249
|
+
const s = ctx.uiServer
|
|
250
|
+
if (!s) return
|
|
251
|
+
await new Promise<void>((r) => s.close(() => r()))
|
|
252
|
+
},
|
|
253
|
+
stopDaemon: async () => {
|
|
254
|
+
await ctx.daemon?.stop()
|
|
255
|
+
},
|
|
256
|
+
stopTuwunel: async () => {
|
|
257
|
+
await ctx.svc?.stop()
|
|
258
|
+
},
|
|
259
|
+
stopWebWatch: async () => {
|
|
260
|
+
await ctx.webWatch?.stop()
|
|
261
|
+
},
|
|
262
|
+
stopCaptures: async () => {
|
|
263
|
+
if (!ctx.captures) return
|
|
264
|
+
await Promise.all(Object.values(ctx.captures).map((c) => c.close()))
|
|
265
|
+
},
|
|
266
|
+
finalizeTuwunelCapture: async () => {
|
|
267
|
+
if (ctx.tuwunelCaptureDone) await ctx.tuwunelCaptureDone
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
if (flags.installSignalHandlers !== false) {
|
|
272
|
+
const handler = async (): Promise<void> => {
|
|
273
|
+
process.stdout.write(chalk.dim('\nStopping…\n'))
|
|
274
|
+
await shutdown()
|
|
275
|
+
process.exit(0)
|
|
276
|
+
}
|
|
277
|
+
process.on('SIGINT', () => void handler())
|
|
278
|
+
process.on('SIGTERM', () => void handler())
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
process.stdout.write(
|
|
282
|
+
[
|
|
283
|
+
'',
|
|
284
|
+
chalk.bold('Tuwunel is up.') + ` ${homeserver}`,
|
|
285
|
+
chalk.bold('UI: ') + ` http://localhost:${flags.uiPort}`,
|
|
286
|
+
` ${chalk.cyan('admin user:')} ${flags.adminUser} / ${flags.adminPassword}`,
|
|
287
|
+
` ${chalk.cyan('data dir:')} ${layout.dataRoot}`,
|
|
288
|
+
...(ctx.webWatch
|
|
289
|
+
? [` ${chalk.cyan('web watcher:')} live (vite build --watch on @zoon/web)`]
|
|
290
|
+
: []),
|
|
291
|
+
'',
|
|
292
|
+
chalk.dim('Press Ctrl-C to stop.'),
|
|
293
|
+
'',
|
|
294
|
+
].join('\n'),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if (flags.foreground !== false) {
|
|
298
|
+
await ctx.daemon!.whenStopped
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { stop: shutdown }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function loadEnvFiles(cwd: string): void {
|
|
305
|
+
// .env.local takes precedence over .env, but neither overrides shell-exported
|
|
306
|
+
// vars — process.loadEnvFile() refuses to overwrite already-set keys.
|
|
307
|
+
for (const name of ['.env.local', '.env']) {
|
|
308
|
+
const path = resolve(cwd, name)
|
|
309
|
+
if (!existsSync(path)) continue
|
|
310
|
+
try {
|
|
311
|
+
process.loadEnvFile(path)
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.warn(`[zooid dev] failed to load ${name}: ${(err as Error).message}`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -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,124 @@
|
|
|
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
|
+
## Stack facts
|
|
39
|
+
|
|
40
|
+
- **Tuwunel** is a lightweight Rust Matrix homeserver (Conduit fork) backed by RocksDB. Single binary, low resource floor — not "heavy" for typical workforces.
|
|
41
|
+
- **Logs land on disk** in \`./data/\` inside this zooid home dir: tuwunel logs, the daemon, and each agent's ACP stream. Use \`zooid logs <source>\` to tail them (sources: \`tuwunel\`, \`daemon\`, \`dev\`, \`agent-<name>\`, \`agent-<name>.acp\`). Debugging is mostly "read the log file" — don't tell the operator it's opaque.
|
|
42
|
+
|
|
43
|
+
## Behavior
|
|
44
|
+
|
|
45
|
+
- Be terse. Give the answer; link to the doc page; stop.
|
|
46
|
+
- When the operator asks how to do something, prefer to fetch the relevant docs section over guessing from training data.
|
|
47
|
+
- 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.
|
|
48
|
+
`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Claude Code auto-loads `CLAUDE.md`, not `AGENTS.md`. We keep `AGENTS.md`
|
|
53
|
+
* as the canonical content (codex / opencode read it directly) and ship a
|
|
54
|
+
* one-line `CLAUDE.md` that pulls it in via Claude Code's `@<path>` import.
|
|
55
|
+
*/
|
|
56
|
+
export function generateClaudeMd(): string {
|
|
57
|
+
return `@AGENTS.md\n`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function generateClaudeSettings(): string {
|
|
61
|
+
return (
|
|
62
|
+
JSON.stringify(
|
|
63
|
+
{
|
|
64
|
+
permissions: {
|
|
65
|
+
allow: ['WebFetch(domain:zooid.dev)'],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
null,
|
|
69
|
+
2,
|
|
70
|
+
) + '\n'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface OpencodeJsonOpts {
|
|
75
|
+
provider: string
|
|
76
|
+
model?: string
|
|
77
|
+
apiKeyEnvVar?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function generateOpencodeJson(opts: OpencodeJsonOpts): string {
|
|
81
|
+
if (opts.provider === 'custom') {
|
|
82
|
+
return (
|
|
83
|
+
JSON.stringify(
|
|
84
|
+
{
|
|
85
|
+
$schema: 'https://opencode.ai/config.json',
|
|
86
|
+
model: 'TODO/your-model',
|
|
87
|
+
provider: { TODO: { options: {} } },
|
|
88
|
+
},
|
|
89
|
+
null,
|
|
90
|
+
2,
|
|
91
|
+
) + '\n'
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
const cfg = {
|
|
95
|
+
$schema: 'https://opencode.ai/config.json',
|
|
96
|
+
model: `${opts.provider}/${opts.model}`,
|
|
97
|
+
provider: {
|
|
98
|
+
[opts.provider]: {
|
|
99
|
+
options: { apiKey: `{env:${opts.apiKeyEnvVar}}` },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
permission: { webfetch: 'allow' as const },
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify(cfg, null, 2) + '\n'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface EnvOpts {
|
|
108
|
+
envVar: string
|
|
109
|
+
value: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function generateEnv(opts: EnvOpts): string {
|
|
113
|
+
return `${opts.envVar}=${opts.value}\n`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function generateGitignore(): string {
|
|
117
|
+
return ['.env', 'node_modules/', 'data/', ''].join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function generateOpencodeReadme(): string {
|
|
121
|
+
return `See https://opencode.ai/config for the full opencode.json schema, including
|
|
122
|
+
provider-specific options and per-tool permissions.
|
|
123
|
+
`
|
|
124
|
+
}
|
|
@@ -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
|
+
})
|