zooid 0.11.2 → 0.12.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/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-3Q4BPAZD.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.12.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",
@@ -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/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"
44
+ "@zooid/context-mcp": "^0.12.0",
45
+ "@zooid/acp-client": "^0.12.0",
46
+ "@zooid/core": "^0.12.0",
47
+ "@zooid/runtime-docker": "^0.12.0",
48
+ "@zooid/transport-http": "^0.12.0",
49
+ "@zooid/runtime-local": "^0.12.0",
50
+ "@zooid/transport-matrix": "^0.12.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@agentclientprotocol/sdk": "^0.21.0",
package/src/bin.ts CHANGED
@@ -87,10 +87,10 @@ cli
87
87
 
88
88
  cli
89
89
  .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)')
90
+ .option('--preset <name>', 'claude | codex | opencode | pi')
91
+ .option('--auth <mode>', 'subscription | api-key (claude/codex/pi only)')
92
92
  .option('--model <id>', 'Model identifier')
93
- .option('--provider <id>', 'opencode provider: opencode-go | opencode | anthropic | openrouter | custom')
93
+ .option('--provider <id>', 'opencode provider: opencode-go | opencode | anthropic | openrouter | custom; pi provider: openrouter | anthropic | openai')
94
94
  .option('--api-key <value>', 'API key (api-key path; opencode always)')
95
95
  .option('--force', 'Allow scaffolding into a non-empty directory')
96
96
  .option('--overwrite', 'With --force, overwrite existing files')
@@ -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[] = []
@@ -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
+ })
@@ -1,5 +1,6 @@
1
1
  import { select, password } from '@inquirer/prompts'
2
- import { findOpencodeProvider, findSimplePreset } from './registry.js'
2
+ import { findOpencodeProvider, findPiProvider, findSimplePreset, PI_DEFAULT_PROVIDER } from './registry.js'
3
+ import { sniffCredentials } from './sniff.js'
3
4
  import type { InitOptions } from '../init.js'
4
5
 
5
6
  export interface PromptInput {
@@ -28,9 +29,10 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
28
29
  { name: 'claude (Claude Code)', value: 'claude' as const },
29
30
  { name: 'codex (OpenAI Codex)', value: 'codex' as const },
30
31
  { name: 'opencode', value: 'opencode' as const },
32
+ { name: 'pi', value: 'pi' as const },
31
33
  ],
32
34
  }),
33
- () => '--preset is required (claude | codex | opencode)',
35
+ () => '--preset is required (claude | codex | opencode | pi)',
34
36
  ))
35
37
 
36
38
  if (preset === 'opencode') {
@@ -64,6 +66,55 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
64
66
  }
65
67
  }
66
68
 
69
+ if (preset === 'pi') {
70
+ // Offer, never infer: detecting a login only ever adds a question, it
71
+ // never silently decides the answer.
72
+ const loginFound = sniffCredentials('pi').found
73
+ const auth =
74
+ (flags.auth as 'subscription' | 'api-key') ??
75
+ (loginFound
76
+ ? await ask(
77
+ () =>
78
+ select({
79
+ message: 'A pi login was found. How should the agent authenticate?',
80
+ choices: [
81
+ { name: 'Share my pi login (agent uses my existing subscription)', value: 'subscription' as const },
82
+ { name: "Give the agent its own key (billed and revoked separately)", value: 'api-key' as const },
83
+ ],
84
+ }),
85
+ () => '--auth is required (subscription | api-key)',
86
+ )
87
+ : ('api-key' as const))
88
+
89
+ if (auth === 'subscription') {
90
+ return {
91
+ dir: flags.dir,
92
+ preset,
93
+ auth,
94
+ force: flags.force,
95
+ overwrite: flags.overwrite,
96
+ }
97
+ }
98
+
99
+ const provider = flags.provider ?? PI_DEFAULT_PROVIDER
100
+ const providerMeta = findPiProvider(provider)
101
+ if (!providerMeta) throw new Error(`unknown pi provider: ${provider}`)
102
+ const apiKey = flags.apiKey ?? (await ask(
103
+ () => password({ message: `${providerMeta.label} API key:` }),
104
+ () => '--api-key is required',
105
+ ))
106
+ return {
107
+ dir: flags.dir,
108
+ preset,
109
+ auth,
110
+ provider,
111
+ model: flags.model,
112
+ apiKey,
113
+ force: flags.force,
114
+ overwrite: flags.overwrite,
115
+ }
116
+ }
117
+
67
118
  const meta = findSimplePreset(preset)
68
119
  if (!meta) throw new Error(`unknown preset: ${preset}`)
