zooid 0.8.0 → 0.9.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.
Files changed (37) hide show
  1. package/README.md +5 -5
  2. package/dist/bin.js +195 -156
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-R6EDLH23.js → chunk-PDSJJWQW.js} +1928 -990
  5. package/dist/chunk-PDSJJWQW.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/package.json +10 -10
  8. package/src/bin.ts +1 -1
  9. package/src/bootstrap/configs.ts +19 -6
  10. package/src/bootstrap/derive.ts +3 -2
  11. package/src/bootstrap/identity-bootstrap.integration.test.ts +53 -0
  12. package/src/bootstrap/registration-url.test.ts +14 -0
  13. package/src/bootstrap/registration-url.ts +7 -0
  14. package/src/commands/dev.ts +9 -9
  15. package/src/commands/init/generators.test.ts +30 -12
  16. package/src/commands/init/generators.ts +23 -12
  17. package/src/commands/init/prompts.ts +9 -34
  18. package/src/commands/init/registry.test.ts +2 -2
  19. package/src/commands/init/registry.ts +9 -8
  20. package/src/commands/init/scaffold-roundtrip.test.ts +38 -0
  21. package/src/commands/init.test.ts +17 -12
  22. package/src/commands/init.ts +2 -4
  23. package/src/daemon/pull-wiring.test.ts +14 -0
  24. package/src/daemon/pull-wiring.ts +4 -0
  25. package/src/daemon/start-daemon-pull.integration.test.ts +95 -0
  26. package/src/daemon/start-daemon.ts +62 -15
  27. package/src/daemon/sync-cursors.test.ts +38 -0
  28. package/src/daemon/sync-cursors.ts +31 -0
  29. package/src/web/fetch.integration.test.ts +4 -4
  30. package/src/web/fetch.test.ts +4 -4
  31. package/src/web/fetch.ts +2 -2
  32. package/src/web/pin.test.ts +2 -2
  33. package/src/web/pin.ts +2 -2
  34. package/src/web/resolve.test.ts +5 -5
  35. package/src/web/resolve.ts +5 -5
  36. package/src/web/test-helpers.ts +1 -1
  37. package/dist/chunk-R6EDLH23.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-R6EDLH23.js";
