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.
- package/README.md +5 -5
- package/dist/bin.js +195 -156
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-R6EDLH23.js → chunk-PDSJJWQW.js} +1928 -990
- package/dist/chunk-PDSJJWQW.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/bin.ts +1 -1
- package/src/bootstrap/configs.ts +19 -6
- package/src/bootstrap/derive.ts +3 -2
- package/src/bootstrap/identity-bootstrap.integration.test.ts +53 -0
- package/src/bootstrap/registration-url.test.ts +14 -0
- package/src/bootstrap/registration-url.ts +7 -0
- package/src/commands/dev.ts +9 -9
- package/src/commands/init/generators.test.ts +30 -12
- package/src/commands/init/generators.ts +23 -12
- package/src/commands/init/prompts.ts +9 -34
- package/src/commands/init/registry.test.ts +2 -2
- package/src/commands/init/registry.ts +9 -8
- package/src/commands/init/scaffold-roundtrip.test.ts +38 -0
- package/src/commands/init.test.ts +17 -12
- package/src/commands/init.ts +2 -4
- package/src/daemon/pull-wiring.test.ts +14 -0
- package/src/daemon/pull-wiring.ts +4 -0
- package/src/daemon/start-daemon-pull.integration.test.ts +95 -0
- package/src/daemon/start-daemon.ts +62 -15
- package/src/daemon/sync-cursors.test.ts +38 -0
- package/src/daemon/sync-cursors.ts +31 -0
- package/src/web/fetch.integration.test.ts +4 -4
- package/src/web/fetch.test.ts +4 -4
- package/src/web/fetch.ts +2 -2
- package/src/web/pin.test.ts +2 -2
- package/src/web/pin.ts +2 -2
- package/src/web/resolve.test.ts +5 -5
- package/src/web/resolve.ts +5 -5
- package/src/web/test-helpers.ts +1 -1
- package/dist/chunk-R6EDLH23.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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/
|
|
45
|
-
"@zooid/
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/
|
|
50
|
-
"@zooid/
|
|
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/
|
|
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({
|
package/src/bootstrap/configs.ts
CHANGED
|
@@ -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
|
|
50
|
-
url
|
|
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
|
-
|
|
60
|
-
// admin human, so the AS cannot claim the namespace exclusively.
|
|
61
|
-
exclusive: false,
|
|
74
|
+
exclusive,
|
|
62
75
|
})
|
|
63
|
-
writeFileSync(
|
|
76
|
+
writeFileSync(registrationPath, yaml)
|
|
64
77
|
}
|
package/src/bootstrap/derive.ts
CHANGED
|
@@ -6,7 +6,8 @@ export interface HomeserverShape {
|
|
|
6
6
|
serverName: string
|
|
7
7
|
}
|
|
8
8
|
|
|
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
|
|
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
|
+
}
|
package/src/commands/dev.ts
CHANGED
|
@@ -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/
|
|
71
|
-
// true = auto-detect (sibling ../
|
|
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/
|
|
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))) + '/
|
|
227
|
+
const defaultPath = dirname(dirname(dirname(CLI_ROOT))) + '/zooid-clients/packages/web'
|
|
228
228
|
throw new Error(
|
|
229
|
-
`--watch-web: @zooid/
|
|
230
|
-
`Pass an explicit path: --watch-web=/path/to/
|
|
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/
|
|
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/
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
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.
|
|
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
|
+
})
|