zooid 0.9.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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-LOILUENL.js";
4
+ } from "./chunk-PDSJJWQW.js";
5
5
  export {
6
6
  buildAcpRegistry
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
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",
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "zooid": {
28
- "webVersion": "0.7.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.9.0",
45
- "@zooid/runtime-docker": "^0.9.0",
46
- "@zooid/acp-client": "^0.9.0",
47
- "@zooid/runtime-local": "^0.9.0",
48
- "@zooid/core": "^0.9.0",
49
- "@zooid/transport-matrix": "^0.9.0",
50
- "@zooid/transport-http": "^0.9.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",
@@ -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
+ }
@@ -300,7 +300,7 @@ 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
@@ -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
+ })
@@ -19,12 +19,11 @@ function loadYaml(path: string): unknown {
19
19
  }
20
20
 
21
21
  describe('runInit — claude subscription path', () => {
22
- it('writes yaml + AGENTS.md + .claude/settings.json + .gitignore (no .env, no opencode.json)', async () => {
22
+ it('writes yaml + AGENTS.md + .claude/settings.json + .gitignore (no model, no .env, no opencode.json)', async () => {
23
23
  await runInit({
24
24
  dir,
25
25
  preset: 'claude',
26
26
  auth: 'subscription',
27
- model: 'claude-sonnet-4-6',
28
27
  })
29
28
 
30
29
  expect(existsSync(join(dir, 'zooid.yaml'))).toBe(true)
@@ -36,11 +35,20 @@ describe('runInit — claude subscription path', () => {
36
35
  expect(existsSync(join(dir, 'agents/zooid-assistant/opencode.json'))).toBe(false)
37
36
 
38
37
  const cfg = loadYaml(join(dir, 'zooid.yaml')) as {
39
- agents: { 'zooid-assistant': { acp: { preset: string; model: string } } }
38
+ agents: { 'zooid-assistant': { acp: { preset: string; model?: string } } }
39
+ }
40
+ // No model — the harness chooses its own default.
41
+ expect(cfg.agents['zooid-assistant'].acp).toEqual({ preset: 'claude' })
42
+ })
43
+
44
+ it('pins a model when --model is passed', async () => {
45
+ await runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-opus-4-8' })
46
+ const cfg = loadYaml(join(dir, 'zooid.yaml')) as {
47
+ agents: { 'zooid-assistant': { acp: { preset: string; model?: string } } }
40
48
  }
41
49
  expect(cfg.agents['zooid-assistant'].acp).toEqual({
42
50
  preset: 'claude',
43
- model: 'claude-sonnet-4-6',
51
+ model: 'claude-opus-4-8',
44
52
  })
45
53
  })
46
54
  })
@@ -51,7 +59,6 @@ describe('runInit — claude api-key path', () => {
51
59
  dir,
52
60
  preset: 'claude',
53
61
  auth: 'api-key',
54
- model: 'claude-sonnet-4-6',
55
62
  apiKey: 'sk-ant-xyz',
56
63
  })
57
64
 
@@ -60,12 +67,11 @@ describe('runInit — claude api-key path', () => {
60
67
  })
61
68
 
62
69
  describe('runInit — opencode opencode-go path', () => {
63
- it('writes opencode.json with opencode-go provider + env-interpolated apiKey', async () => {
70
+ it('writes opencode.json with opencode-go provider + env-interpolated apiKey, no model', async () => {
64
71
  await runInit({
65
72
  dir,
66
73
  preset: 'opencode',
67
74
  provider: 'opencode-go',
68
- model: 'kimi-k2.6',
69
75
  apiKey: 'sk-opencode-zzz',
70
76
  })
71
77
 
@@ -73,7 +79,8 @@ describe('runInit — opencode opencode-go path', () => {
73
79
  const oc = JSON.parse(
74
80
  readFileSync(join(dir, 'agents/zooid-assistant/opencode.json'), 'utf8'),
75
81
  )
76
- expect(oc.model).toBe('opencode-go/kimi-k2.6')
82
+ // No model — opencode picks its own default (e.g. bigpickle).
83
+ expect(oc).not.toHaveProperty('model')
77
84
  expect(oc.provider['opencode-go'].options.apiKey).toBe('{env:OPENCODE_API_KEY}')
78
85
  expect(oc.permission.webfetch).toBe('allow')
79
86
 
@@ -90,7 +97,7 @@ describe('runInit — idempotency', () => {
90
97
  it('errors on non-empty dir without --force', async () => {
91
98
  writeFileSync(join(dir, 'preexisting.txt'), 'x')
92
99
  await expect(
93
- runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-sonnet-4-6' }),
100
+ runInit({ dir, preset: 'claude', auth: 'subscription' }),
94
101
  ).rejects.toThrow(/non-empty/i)
95
102
  })
96
103
 
@@ -99,7 +106,7 @@ describe('runInit — idempotency', () => {
99
106
  writeFileSync(join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 9')
100
107
  require('node:fs').mkdirSync(join(dir, 'node_modules'))
101
108
  require('node:fs').mkdirSync(join(dir, '.git'))
102
- await runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-sonnet-4-6' })
109
+ await runInit({ dir, preset: 'claude', auth: 'subscription' })
103
110
  expect(existsSync(join(dir, 'zooid.yaml'))).toBe(true)
104
111
  })
105
112
 
@@ -109,7 +116,6 @@ describe('runInit — idempotency', () => {
109
116
  dir,
110
117
  preset: 'claude',
111
118
  auth: 'subscription',
112
- model: 'claude-sonnet-4-6',
113
119
  force: true,
114
120
  })
115
121
  expect(readFileSync(join(dir, 'zooid.yaml'), 'utf8')).toContain('preexisting: true')
@@ -122,7 +128,6 @@ describe('runInit — idempotency', () => {
122
128
  dir,
123
129
  preset: 'claude',
124
130
  auth: 'subscription',
125
- model: 'claude-sonnet-4-6',
126
131
  force: true,
127
132
  overwrite: true,
128
133
  })