4
+ } from "./chunk-PDSJJWQW.js";
5
5
  export {
6
6
  buildAcpRegistry
7
7
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.8.0",
4
- "description": "Open-source tool for defining and running AI agents alongside you and your team. Any model, any CLI.",
3
+ "version": "0.9.1",
4
+ "description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "zooid": {
28
- "zoonWebVersion": "0.1.0"
28
+ "webVersion": "0.8.0"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=22"
@@ -41,13 +41,13 @@
41
41
  "sanitize-html": "^2.17.4",
42
42
  "tar": "^7.5.16",
43
43
  "yaml": "^2.5.0",
44
- "@zooid/context-mcp": "^0.8.0",
45
- "@zooid/core": "^0.8.0",
46
- "@zooid/acp-client": "^0.8.0",
47
- "@zooid/transport-http": "^0.8.0",
48
- "@zooid/transport-matrix": "^0.8.0",
49
- "@zooid/runtime-docker": "^0.8.0",
50
- "@zooid/runtime-local": "^0.8.0"
44
+ "@zooid/acp-client": "^0.9.1",
45
+ "@zooid/context-mcp": "^0.9.1",
46
+ "@zooid/core": "^0.9.1",
47
+ "@zooid/runtime-docker": "^0.9.1",
48
+ "@zooid/runtime-local": "^0.9.1",
49
+ "@zooid/transport-http": "^0.9.1",
50
+ "@zooid/transport-matrix": "^0.9.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@agentclientprotocol/sdk": "^0.21.0",
package/src/bin.ts CHANGED
@@ -33,7 +33,7 @@ cli
33
33
  .option('--admin-password <pw>', 'Admin password', { default: 'admin' })
34
34
  .option(
35
35
  '--watch-web [path]',
36
- 'Run vite build --watch on @zooid/zoon-web. Path defaults to sibling ../zoon/packages/web.',
36
+ 'Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web.',
37
37
  )
38
38
  .action(async (flags) => {
39
39
  await runDev({
@@ -1,5 +1,7 @@
1
+ import { join } from 'node:path'
1
2
  import { mkdirSync, writeFileSync } from 'node:fs'
2
3
  import { renderRegistration } from '@zooid/transport-matrix'
4
+ import { deriveRegistrationUrl } from './registration-url.js'
3
5
  import type { Paths } from './paths.js'
4
6
 
5
7
  export interface TuwunelTomlOpts {
@@ -35,6 +37,12 @@ export interface BootstrapConfigsOpts {
35
37
  hsToken: string
36
38
  senderLocalpart: string
37
39
  userNamespace: string
40
+ /** Workstation id. When set: registration id = workstation, exclusive namespace, url derived from port/advertise_url. */
41
+ workstation?: string
42
+ /** AS HTTP listener port. Used to derive the registration url when workstation is set. */
43
+ port?: number
44
+ /** Explicit registration url override. Mutually exclusive with port. */
45
+ advertiseUrl?: string
38
46
  }
39
47
 
40
48
  export function writeBootstrapConfigs(opts: BootstrapConfigsOpts): void {
@@ -45,9 +53,16 @@ export function writeBootstrapConfigs(opts: BootstrapConfigsOpts): void {
45
53
 
46
54
  writeFileSync(paths.tuwunelTomlPath, renderTuwunelToml({ serverName }))
47
55
 
56
+ const id = opts.workstation ?? 'zooid'
57
+ const url = opts.workstation
58
+ ? deriveRegistrationUrl({ port: opts.port ?? 9099, advertise_url: opts.advertiseUrl })
59
+ : `http://host.docker.internal:9099`
60
+ const exclusive = !!opts.workstation
61
+ const registrationPath = join(paths.registrationsDir, `${id}.yaml`)
62
+
48
63
  const yaml = renderRegistration({
49
- id: 'zooid',
50
- url: `http://host.docker.internal:9099`,
64
+ id,
65
+ url,
51
66
  homeserver: `http://localhost:${TUWUNEL_INTERNAL_PORT}`,
52
67
  asToken,
53
68
  hsToken,
@@ -56,9 +71,7 @@ export function writeBootstrapConfigs(opts: BootstrapConfigsOpts): void {
56
71
  // BotPool.bootstrap creates `#alias:<server>` rooms when missing — the
57
72
  // AS needs an aliases namespace to legally claim them.
58
73
  aliasNamespace: `#.*:${serverName}`,
59
- // zooid dev shares @.*:<server_name> between bots and the predefined
60
- // admin human, so the AS cannot claim the namespace exclusively.
61
- exclusive: false,
74
+ exclusive,
62
75
  })
63
- writeFileSync(paths.appserviceYamlPath, yaml)
76
+ writeFileSync(registrationPath, yaml)
64
77
  }
@@ -6,7 +6,8 @@ export interface HomeserverShape {
6
6
  serverName: string
7
7
  }
8
8
 
9
- const NAMESPACE_RE = /^@\.\*:([A-Za-z0-9.-]+)$/
9
+ // Matches @.*:server (flat) or @<workstation>\..*:server (workstation-scoped exclusive namespace)
10
+ const NAMESPACE_RE = /^@(?:\.\*|[a-z0-9-]+\\\.\.\*):([A-Za-z0-9.-]+)$/
10
11
 
11
12
  export function deriveHomeserverShape(
12
13
  matrix: MatrixTransportConfig,
@@ -23,7 +24,7 @@ export function deriveHomeserverShape(
23
24
  const m = NAMESPACE_RE.exec(matrix.user_namespace)
24
25
  if (!m) {
25
26
  throw new Error(
26
- `user_namespace ${matrix.user_namespace!}: expected the simple form '@.*:<server_name>'`,
27
+ `user_namespace ${matrix.user_namespace!}: expected '@.*:<server_name>' or '@<workstation>\\\..*:<server_name>'`,
27
28
  )
28
29
  }
29
30
  const serverName = m[1]!
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { parse } from 'yaml'
6
+ import { loadZooidConfig, findMatrixTransport } from '@zooid/core'
7
+ import { writeBootstrapConfigs } from './configs.js'
8
+ import { resolvePaths } from './paths.js'
9
+
10
+ const YAML = `
11
+ runtime: podman
12
+ workstation: laptop
13
+ transports:
14
+ matrix:
15
+ homeserver: http://localhost:8448
16
+ as_token: as-x
17
+ hs_token: hs-x
18
+ port: 9099
19
+ agents:
20
+ docs: { acp: { preset: opencode }, matrix: { rooms: ['#docs'] } }
21
+ `
22
+
23
+ describe('bootstrap emits a workstation-scoped registration', () => {
24
+ let dir: string
25
+ afterEach(() => dir && rmSync(dir, { recursive: true, force: true }))
26
+
27
+ it('config → registration round-trip is workstation-consistent', () => {
28
+ dir = mkdtempSync(join(tmpdir(), 'zod063-'))
29
+ const dataDir = join(dir, 'data', 'matrix')
30
+ const paths = resolvePaths(dataDir)
31
+ const cfg = loadZooidConfig(YAML)
32
+ const m = findMatrixTransport(cfg)!.transport
33
+
34
+ writeBootstrapConfigs({
35
+ paths,
36
+ serverName: new URL(m.homeserver).hostname,
37
+ asToken: 'as-x',
38
+ hsToken: 'hs-x',
39
+ senderLocalpart: m.sender_localpart,
40
+ userNamespace: m.user_namespace,
41
+ workstation: m.workstation,
42
+ port: m.port,
43
+ })
44
+
45
+ const regPath = join(dataDir, 'config', 'registrations', 'laptop.yaml')
46
+ const reg = parse(readFileSync(regPath, 'utf8'))
47
+ expect(reg.id).toBe('laptop')
48
+ expect(reg.sender_localpart).toBe('laptop')
49
+ expect(reg.namespaces.users[0]).toEqual({ exclusive: true, regex: '@laptop\\..*:localhost' })
50
+ // co-located: url advertises the daemon's bind port back to the homeserver
51
+ expect(reg.url).toBe('http://host.docker.internal:9099')
52
+ })
53
+ })
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { deriveRegistrationUrl } from './registration-url.js'
3
+
4
+ describe('deriveRegistrationUrl', () => {
5
+ it('port → co-located host.docker.internal shorthand', () => {
6
+ expect(deriveRegistrationUrl({ port: 9099 })).toBe('http://host.docker.internal:9099')
7
+ })
8
+ it('advertise_url → verbatim', () => {
9
+ expect(deriveRegistrationUrl({ advertise_url: 'http://10.0.1.5:9099' })).toBe('http://10.0.1.5:9099')
10
+ })
11
+ it('throws if both are present (caller should have rejected earlier, defense-in-depth)', () => {
12
+ expect(() => deriveRegistrationUrl({ port: 9099, advertise_url: 'http://x:1' })).toThrow()
13
+ })
14
+ })
@@ -0,0 +1,7 @@
1
+ export function deriveRegistrationUrl(opts: { port?: number; advertise_url?: string }): string {
2
+ if (opts.port != null && opts.advertise_url != null)
3
+ throw new Error("registration url: 'port' and 'advertise_url' are mutually exclusive")
4
+ if (opts.advertise_url != null) return opts.advertise_url
5
+ if (opts.port != null) return `http://host.docker.internal:${opts.port}`
6
+ throw new Error('registration url: need port or advertise_url')
7
+ }
@@ -67,8 +67,8 @@ export interface DevFlags {
67
67
  adminPassword: string
68
68
  installSignalHandlers?: boolean
69
69
  foreground?: boolean
70
- // Run `vite build --watch` against a @zooid/zoon-web package and serve its dist.
71
- // true = auto-detect (sibling ../zoon/packages/web or in-monorepo); string = explicit path.
70
+ // Run `vite build --watch` against a @zooid/web package and serve its dist.
71
+ // true = auto-detect (sibling ../zooid-clients/packages/web or in-monorepo); string = explicit path.
72
72
  watchWeb?: string | boolean
73
73
  }
74
74
 
@@ -217,17 +217,17 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
217
217
  ...(flags.watchWeb
218
218
  ? [
219
219
  {
220
- title: 'Start @zooid/zoon-web watcher (vite build --watch)',
220
+ title: 'Start @zooid/web watcher (vite build --watch)',
221
221
  task: async (): Promise<void> => {
222
222
  const pkgDir =
223
223
  typeof flags.watchWeb === 'string'
224
224
  ? resolve(flags.watchWeb)
225
225
  : webSourcePackage(CLI_ROOT)
226
226
  if (!pkgDir) {
227
- const defaultPath = dirname(dirname(dirname(CLI_ROOT))) + '/zoon/packages/web'
227
+ const defaultPath = dirname(dirname(dirname(CLI_ROOT))) + '/zooid-clients/packages/web'
228
228
  throw new Error(
229
- `--watch-web: @zooid/zoon-web not found at ${defaultPath}.\n` +
230
- `Pass an explicit path: --watch-web=/path/to/zoon/packages/web`,
229
+ `--watch-web: @zooid/web not found at ${defaultPath}.\n` +
230
+ `Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`,
231
231
  )
232
232
  }
233
233
  ctx.webWatch = await startWebWatch({ webPackageDir: pkgDir })
@@ -236,7 +236,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
236
236
  ]
237
237
  : []),
238
238
  {
239
- title: `Serve @zooid/zoon-web on http://localhost:${flags.uiPort}`,
239
+ title: `Serve @zooid/web on http://localhost:${flags.uiPort}`,
240
240
  task: async (_, t) => {
241
241
  const webRoot =
242
242
  ctx.webWatch?.distPath ??
@@ -300,11 +300,11 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
300
300
  [
301
301
  '',
302
302
  chalk.bold('Tuwunel is up.') + ` ${homeserver}`,
303
- chalk.bold('UI: ') + ` http://localhost:${flags.uiPort}`,
303
+ chalk.bold('UI: ') + ` http://localhost:${(ctx.uiServer?.address() as { port?: number } | null)?.port ?? flags.uiPort}`,
304
304
  ` ${chalk.cyan('admin user:')} ${flags.adminUser} / ${flags.adminPassword}`,
305
305
  ` ${chalk.cyan('data dir:')} ${layout.dataRoot}`,
306
306
  ...(ctx.webWatch
307
- ? [` ${chalk.cyan('web watcher:')} live (vite build --watch on @zooid/zoon-web)`]
307
+ ? [` ${chalk.cyan('web watcher:')} live (vite build --watch on @zooid/web)`]
308
308
  : []),
309
309
  '',
310
310
  chalk.dim('Press Ctrl-C to stop.'),
@@ -9,24 +9,34 @@ import {
9
9
  } from './generators.js'
10
10
 
11
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
- })
12
+ it('produces the short shape for claude with NO model (harness default)', () => {
13
+ const out = generateZooidYaml({ preset: 'claude' })
17
14
  expect(out).toContain('runtime: local')
15
+ expect(out).toContain('workstation: dev')
18
16
  expect(out).toContain('homeserver: http://localhost:8448')
19
17
  expect(out).toContain('port: 9099')
20
18
  expect(out).toContain('zooid-assistant:')
21
- expect(out).toContain('preset: claude')
22
- expect(out).toContain('model: claude-sonnet-4-6')
19
+ expect(out).toContain('acp: { preset: claude }')
23
20
  expect(out).toContain("display_name: 'Zooid Assistant'")
24
21
  expect(out).toContain("rooms: ['#zooid']")
22
+ expect(out).not.toContain('model:')
25
23
  expect(out).not.toContain('user_id:')
26
24
  expect(out).not.toContain('workdir:')
27
25
  expect(out).not.toContain('transport: matrix')
28
26
  })
29
27
 
28
+ it('expands the acp block to carry a model only when one is pinned', () => {
29
+ const out = generateZooidYaml({ preset: 'claude', model: 'claude-opus-4-8' })
30
+ expect(out).toContain('preset: claude')
31
+ expect(out).toContain('model: claude-opus-4-8')
32
+ })
33
+
34
+ it('includes a comment pointing at pull mode for remote/NAT setups', () => {
35
+ const out = generateZooidYaml({ preset: 'claude' })
36
+ expect(out).toContain('mode: client')
37
+ expect(out).toMatch(/pull mode/i)
38
+ })
39
+
30
40
  it('omits model on the acp block for opencode (model lives in opencode.json)', () => {
31
41
  const out = generateZooidYaml({ preset: 'opencode' })
32
42
  expect(out).toContain('acp: { preset: opencode }')
@@ -59,22 +69,30 @@ describe('generateClaudeSettings', () => {
59
69
  })
60
70
 
61
71
  describe('generateOpencodeJson', () => {
62
- it('produces opencode-go/<model> with the OPENCODE_API_KEY env reference', () => {
72
+ it('omits model by default (opencode picks its own) but keeps provider + apiKey', () => {
63
73
  const out = generateOpencodeJson({
64
74
  provider: 'opencode-go',
65
- model: 'kimi-k2.6',
66
75
  apiKeyEnvVar: 'OPENCODE_API_KEY',
67
76
  })
68
77
  const parsed = JSON.parse(out)
69
- expect(parsed.model).toBe('opencode-go/kimi-k2.6')
78
+ expect(parsed).not.toHaveProperty('model')
70
79
  expect(parsed.provider['opencode-go'].options.apiKey).toBe('{env:OPENCODE_API_KEY}')
71
80
  expect(parsed.permission.webfetch).toBe('allow')
72
81
  })
73
82
 
74
- it('produces a TODO stub for the custom provider', () => {
83
+ it('writes provider/model only when a model is pinned', () => {
84
+ const out = generateOpencodeJson({
85
+ provider: 'opencode-go',
86
+ model: 'kimi-k2.6',
87
+ apiKeyEnvVar: 'OPENCODE_API_KEY',
88
+ })
89
+ expect(JSON.parse(out).model).toBe('opencode-go/kimi-k2.6')
90
+ })
91
+
92
+ it('produces a model-less TODO stub for the custom provider', () => {
75
93
  const out = generateOpencodeJson({ provider: 'custom' })
76
94
  const parsed = JSON.parse(out)
77
- expect(parsed.model).toMatch(/TODO/)
95
+ expect(parsed).not.toHaveProperty('model')
78
96
  expect(parsed.provider).toHaveProperty('TODO')
79
97
  })
80
98
  })
@@ -1,15 +1,27 @@
1
1
  export interface ZooidYamlOpts {
2
2
  preset: 'claude' | 'codex' | 'opencode'
3
- /** Only set for claude / codex; opencode reads its own opencode.json. */
3
+ /**
4
+ * Optional model pin. Omitted by default — the harness picks its own current
5
+ * default. Only set when the user passes `--model` (claude / codex); opencode
6
+ * reads its own opencode.json.
7
+ */
4
8
  model?: string
5
9
  }
6
10
 
7
11
  export function generateZooidYaml(opts: ZooidYamlOpts): string {
12
+ // No model by default: the harness chooses. A `--model` pin (claude/codex
13
+ // only) expands the block to carry it.
8
14
  const acpBlock =
9
- opts.preset === 'opencode'
10
- ? ` acp: { preset: opencode }`
15
+ opts.preset === 'opencode' || !opts.model
16
+ ? ` acp: { preset: ${opts.preset} }`
11
17
  : ` acp:\n preset: ${opts.preset}\n model: ${opts.model}`
12
18
  return `runtime: local
19
+ workstation: dev
20
+
21
+ # This daemon connects in push mode: the homeserver reaches the listener on
22
+ # \`port\` below. To run it against a homeserver it can't open a port to —
23
+ # e.g. on your laptop, behind NAT — switch to pull mode: set \`mode: client\`
24
+ # under transports.matrix and drop \`port\`. Agents are reachable either way.
13
25
 
14
26
  transports:
15
27
  matrix:
@@ -83,7 +95,6 @@ export function generateOpencodeJson(opts: OpencodeJsonOpts): string {
83
95
  JSON.stringify(
84
96
  {
85
97
  $schema: 'https://opencode.ai/config.json',
86
- model: 'TODO/your-model',
87
98
  provider: { TODO: { options: {} } },
88
99
  },
89
100
  null,
@@ -91,16 +102,16 @@ export function generateOpencodeJson(opts: OpencodeJsonOpts): string {
91
102
  ) + '\n'
92
103
  )
93
104
  }
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
- },
105
+ // No `model` by default — opencode picks its own default. A `--model` pin
106
+ // writes the `provider/model` route.
107
+ const cfg: Record<string, unknown> = { $schema: 'https://opencode.ai/config.json' }
108
+ if (opts.model) cfg.model = `${opts.provider}/${opts.model}`
109
+ cfg.provider = {
110
+ [opts.provider]: {
111
+ options: { apiKey: `{env:${opts.apiKeyEnvVar}}` },
101
112
  },
102
- permission: { webfetch: 'allow' as const },
103
113
  }
114
+ cfg.permission = { webfetch: 'allow' as const }
104
115
  return JSON.stringify(cfg, null, 2) + '\n'
105
116
  }
106
117
 
@@ -1,9 +1,5 @@
1
1
  import { select, password } from '@inquirer/prompts'
2
- import {
3
- OPENCODE_PROVIDERS,
4
- findOpencodeProvider,
5
- findSimplePreset,
6
- } from './registry.js'
2
+ import { findOpencodeProvider, findSimplePreset } from './registry.js'
7
3
  import type { InitOptions } from '../init.js'
8
4
 
9
5
  export interface PromptInput {
@@ -38,19 +34,10 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
38
34
  ))
39
35
 
40
36
  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
- ))
37
+ // The interactive wizard never asks which provider/model it defaults to
38
+ // opencode-go (the recommended subscription) and just collects the API key.
39
+ // Other providers and a pinned model are reachable via --provider / --model.
40
+ const provider = flags.provider ?? 'opencode-go'
54
41
  if (provider === 'custom') {
55
42
  return {
56
43
  dir: flags.dir,
@@ -62,13 +49,6 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
62
49
  }
63
50
  const meta = findOpencodeProvider(provider)
64
51
  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
52
  const apiKey = flags.apiKey ?? (await ask(
73
53
  () => password({ message: `${meta.label} API key:` }),
74
54
  () => '--api-key is required',
@@ -77,7 +57,7 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
77
57
  dir: flags.dir,
78
58
  preset,
79
59
  provider,
80
- model,
60
+ model: flags.model,
81
61
  apiKey,
82
62
  force: flags.force,
83
63
  overwrite: flags.overwrite,
@@ -96,13 +76,8 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
96
76
  }),
97
77
  () => '--auth is required (subscription | api-key)',
98
78
  ))
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
- ))
79
+ // No model prompt Claude Code / Codex use their own current default.
80
+ // `--model` pins one for those who want it.
106
81
  const apiKey =
107
82
  auth === 'api-key'
108
83
  ? flags.apiKey ?? (await ask(
@@ -115,7 +90,7 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
115
90
  dir: flags.dir,
116
91
  preset,
117
92
  auth,
118
- model,
93
+ model: flags.model,
119
94
  apiKey,
120
95
  force: flags.force,
121
96
  overwrite: flags.overwrite,
@@ -7,11 +7,11 @@ import {
7
7
  } from './registry.js'
8
8
 
9
9
  describe('init registry', () => {
10
- it('SIMPLE_PRESETS contains claude and codex with non-empty model lists', () => {
10
+ it('SIMPLE_PRESETS contains claude and codex (no model lists to maintain)', () => {
11
11
  const names = SIMPLE_PRESETS.map((p) => p.preset)
12
12
  expect(names).toEqual(['claude', 'codex'])
13
13
  for (const p of SIMPLE_PRESETS) {
14
- expect(p.models.length).toBeGreaterThan(0)
14
+ expect(p).not.toHaveProperty('models')
15
15
  expect(p.apiKeyEnvVar).toMatch(/^[A-Z_]+_API_KEY$/)
16
16
  expect(p.credentialDir.length).toBeGreaterThan(0)
17
17
  }
@@ -1,6 +1,11 @@
1
+ // Note: no model lists live here. The wizard never asks for a model — every
2
+ // harness (Claude Code, Codex, opencode) picks its own current default, so
3
+ // there's no version list to keep fresh. A specific model is a normal
4
+ // post-init edit (`model:` in zooid.yaml, or `model` in opencode.json), and
5
+ // can be pinned non-interactively via the optional `--model` flag.
6
+
1
7
  export interface SimplePresetMeta {
2
8
  preset: 'claude' | 'codex'
3
- models: string[]
4
9
  subscriptionLabel: string
5
10
  apiKeyEnvVar: string
6
11
  apiKeyPromptLabel: string
@@ -11,7 +16,6 @@ export interface SimplePresetMeta {
11
16
  export const SIMPLE_PRESETS: readonly SimplePresetMeta[] = [
12
17
  {
13
18
  preset: 'claude',
14
- models: ['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5'],
15
19
  subscriptionLabel: 'Claude subscription (Pro / Max / Team / Enterprise)',
16
20
  apiKeyEnvVar: 'ANTHROPIC_API_KEY',
17
21
  apiKeyPromptLabel: 'Anthropic',
@@ -19,7 +23,6 @@ export const SIMPLE_PRESETS: readonly SimplePresetMeta[] = [
19
23
  },
20
24
  {
21
25
  preset: 'codex',
22
- models: ['gpt-5.5'],
23
26
  subscriptionLabel: 'ChatGPT subscription (Plus / Pro / Team)',
24
27
  apiKeyEnvVar: 'OPENAI_API_KEY',
25
28
  apiKeyPromptLabel: 'OpenAI',
@@ -31,37 +34,35 @@ export interface OpencodeProviderMeta {
31
34
  id: string
32
35
  label: string
33
36
  description: string
34
- models: string[]
35
37
  apiKeyEnvVar: string
36
38
  }
37
39
 
40
+ // Providers are an opencode routing/auth concern, reachable via the optional
41
+ // `--provider` flag. The interactive wizard defaults to `opencode-go` (first
42
+ // entry) and only asks for the API key.
38
43
  export const OPENCODE_PROVIDERS: readonly OpencodeProviderMeta[] = [
39
44
  {
40
45
  id: 'opencode-go',
41
46
  label: 'opencode-go',
42
47
  description: '$10/mo subscription — Kimi, GLM, MiniMax',
43
- models: ['kimi-k2.6', 'kimi-k2.5', 'glm-5.1', 'glm-5', 'minimax-m2.7'],
44
48
  apiKeyEnvVar: 'OPENCODE_API_KEY',
45
49
  },
46
50
  {
47
51
  id: 'opencode',
48
52
  label: 'opencode (Zen)',
49
53
  description: 'free tier — curated models',
50
- models: ['kimi-k2.5-free', 'glm-4.7-free', 'minimax-m2.1-free'],
51
54
  apiKeyEnvVar: 'OPENCODE_API_KEY',
52
55
  },
53
56
  {
54
57
  id: 'anthropic',
55
58
  label: 'Anthropic',
56
59
  description: 'Claude via direct Anthropic API',
57
- models: ['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5'],
58
60
  apiKeyEnvVar: 'ANTHROPIC_API_KEY',
59
61
  },
60
62
  {
61
63
  id: 'openrouter',
62
64
  label: 'OpenRouter',
63
65
  description: 'many providers via OpenRouter',
64
- models: ['anthropic/claude-sonnet-4-6', 'zhipuai/glm-5', 'moonshot/kimi-k2.6'],
65
66
  apiKeyEnvVar: 'OPENROUTER_API_KEY',
66
67
  },
67
68
  ]
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { loadZooidConfig, findMatrixTransport } from '@zooid/core'
3
+ import { generateZooidYaml } from './generators.js'
4
+
5
+ describe('zooid init scaffold round-trips into an in-namespace agent MXID', () => {
6
+ // The scaffold declares no explicit tokens; loadZooidConfig infers them from
7
+ // the environment (the real daemon supplies them — `zooid dev` generates them).
8
+ beforeEach(() => {
9
+ process.env.MATRIX_AS_TOKEN = 'as-test'
10
+ process.env.MATRIX_HS_TOKEN = 'hs-test'
11
+ })
12
+ afterEach(() => {
13
+ delete process.env.MATRIX_AS_TOKEN
14
+ delete process.env.MATRIX_HS_TOKEN
15
+ })
16
+
17
+ it('the generated workstation scaffold parses to @dev.zooid-assistant inside the exclusive namespace', () => {
18
+ const yaml = generateZooidYaml({ preset: 'claude' })
19
+ const config = loadZooidConfig(yaml)
20
+
21
+ // Workstation drove the registration namespace...
22
+ const tx = findMatrixTransport(config)!.transport
23
+ expect(config.workstation).toBe('dev')
24
+ expect(tx.sender_localpart).toBe('dev')
25
+ expect(tx.user_namespace).toBe('@dev\\..*:localhost')
26
+
27
+ // ...and the agent MXID was derived to sit inside it (no hand-written user_id).
28
+ const mxid = config.agents['zooid-assistant']!.matrix!.user_id
29
+ expect(mxid).toBe('@dev.zooid-assistant:localhost')
30
+ expect(new RegExp(`^${tx.user_namespace}$`).test(mxid)).toBe(true)
31
+ })
32
+
33
+ it('the opencode scaffold derives the same way (preset has no model)', () => {
34
+ const yaml = generateZooidYaml({ preset: 'opencode' })
35
+ const config = loadZooidConfig(yaml)
36
+ expect(config.agents['zooid-assistant']!.matrix!.user_id).toBe('@dev.zooid-assistant:localhost')
37
+ })
38
+ })