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/README.md +6 -3
- package/dist/bin.js +157 -14
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-KGYQ5YNP.js → chunk-3Q4BPAZD.js} +23 -2
- package/dist/chunk-3Q4BPAZD.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +8 -8
- package/src/bin.ts +3 -3
- package/src/build-registry.ts +1 -1
- package/src/build-registry.zod044.test.ts +23 -0
- 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/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.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/
|
|
45
|
-
"@zooid/
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/runtime-
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/
|
|
50
|
-
"@zooid/transport-
|
|
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')
|
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[] = []
|
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|