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/bin.js +182 -142
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-LOILUENL.js → chunk-PDSJJWQW.js} +1827 -967
- package/dist/chunk-PDSJJWQW.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +9 -9
- 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 +1 -1
- 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 +53 -6
- package/src/daemon/sync-cursors.test.ts +38 -0
- package/src/daemon/sync-cursors.ts +31 -0
- package/src/web/resolve.test.ts +2 -2
- package/src/web/resolve.ts +2 -2
- package/dist/chunk-LOILUENL.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
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/runtime-
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/transport-
|
|
50
|
-
"@zooid/transport-
|
|
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/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
|
@@ -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
|
|
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
|
+
})
|
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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'
|
|
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'
|
|
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
|
})
|