zooid 0.11.2 → 0.13.0
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 +8 -3
- package/dist/bin.js +503 -124
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-KGYQ5YNP.js → chunk-YZ4IO5MR.js} +78 -11
- package/dist/chunk-YZ4IO5MR.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +11 -9
- package/src/bin.test.ts +63 -0
- package/src/bin.ts +55 -6
- package/src/bootstrap/configs.test.ts +7 -0
- package/src/bootstrap/configs.ts +14 -0
- package/src/build-registry.ts +1 -1
- package/src/build-registry.zod044.test.ts +23 -0
- package/src/commands/dev.ts +37 -7
- package/src/commands/init/generators.test.ts +43 -0
- package/src/commands/init/generators.ts +24 -5
- package/src/commands/init/pi-scaffold.test.ts +151 -0
- package/src/commands/init/prompts.ts +53 -2
- package/src/commands/init/registry.test.ts +60 -0
- package/src/commands/init/registry.ts +58 -0
- package/src/commands/init/sniff.test.ts +61 -2
- package/src/commands/init/sniff.ts +43 -12
- package/src/commands/init.ts +91 -5
- package/src/commands/status.test.ts +15 -0
- package/src/commands/status.ts +27 -2
- package/src/daemon/start-daemon.ts +15 -1
- package/src/push-gateway/gateway.test.ts +150 -0
- package/src/push-gateway/gateway.ts +78 -0
- package/src/push-gateway/index.ts +17 -0
- package/src/push-gateway/payload.test.ts +88 -0
- package/src/push-gateway/payload.ts +39 -0
- package/src/push-gateway/types.ts +37 -0
- package/src/push-gateway/vapid.test.ts +45 -0
- package/src/push-gateway/vapid.ts +34 -0
- package/src/services/tuwunel.ts +21 -2
- package/src/version.test.ts +67 -0
- package/src/version.ts +30 -0
- package/src/web/static.test.ts +22 -0
- package/src/web/static.ts +9 -1
- package/dist/chunk-KGYQ5YNP.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.
|
|
3
|
+
"version": "0.13.0",
|
|
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.11.0"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=22"
|
|
@@ -40,19 +40,21 @@
|
|
|
40
40
|
"marked": "^18.0.4",
|
|
41
41
|
"sanitize-html": "^2.17.4",
|
|
42
42
|
"tar": "^7.5.16",
|
|
43
|
+
"web-push": "^3.6.7",
|
|
43
44
|
"yaml": "^2.5.0",
|
|
44
|
-
"@zooid/acp-client": "^0.
|
|
45
|
-
"@zooid/
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/
|
|
50
|
-
"@zooid/transport-http": "^0.
|
|
45
|
+
"@zooid/acp-client": "^0.13.0",
|
|
46
|
+
"@zooid/transport-matrix": "^0.13.0",
|
|
47
|
+
"@zooid/context-mcp": "^0.13.0",
|
|
48
|
+
"@zooid/core": "^0.13.0",
|
|
49
|
+
"@zooid/runtime-local": "^0.13.0",
|
|
50
|
+
"@zooid/runtime-docker": "^0.13.0",
|
|
51
|
+
"@zooid/transport-http": "^0.13.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@agentclientprotocol/sdk": "^0.21.0",
|
|
54
55
|
"@playwright/test": "^1.49.0",
|
|
55
56
|
"@types/node": "^22.0.0",
|
|
57
|
+
"@types/web-push": "^3.6.4",
|
|
56
58
|
"release-it": "^19.2.4",
|
|
57
59
|
"tsup": "^8.5.1",
|
|
58
60
|
"tsx": "^4.19.0",
|
package/src/bin.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const TSX = join(HERE, '..', 'node_modules', '.bin', 'tsx')
|
|
8
|
+
const BIN = join(HERE, 'bin.ts')
|
|
9
|
+
|
|
10
|
+
function runCli(args: string[]) {
|
|
11
|
+
const result = spawnSync(TSX, [BIN, ...args], { encoding: 'utf8' })
|
|
12
|
+
return { stdout: result.stdout, stderr: result.stderr, status: result.status }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('zooid help', () => {
|
|
16
|
+
it('lists every registered command with no args', () => {
|
|
17
|
+
const { stdout, status } = runCli([])
|
|
18
|
+
expect(status).toBe(1)
|
|
19
|
+
for (const name of ['start', 'dev', 'logs', 'status', 'init', 'help']) {
|
|
20
|
+
expect(stdout).toContain(name)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('--help prints the same command list and exits 0', () => {
|
|
25
|
+
const { stdout, status } = runCli(['--help'])
|
|
26
|
+
expect(status).toBe(0)
|
|
27
|
+
expect(stdout).toContain('Commands:')
|
|
28
|
+
expect(stdout).toContain('init [dir]')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('`zooid help` mirrors `zooid --help`', () => {
|
|
32
|
+
const { stdout, status } = runCli(['help'])
|
|
33
|
+
expect(status).toBe(0)
|
|
34
|
+
expect(stdout).toContain('Commands:')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('`zooid help <command>` shows that command\'s own help', () => {
|
|
38
|
+
const { stdout, status } = runCli(['help', 'init'])
|
|
39
|
+
expect(status).toBe(0)
|
|
40
|
+
expect(stdout).toContain('$ zooid init [dir]')
|
|
41
|
+
expect(stdout).toContain('--preset <name>')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('`zooid <command> --help` still works directly', () => {
|
|
45
|
+
const { stdout, status } = runCli(['logs', '--help'])
|
|
46
|
+
expect(status).toBe(0)
|
|
47
|
+
expect(stdout).toContain('$ zooid logs [source]')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects an unknown subcommand of help', () => {
|
|
51
|
+
const { stdout, stderr, status } = runCli(['help', 'bogus'])
|
|
52
|
+
expect(status).toBe(1)
|
|
53
|
+
expect(stderr).toContain('Unknown command: bogus')
|
|
54
|
+
expect(stdout).toContain('Commands:')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects an unknown top-level command instead of exiting silently', () => {
|
|
58
|
+
const { stdout, stderr, status } = runCli(['frobnicate'])
|
|
59
|
+
expect(status).toBe(1)
|
|
60
|
+
expect(stderr).toContain('Unknown command: frobnicate')
|
|
61
|
+
expect(stdout).toContain('Commands:')
|
|
62
|
+
})
|
|
63
|
+
})
|
package/src/bin.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveOptions } from './commands/init/prompts.js'
|
|
|
6
6
|
import { runLogs } from './commands/logs.js'
|
|
7
7
|
import { runStart } from './commands/start.js'
|
|
8
8
|
import { runStatus } from './commands/status.js'
|
|
9
|
+
import { CLI_VERSION } from './version.js'
|
|
9
10
|
|
|
10
11
|
const cli = cac('zooid')
|
|
11
12
|
|
|
@@ -15,6 +16,8 @@ cli
|
|
|
15
16
|
.option('--runtime <local|docker|podman>', 'Agent runtime')
|
|
16
17
|
.option('--image <ref>', 'Agent container image')
|
|
17
18
|
.option('--print-token', 'Print a 32-byte hex token and exit')
|
|
19
|
+
.example('$ zooid start --data ./data')
|
|
20
|
+
.example('$ zooid start --runtime docker --image ghcr.io/zooid-ai/agent:latest')
|
|
18
21
|
.action(async (flags) => {
|
|
19
22
|
await runStart({
|
|
20
23
|
dataDir: flags.data,
|
|
@@ -35,6 +38,8 @@ cli
|
|
|
35
38
|
'--watch-web [path]',
|
|
36
39
|
'Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web.',
|
|
37
40
|
)
|
|
41
|
+
.example('$ zooid dev')
|
|
42
|
+
.example('$ zooid dev --engine podman --ui-port 5174')
|
|
38
43
|
.action(async (flags) => {
|
|
39
44
|
await runDev({
|
|
40
45
|
dataDir: flags.data,
|
|
@@ -56,6 +61,9 @@ cli
|
|
|
56
61
|
.option('--turn <id>', 'Filter ACP taps to a single turn id')
|
|
57
62
|
.option('-f, --follow', 'Tail the file (not yet implemented)')
|
|
58
63
|
.option('--keep <n>', 'For `logs prune`: days to retain', { default: 14 })
|
|
64
|
+
.example('$ zooid logs daemon')
|
|
65
|
+
.example('$ zooid logs agent-support.acp --turn 3f9c1a --day 2026-09-06')
|
|
66
|
+
.example('$ zooid logs prune --keep 7')
|
|
59
67
|
.action(async (source, flags) => {
|
|
60
68
|
if (source === 'prune') {
|
|
61
69
|
await runLogs({
|
|
@@ -78,6 +86,7 @@ cli
|
|
|
78
86
|
.command('status', 'Print Tuwunel + daemon health')
|
|
79
87
|
.option('--data <dir>', 'Persistent data root dir', { default: './data' })
|
|
80
88
|
.option('--port <n>', 'Tuwunel host port (defaults to zooid.yaml)')
|
|
89
|
+
.example('$ zooid status')
|
|
81
90
|
.action(async (flags) => {
|
|
82
91
|
await runStatus({
|
|
83
92
|
dataDir: flags.data,
|
|
@@ -87,14 +96,17 @@ cli
|
|
|
87
96
|
|
|
88
97
|
cli
|
|
89
98
|
.command('init [dir]', 'Scaffold a new zooid workforce in the current (or named) directory')
|
|
90
|
-
.option('--preset <name>', 'claude | codex | opencode')
|
|
91
|
-
.option('--auth <mode>', 'subscription | api-key (claude/codex only)')
|
|
99
|
+
.option('--preset <name>', 'claude | codex | opencode | pi')
|
|
100
|
+
.option('--auth <mode>', 'subscription | api-key (claude/codex/pi only)')
|
|
92
101
|
.option('--model <id>', 'Model identifier')
|
|
93
|
-
.option('--provider <id>', 'opencode provider: opencode-go | opencode | anthropic | openrouter | custom')
|
|
102
|
+
.option('--provider <id>', 'opencode provider: opencode-go | opencode | anthropic | openrouter | custom; pi provider: openrouter | anthropic | openai')
|
|
94
103
|
.option('--api-key <value>', 'API key (api-key path; opencode always)')
|
|
95
104
|
.option('--force', 'Allow scaffolding into a non-empty directory')
|
|
96
105
|
.option('--overwrite', 'With --force, overwrite existing files')
|
|
97
106
|
.option('--no-interactive', 'Disable prompts; require all flags up front')
|
|
107
|
+
.example('$ zooid init')
|
|
108
|
+
.example('$ zooid init my-workforce --preset opencode --provider anthropic')
|
|
109
|
+
.example('$ zooid init --preset claude --auth api-key --api-key sk-... --no-interactive')
|
|
98
110
|
.action(async (dir: string | undefined, flags) => {
|
|
99
111
|
const resolved = await resolveOptions({
|
|
100
112
|
dir: dir ?? process.cwd(),
|
|
@@ -110,6 +122,43 @@ cli
|
|
|
110
122
|
await runInit(resolved)
|
|
111
123
|
})
|
|
112
124
|
|
|
113
|
-
cli
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
cli
|
|
126
|
+
.command('help [command]', 'Display help for zooid, or for a specific command')
|
|
127
|
+
.example('$ zooid help')
|
|
128
|
+
.example('$ zooid help init')
|
|
129
|
+
.action((commandName?: string) => {
|
|
130
|
+
if (!commandName) {
|
|
131
|
+
cli.globalCommand.outputHelp()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const target = cli.commands.find((c) => c.isMatched(commandName))
|
|
135
|
+
if (!target) {
|
|
136
|
+
console.error(`Unknown command: ${commandName}\n`)
|
|
137
|
+
cli.globalCommand.outputHelp()
|
|
138
|
+
process.exitCode = 1
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
target.outputHelp()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
cli.help((sections) => {
|
|
145
|
+
sections.push({
|
|
146
|
+
title: 'Docs',
|
|
147
|
+
body: ' https://zooid.dev/docs',
|
|
148
|
+
})
|
|
149
|
+
return sections
|
|
150
|
+
})
|
|
151
|
+
cli.version(CLI_VERSION)
|
|
152
|
+
|
|
153
|
+
cli.on('command:*', () => {
|
|
154
|
+
console.error(`Unknown command: ${cli.args.join(' ')}\n`)
|
|
155
|
+
cli.outputHelp()
|
|
156
|
+
process.exitCode = 1
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (process.argv.slice(2).length === 0) {
|
|
160
|
+
cli.outputHelp()
|
|
161
|
+
process.exitCode = 1
|
|
162
|
+
} else {
|
|
163
|
+
cli.parse()
|
|
164
|
+
}
|
|
@@ -15,6 +15,13 @@ describe('renderTuwunelToml', () => {
|
|
|
15
15
|
)
|
|
16
16
|
expect(toml).toContain('allow_local_presence = true')
|
|
17
17
|
expect(toml).toContain('port = [8448]')
|
|
18
|
+
// Presence-based push suppression stays OFF — sw.js suppresses per-room
|
|
19
|
+
// on visibility instead, and presence lingers long past a closed tab.
|
|
20
|
+
expect(toml).not.toContain('suppress_push_when_active')
|
|
21
|
+
// DEV ONLY: Tuwunel is in Podman, the push gateway runs on the host, and
|
|
22
|
+
// the default ip_range_denylist (127/8, 10/8, 172.16/12, 192.168/16, ::1)
|
|
23
|
+
// would silently drop every pusher delivery to it.
|
|
24
|
+
expect(toml).toContain('ip_range_denylist = []')
|
|
18
25
|
})
|
|
19
26
|
|
|
20
27
|
it('honors a non-default server_name', () => {
|
package/src/bootstrap/configs.ts
CHANGED
|
@@ -26,6 +26,20 @@ export function renderTuwunelToml(opts: TuwunelTomlOpts): string {
|
|
|
26
26
|
'allow_local_presence = true',
|
|
27
27
|
'address = ["0.0.0.0"]',
|
|
28
28
|
`port = [${TUWUNEL_INTERNAL_PORT}]`,
|
|
29
|
+
// NOT `suppress_push_when_active` ([[ZNC025]]). It reads Matrix presence,
|
|
30
|
+
// which is both too coarse and too slow for this: coarse because it is
|
|
31
|
+
// per-user, so reading room A kills the push for room B; slow because
|
|
32
|
+
// `currently_active` lingers for minutes after the last sync, so closing
|
|
33
|
+
// the tab and waiting for an agent to finish still delivers nothing —
|
|
34
|
+
// exactly the case this feature exists for. `public/sw.js` already does
|
|
35
|
+
// the suppression we actually want, precisely: it drops a push only when
|
|
36
|
+
// a *visible* window is on *that* room.
|
|
37
|
+
// DEV ONLY — disables an SSRF guard. Tuwunel is in Podman and the push
|
|
38
|
+
// gateway runs on the host, so the default ip_range_denylist (127/8,
|
|
39
|
+
// 10/8, 172.16/12, 192.168/16, ::1) silently drops every pusher delivery.
|
|
40
|
+
// Never set on a box: there the gateway is reached at the public
|
|
41
|
+
// hostname through Caddy.
|
|
42
|
+
'ip_range_denylist = []',
|
|
29
43
|
'',
|
|
30
44
|
].join('\n')
|
|
31
45
|
}
|
package/src/build-registry.ts
CHANGED
|
@@ -234,7 +234,7 @@ export function buildAcpRegistry(
|
|
|
234
234
|
`runtime: ${cfg.runtime} requires a container image for each agent. Unresolved:\n` +
|
|
235
235
|
lines.join('\n') +
|
|
236
236
|
`\nSet agents.<name>.container.image, top-level container.image, or use a ` +
|
|
237
|
-
`preset that ships a default image (claude, codex, opencode).`,
|
|
237
|
+
`preset that ships a default image (claude, codex, opencode, pi).`,
|
|
238
238
|
)
|
|
239
239
|
}
|
|
240
240
|
}
|
|
@@ -220,6 +220,29 @@ describe('buildAcpRegistry — image resolution (ZOD044)', () => {
|
|
|
220
220
|
})
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
describe('pi image resolution (ZOD073)', () => {
|
|
224
|
+
it('resolves a pi agent to the preset default image under runtime: docker', () => {
|
|
225
|
+
const cfg = mkCfg({ acp: { preset: 'pi' } })
|
|
226
|
+
const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
|
|
227
|
+
expect(reg.resolveSpawnImage('alice')).toBe('ghcr.io/zooid-ai/agent-pi:latest')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('lets agents.<name>.container.image override the pi default', () => {
|
|
231
|
+
const cfg = mkCfg({ acp: { preset: 'pi' }, container: { image: 'local/pi:dev' } })
|
|
232
|
+
const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
|
|
233
|
+
expect(reg.resolveSpawnImage('alice')).toBe('local/pi:dev')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Before this cycle, a pi agent under docker threw at startup with
|
|
237
|
+
// "no preset-default image". That error must stop naming pi.
|
|
238
|
+
it('does not throw the unresolved-image startup error for pi', () => {
|
|
239
|
+
const cfg = mkCfg({ acp: { preset: 'pi' } })
|
|
240
|
+
expect(() =>
|
|
241
|
+
buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
|
|
242
|
+
).not.toThrow()
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
223
246
|
describe('buildAcpRegistry — startup discoverability (ZOD044)', () => {
|
|
224
247
|
it('logs one resolved-image line per agent with provenance', () => {
|
|
225
248
|
const lines: string[] = []
|
package/src/commands/dev.ts
CHANGED
|
@@ -248,7 +248,22 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
248
248
|
t.output = msg
|
|
249
249
|
},
|
|
250
250
|
}))
|
|
251
|
-
const app = webStatic({
|
|
251
|
+
const app = webStatic({
|
|
252
|
+
webRoot,
|
|
253
|
+
homeserverUrl: homeserver,
|
|
254
|
+
...(ctx.daemon?.vapidPublicKey
|
|
255
|
+
? {
|
|
256
|
+
// Tuwunel runs in a container; `localhost` here would
|
|
257
|
+
// resolve to the container, not the daemon. Same shorthand
|
|
258
|
+
// as the AS registration url
|
|
259
|
+
// (bootstrap/registration-url.ts). This URL is stored in
|
|
260
|
+
// the pusher and fetched by the homeserver — the browser
|
|
261
|
+
// never requests it.
|
|
262
|
+
pushGatewayUrl: `http://host.docker.internal:${ctx.daemon.port}/_matrix/push/v1/notify`,
|
|
263
|
+
vapidPublicKey: ctx.daemon.vapidPublicKey,
|
|
264
|
+
}
|
|
265
|
+
: {}),
|
|
266
|
+
})
|
|
252
267
|
ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
|
|
253
268
|
},
|
|
254
269
|
},
|
|
@@ -287,13 +302,28 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
287
302
|
})
|
|
288
303
|
|
|
289
304
|
if (flags.installSignalHandlers !== false) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
305
|
+
// buildShutdown is idempotent, so a repeat Ctrl-C used to print again and
|
|
306
|
+
// await the same promise, with no way to give up on a step taking too
|
|
307
|
+
// long. Escalate on repeats instead.
|
|
308
|
+
let interrupts = 0
|
|
309
|
+
const onSignal = (): void => {
|
|
310
|
+
interrupts += 1
|
|
311
|
+
if (interrupts === 1) {
|
|
312
|
+
process.stdout.write(chalk.dim('\nStopping…\n'))
|
|
313
|
+
void shutdown().then(() => process.exit(0))
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (interrupts === 2) {
|
|
317
|
+
process.stdout.write(
|
|
318
|
+
chalk.dim('Still stopping — press Ctrl-C again to force quit.\n'),
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write(chalk.dim('Forced.\n'))
|
|
323
|
+
process.exit(130)
|
|
294
324
|
}
|
|
295
|
-
process.on('SIGINT',
|
|
296
|
-
process.on('SIGTERM',
|
|
325
|
+
process.on('SIGINT', onSignal)
|
|
326
|
+
process.on('SIGTERM', onSignal)
|
|
297
327
|
}
|
|
298
328
|
|
|
299
329
|
process.stdout.write(
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
generateOpencodeJson,
|
|
7
7
|
generateEnv,
|
|
8
8
|
generateGitignore,
|
|
9
|
+
generatePiSettings,
|
|
9
10
|
} from './generators.js'
|
|
10
11
|
|
|
11
12
|
describe('generateZooidYaml', () => {
|
|
@@ -113,3 +114,45 @@ describe('generateGitignore', () => {
|
|
|
113
114
|
expect(out).toContain('data/')
|
|
114
115
|
})
|
|
115
116
|
})
|
|
117
|
+
|
|
118
|
+
describe('generatePiSettings (ZOD075)', () => {
|
|
119
|
+
it('writes only the two keys pi needs to boot', () => {
|
|
120
|
+
const parsed = JSON.parse(
|
|
121
|
+
generatePiSettings({ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }),
|
|
122
|
+
)
|
|
123
|
+
expect(parsed).toEqual({
|
|
124
|
+
defaultProvider: 'openrouter',
|
|
125
|
+
defaultModel: 'deepseek/deepseek-v4-pro',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('ends with a newline like every other generator', () => {
|
|
130
|
+
expect(generatePiSettings({ provider: 'openrouter', model: 'm' })).toMatch(/\n$/)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('generateZooidYaml — pi (ZOD075)', () => {
|
|
135
|
+
it('emits acp preset pi', () => {
|
|
136
|
+
expect(generateZooidYaml({ preset: 'pi' })).toContain('acp: { preset: pi }')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// pi is the wizard's ONE pinned model (see registry.ts comment): its own
|
|
140
|
+
// default returned an empty turn. The pin lives in .pi-agent/settings.json,
|
|
141
|
+
// NOT in acp.model — acp.model is unwired for pi (ZOD073 non-goal).
|
|
142
|
+
it('does not pin the model via acp.model even when one is supplied', () => {
|
|
143
|
+
const yaml = generateZooidYaml({ preset: 'pi', model: 'deepseek/deepseek-v4-pro' })
|
|
144
|
+
expect(yaml).not.toContain('model: deepseek')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('generateGitignore — pi (ZOD075)', () => {
|
|
149
|
+
// .pi-agent holds settings and, on the shared-credential path, a link to a
|
|
150
|
+
// 600-mode token file. It must never be committable.
|
|
151
|
+
it('ignores the pi agent dir', () => {
|
|
152
|
+
expect(generateGitignore().split('\n')).toContain('.pi-agent/')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('still ignores .env', () => {
|
|
156
|
+
expect(generateGitignore().split('\n')).toContain('.env')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
export interface ZooidYamlOpts {
|
|
2
|
-
preset: 'claude' | 'codex' | 'opencode'
|
|
2
|
+
preset: 'claude' | 'codex' | 'opencode' | 'pi'
|
|
3
3
|
/**
|
|
4
4
|
* Optional model pin. Omitted by default — the harness picks its own current
|
|
5
5
|
* default. Only set when the user passes `--model` (claude / codex); opencode
|
|
6
|
-
*
|
|
6
|
+
* and pi read their own config files instead (opencode.json / settings.json).
|
|
7
7
|
*/
|
|
8
8
|
model?: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function generateZooidYaml(opts: ZooidYamlOpts): string {
|
|
12
12
|
// No model by default: the harness chooses. A `--model` pin (claude/codex
|
|
13
|
-
// only) expands the block to carry it.
|
|
13
|
+
// only) expands the block to carry it. pi is never expanded here — its pin
|
|
14
|
+
// lives in .pi-agent/settings.json; acp.model is unwired for pi (ZOD073).
|
|
14
15
|
const acpBlock =
|
|
15
|
-
opts.preset === 'opencode' || !opts.model
|
|
16
|
+
opts.preset === 'opencode' || opts.preset === 'pi' || !opts.model
|
|
16
17
|
? ` acp: { preset: ${opts.preset} }`
|
|
17
18
|
: ` acp:\n preset: ${opts.preset}\n model: ${opts.model}`
|
|
18
19
|
return `runtime: local
|
|
@@ -125,7 +126,25 @@ export function generateEnv(opts: EnvOpts): string {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
export function generateGitignore(): string {
|
|
128
|
-
|
|
129
|
+
// .pi-agent carries pi's settings and, on the shared-credential path, a link
|
|
130
|
+
// to a 600-mode token file. Never committable.
|
|
131
|
+
return ['.env', '.pi-agent/', 'node_modules/', 'data/', ''].join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PiSettingsOpts {
|
|
135
|
+
provider: string
|
|
136
|
+
model: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* ZOD075. pi's *global* settings, relocated into the project by
|
|
141
|
+
* PI_CODING_AGENT_DIR. Only the two boot keys — theme and the rest stay the
|
|
142
|
+
* operator's business, and copying more would age badly.
|
|
143
|
+
*/
|
|
144
|
+
export function generatePiSettings(opts: PiSettingsOpts): string {
|
|
145
|
+
return (
|
|
146
|
+
JSON.stringify({ defaultProvider: opts.provider, defaultModel: opts.model }, null, 2) + '\n'
|
|
147
|
+
)
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
export function generateOpencodeReadme(): string {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { loadZooidConfig } from '@zooid/core'
|
|
6
|
+
import { runInit } from '../init.js'
|
|
7
|
+
|
|
8
|
+
let dir: string
|
|
9
|
+
let fakeHome: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), 'zooid-pi-init-'))
|
|
13
|
+
fakeHome = mkdtempSync(join(tmpdir(), 'zooid-pi-home-'))
|
|
14
|
+
process.env.MATRIX_AS_TOKEN = 'as-test'
|
|
15
|
+
process.env.MATRIX_HS_TOKEN = 'hs-test'
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true })
|
|
20
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
21
|
+
delete process.env.MATRIX_AS_TOKEN
|
|
22
|
+
delete process.env.MATRIX_HS_TOKEN
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const read = (p: string) => readFileSync(join(dir, p), 'utf8')
|
|
26
|
+
|
|
27
|
+
describe('zooid init --preset pi (ZOD075)', () => {
|
|
28
|
+
it('scaffolds a config that parses and resolves the pi preset', async () => {
|
|
29
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
30
|
+
const cfg = loadZooidConfig(read('zooid.yaml'))
|
|
31
|
+
expect(cfg.agents['zooid-assistant']!.acp).toMatchObject({ preset: 'pi' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// The agent dir is RELATIVE so one value is correct under both runtimes:
|
|
35
|
+
// cwd is agents/zooid-assistant locally and /workspace in a container, and
|
|
36
|
+
// those are the same directory. See spike 1.1.
|
|
37
|
+
it('points PI_CODING_AGENT_DIR at a relative .pi-agent', () => {
|
|
38
|
+
expect(true).toBe(true) // asserted below once written
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('writes PI_CODING_AGENT_DIR into .env as a relative path', async () => {
|
|
42
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
43
|
+
expect(read('.env')).toContain('PI_CODING_AGENT_DIR=.pi-agent')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('writes the API key for the chosen provider', async () => {
|
|
47
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
48
|
+
expect(read('.env')).toContain('OPENROUTER_API_KEY=sk-test')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// The settings file lands INSIDE the agent's workdir, because that is the
|
|
52
|
+
// cwd pi resolves the relative override against.
|
|
53
|
+
it('seeds .pi-agent/settings.json under the agent workdir', async () => {
|
|
54
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
55
|
+
const s = JSON.parse(read('agents/zooid-assistant/.pi-agent/settings.json'))
|
|
56
|
+
expect(s).toEqual({
|
|
57
|
+
defaultProvider: 'openrouter',
|
|
58
|
+
defaultModel: 'deepseek/deepseek-v4-pro',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('gitignores the agent dir', async () => {
|
|
63
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
64
|
+
expect(read('.gitignore')).toContain('.pi-agent/')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('writes AGENTS.md but no CLAUDE.md or opencode.json', async () => {
|
|
68
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
69
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/AGENTS.md'))).toBe(true)
|
|
70
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/CLAUDE.md'))).toBe(false)
|
|
71
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/opencode.json'))).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('zooid init --preset pi auth choice (ZOD075)', () => {
|
|
76
|
+
const seedLogin = () => {
|
|
77
|
+
mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
|
|
78
|
+
writeFileSync(join(fakeHome, '.pi', 'agent', 'auth.json'), '{"openrouter":{}}')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The agent gets its OWN identity. This is the only path on a server, the
|
|
82
|
+
// simpler path in a container, and the right default when agent spend should
|
|
83
|
+
// be billed or revoked separately.
|
|
84
|
+
it('api-key gives the agent its own credential and shares nothing', async () => {
|
|
85
|
+
seedLogin()
|
|
86
|
+
await runInit({
|
|
87
|
+
dir, preset: 'pi', auth: 'api-key', provider: 'openrouter',
|
|
88
|
+
apiKey: 'sk-agent-own', home: fakeHome,
|
|
89
|
+
})
|
|
90
|
+
expect(read('.env')).toContain('OPENROUTER_API_KEY=sk-agent-own')
|
|
91
|
+
// No link/copy of the operator's auth.json anywhere in the scaffold.
|
|
92
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/.pi-agent/auth.json'))).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Detecting a login must OFFER, never assume — an inferred share is exactly
|
|
96
|
+
// the silent guess this spec exists to remove.
|
|
97
|
+
it('does not borrow the login just because one was detected', async () => {
|
|
98
|
+
seedLogin()
|
|
99
|
+
await runInit({
|
|
100
|
+
dir, preset: 'pi', auth: 'api-key', provider: 'openrouter',
|
|
101
|
+
apiKey: 'sk-agent-own', home: fakeHome,
|
|
102
|
+
})
|
|
103
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/.pi-agent/auth.json'))).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('requires --auth for pi, as it does for claude/codex', async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
runInit({ dir, preset: 'pi', provider: 'openrouter', apiKey: 'k', home: fakeHome }),
|
|
109
|
+
).rejects.toThrow(/--auth/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rejects --auth subscription when no pi login exists', async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
runInit({ dir, preset: 'pi', auth: 'subscription', home: fakeHome }),
|
|
115
|
+
).rejects.toThrow(/no pi login/i)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('zooid init --preset pi inherits the operator settings (ZOD075)', () => {
|
|
120
|
+
const seedGlobal = (obj: unknown) => {
|
|
121
|
+
mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
|
|
122
|
+
writeFileSync(join(fakeHome, '.pi', 'agent', 'settings.json'), JSON.stringify(obj))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A pair the operator already runs is verified by use; prefer it to our pin.
|
|
126
|
+
it('prefers the global defaultProvider/defaultModel over the built-in pin', async () => {
|
|
127
|
+
seedGlobal({ defaultProvider: 'anthropic', defaultModel: 'claude-sonnet-5' })
|
|
128
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
129
|
+
const s = JSON.parse(read('agents/zooid-assistant/.pi-agent/settings.json'))
|
|
130
|
+
expect(s).toEqual({ defaultProvider: 'anthropic', defaultModel: 'claude-sonnet-5' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('falls back to the pin when the global settings pin nothing', async () => {
|
|
134
|
+
seedGlobal({ theme: 'dark' })
|
|
135
|
+
await runInit({ dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test', home: fakeHome })
|
|
136
|
+
const s = JSON.parse(read('agents/zooid-assistant/.pi-agent/settings.json'))
|
|
137
|
+
expect(s.defaultProvider).toBe('openrouter')
|
|
138
|
+
expect(s.defaultModel).toBe('deepseek/deepseek-v4-pro')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// An explicit flag always wins over an inherited value.
|
|
142
|
+
it('lets --model override an inherited model', async () => {
|
|
143
|
+
seedGlobal({ defaultProvider: 'openrouter', defaultModel: 'deepseek/deepseek-v4-flash' })
|
|
144
|
+
await runInit({
|
|
145
|
+
dir, preset: 'pi', auth: 'api-key', provider: 'openrouter', apiKey: 'sk-test',
|
|
146
|
+
model: 'deepseek/deepseek-v4-pro', home: fakeHome,
|
|
147
|
+
})
|
|
148
|
+
const s = JSON.parse(read('agents/zooid-assistant/.pi-agent/settings.json'))
|
|
149
|
+
expect(s.defaultModel).toBe('deepseek/deepseek-v4-pro')
|
|
150
|
+
})
|
|
151
|
+
})
|