69
120
  const auth = (flags.auth as 'subscription' | 'api-key') ?? (await ask(
@@ -4,6 +4,14 @@ import {
4
4
  OPENCODE_PROVIDERS,
5
5
  findSimplePreset,
6
6
  findOpencodeProvider,
7
+ PI_PROVIDERS,
8
+ PI_DEFAULT_PROVIDER,
9
+ PI_DEFAULT_MODEL,
10
+ PI_AGENT_DIR,
11
+ PI_AUTH_FILE,
12
+ PI_SETTINGS_FILE,
13
+ PI_AUTH_MODES,
14
+ findPiProvider,
7
15
  } from './registry.js'
8
16
 
9
17
  describe('init registry', () => {
@@ -31,3 +39,55 @@ describe('init registry', () => {
31
39
  expect(findOpencodeProvider('nope')).toBeUndefined()
32
40
  })
33
41
  })
42
+
43
+ describe('pi registry (ZOD075)', () => {
44
+ it('PI_PROVIDERS leads with openrouter, the verified default', () => {
45
+ expect(PI_PROVIDERS[0]?.id).toBe('openrouter')
46
+ })
47
+
48
+ it('every provider carries an API-key env var', () => {
49
+ for (const p of PI_PROVIDERS) {
50
+ expect(p.apiKeyEnvVar).toMatch(/^[A-Z_]+_API_KEY$/)
51
+ expect(p.label.length).toBeGreaterThan(0)
52
+ }
53
+ })
54
+
55
+ // anthropic stays reachable, but must not be the default: pi bills Claude
56
+ // Pro/Max through "extra usage", which starts at zero, so a claude default
57
+ // 400s for exactly the operator most likely to try Zooid.
58
+ it('includes anthropic as a choice but not as the default', () => {
59
+ expect(PI_PROVIDERS.map((p) => p.id)).toContain('anthropic')
60
+ expect(PI_DEFAULT_PROVIDER).not.toBe('anthropic')
61
+ })
62
+
63
+ it('pins the verified fallback provider and model', () => {
64
+ expect(PI_DEFAULT_PROVIDER).toBe('openrouter')
65
+ expect(PI_DEFAULT_MODEL).toBe('deepseek/deepseek-v4-pro')
66
+ })
67
+
68
+ it('findPiProvider returns the entry by id', () => {
69
+ expect(findPiProvider('openrouter')?.id).toBe('openrouter')
70
+ expect(findPiProvider('nope')).toBeUndefined()
71
+ })
72
+
73
+ // pi DOES share the subscription-or-key binary (that is what PI_AUTH_MODES
74
+ // encodes). It stays out of SIMPLE_PRESETS for the other reason: those rows
75
+ // carry ONE apiKeyEnvVar and ONE credentialDir, and pi needs a provider table.
76
+ it('pi is NOT in SIMPLE_PRESETS — those rows cannot hold a provider table', () => {
77
+ expect(SIMPLE_PRESETS.map((p) => p.preset)).not.toContain('pi')
78
+ })
79
+
80
+ it('exposes the agent dir and the home-relative pi paths', () => {
81
+ expect(PI_AGENT_DIR).toBe('.pi-agent')
82
+ expect(PI_AUTH_FILE).toBe('.pi/agent/auth.json')
83
+ expect(PI_SETTINGS_FILE).toBe('.pi/agent/settings.json')
84
+ })
85
+ })
86
+
87
+ describe('pi auth binary (ZOD075)', () => {
88
+ // pi's auth surface is the UNION of the two existing shapes: opencode's
89
+ // provider table plus claude/codex's subscription-or-api-key binary.
90
+ it('accepts the same two auth values as claude/codex', () => {
91
+ expect(PI_AUTH_MODES).toEqual(['subscription', 'api-key'])
92
+ })
93
+ })
@@ -74,3 +74,61 @@ export function findSimplePreset(name: string): SimplePresetMeta | undefined {
74
74
  export function findOpencodeProvider(id: string): OpencodeProviderMeta | undefined {
75
75
  return OPENCODE_PROVIDERS.find((p) => p.id === id)
76
76
  }
77
+
78
+ export interface PiProviderMeta {
79
+ id: string
80
+ label: string
81
+ description: string
82
+ apiKeyEnvVar: string
83
+ }
84
+
85
+ // ZOD075. pi is multi-provider like opencode, but unlike opencode it also has an
86
+ // OAuth subscription tier (Claude Pro/Max, ChatGPT Plus/Pro, xAI, OpenRouter)
87
+ // whose tokens live in ~/.pi/agent/auth.json — which is why pi gets its own
88
+ // branch instead of a SIMPLE_PRESETS row.
89
+ export const PI_PROVIDERS: readonly PiProviderMeta[] = [
90
+ {
91
+ id: 'openrouter',
92
+ label: 'OpenRouter',
93
+ description: 'many providers via OpenRouter credits',
94
+ apiKeyEnvVar: 'OPENROUTER_API_KEY',
95
+ },
96
+ {
97
+ id: 'anthropic',
98
+ label: 'Anthropic',
99
+ description: 'Claude via direct Anthropic API key',
100
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
101
+ },
102
+ {
103
+ id: 'openai',
104
+ label: 'OpenAI',
105
+ description: 'GPT via direct OpenAI API key',
106
+ apiKeyEnvVar: 'OPENAI_API_KEY',
107
+ },
108
+ ]
109
+
110
+ // The ONE pinned model in this wizard, and a deliberate exception to the rule at
111
+ // the top of this file. Every other harness picks its own current default; pi's
112
+ // resolved to a model that returned an empty turn (ZOD073), so pi must be told.
113
+ //
114
+ // Not anthropic: pi bills Claude Pro/Max through "extra usage" (per-token, NOT
115
+ // against the plan), which starts at zero — so a claude default returns
116
+ // `400 "You're out of extra usage"` for the operator most likely to try Zooid,
117
+ // and ACP surfaces that as silence. openrouter/deepseek-v4-pro is the verified
118
+ // fallback; an operator's own global pair is preferred over it (sniffPiDefaults).
119
+ export const PI_DEFAULT_PROVIDER = 'openrouter'
120
+ export const PI_DEFAULT_MODEL = 'deepseek/deepseek-v4-pro'
121
+
122
+ /** Agent dir, relative so one value is right under both local and container runtimes. */
123
+ export const PI_AGENT_DIR = '.pi-agent'
124
+ /** Home-relative paths to the operator's real pi install. */
125
+ export const PI_AUTH_FILE = '.pi/agent/auth.json'
126
+ export const PI_SETTINGS_FILE = '.pi/agent/settings.json'
127
+
128
+ // pi's auth surface is the union of the two existing shapes: opencode's
129
+ // provider table plus claude/codex's subscription-or-api-key binary.
130
+ export const PI_AUTH_MODES = ['subscription', 'api-key'] as const
131
+
132
+ export function findPiProvider(id: string): PiProviderMeta | undefined {
133
+ return PI_PROVIDERS.find((p) => p.id === id)
134
+ }
@@ -1,8 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { tmpdir } from 'node:os'
5
- import { sniffCredentials } from './sniff.js'
5
+ import { sniffCredentials, sniffPiDefaults } from './sniff.js'
6
6
 
7
7
  let fakeHome: string
8
8
 
@@ -39,3 +39,62 @@ describe('sniffCredentials', () => {
39
39
  expect(sniffCredentials('claude', fakeHome).found).toBe(true)
40
40
  })
41
41
  })
42
+
43
+ describe('sniffCredentials — pi (ZOD075)', () => {
44
+ // pi's credential is a FILE, not a directory like ~/.claude — existsSync
45
+ // covers both, but the path must point at auth.json.
46
+ it('reports found when ~/.pi/agent/auth.json exists', () => {
47
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
48
+ writeFileSync(join(fakeHome, '.pi', 'agent', 'auth.json'), '{}')
49
+ const r = sniffCredentials('pi', fakeHome)
50
+ expect(r.found).toBe(true)
51
+ expect(r.path).toMatch(/auth\.json$/)
52
+ })
53
+
54
+ it('reports not-found when ~/.pi is absent', () => {
55
+ expect(sniffCredentials('pi', fakeHome).found).toBe(false)
56
+ })
57
+
58
+ it('reports not-found when ~/.pi/agent exists but holds no auth.json', () => {
59
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
60
+ expect(sniffCredentials('pi', fakeHome).found).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe('sniffPiDefaults (ZOD075)', () => {
65
+ const write = (obj: unknown) => {
66
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
67
+ writeFileSync(join(fakeHome, '.pi', 'agent', 'settings.json'), JSON.stringify(obj))
68
+ }
69
+
70
+ // Inheriting a pair the operator already runs interactively beats any default
71
+ // this repo could guess — it is verified by use.
72
+ it('inherits defaultProvider and defaultModel from the global settings', () => {
73
+ write({ theme: 'dark', defaultProvider: 'openrouter', defaultModel: 'deepseek/deepseek-v4-flash' })
74
+ expect(sniffPiDefaults(fakeHome)).toEqual({
75
+ provider: 'openrouter',
76
+ model: 'deepseek/deepseek-v4-flash',
77
+ source: join(fakeHome, '.pi', 'agent', 'settings.json'),
78
+ })
79
+ })
80
+
81
+ it('returns undefined when no settings file exists', () => {
82
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
83
+ })
84
+
85
+ // A settings file that pins neither is the common case for a casual pi user;
86
+ // half a pair is not inheritable either.
87
+ it('returns undefined when the pair is absent or incomplete', () => {
88
+ write({ theme: 'dark' })
89
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
90
+ write({ defaultProvider: 'openrouter' })
91
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
92
+ })
93
+
94
+ // Never let a corrupt personal config abort someone's onboarding.
95
+ it('returns undefined rather than throwing on malformed JSON', () => {
96
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
97
+ writeFileSync(join(fakeHome, '.pi', 'agent', 'settings.json'), '{not json')
98
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
99
+ })
100
+ })