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.
Files changed (40) hide show
  1. package/README.md +8 -3
  2. package/dist/bin.js +503 -124
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-KGYQ5YNP.js → chunk-YZ4IO5MR.js} +78 -11
  5. package/dist/chunk-YZ4IO5MR.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/package.json +11 -9
  8. package/src/bin.test.ts +63 -0
  9. package/src/bin.ts +55 -6
  10. package/src/bootstrap/configs.test.ts +7 -0
  11. package/src/bootstrap/configs.ts +14 -0
  12. package/src/build-registry.ts +1 -1
  13. package/src/build-registry.zod044.test.ts +23 -0
  14. package/src/commands/dev.ts +37 -7
  15. package/src/commands/init/generators.test.ts +43 -0
  16. package/src/commands/init/generators.ts +24 -5
  17. package/src/commands/init/pi-scaffold.test.ts +151 -0
  18. package/src/commands/init/prompts.ts +53 -2
  19. package/src/commands/init/registry.test.ts +60 -0
  20. package/src/commands/init/registry.ts +58 -0
  21. package/src/commands/init/sniff.test.ts +61 -2
  22. package/src/commands/init/sniff.ts +43 -12
  23. package/src/commands/init.ts +91 -5
  24. package/src/commands/status.test.ts +15 -0
  25. package/src/commands/status.ts +27 -2
  26. package/src/daemon/start-daemon.ts +15 -1
  27. package/src/push-gateway/gateway.test.ts +150 -0
  28. package/src/push-gateway/gateway.ts +78 -0
  29. package/src/push-gateway/index.ts +17 -0
  30. package/src/push-gateway/payload.test.ts +88 -0
  31. package/src/push-gateway/payload.ts +39 -0
  32. package/src/push-gateway/types.ts +37 -0
  33. package/src/push-gateway/vapid.test.ts +45 -0
  34. package/src/push-gateway/vapid.ts +34 -0
  35. package/src/services/tuwunel.ts +21 -2
  36. package/src/version.test.ts +67 -0
  37. package/src/version.ts +30 -0
  38. package/src/web/static.test.ts +22 -0
  39. package/src/web/static.ts +9 -1
  40. package/dist/chunk-KGYQ5YNP.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-KGYQ5YNP.js";
4
+ } from "./chunk-YZ4IO5MR.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.11.2",
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.10.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.11.2",
45
- "@zooid/core": "^0.11.2",
46
- "@zooid/runtime-docker": "^0.11.2",
47
- "@zooid/runtime-local": "^0.11.2",
48
- "@zooid/context-mcp": "^0.11.2",
49
- "@zooid/transport-matrix": "^0.11.2",
50
- "@zooid/transport-http": "^0.11.2"
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",
@@ -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.help()
114
- cli.version('0.0.1')
115
- cli.parse()
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', () => {
@@ -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
  }
@@ -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[] = []
@@ -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({ webRoot, homeserverUrl: homeserver })
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
- const handler = async (): Promise<void> => {
291
- process.stdout.write(chalk.dim('\nStopping…\n'))
292
- await shutdown()
293
- process.exit(0)
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', () => void handler())
296
- process.on('SIGTERM', () => void handler())
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
- * reads its own opencode.json.
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
- return ['.env', 'node_modules/', 'data/', ''].join('\n')
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
+ })