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
|
@@ -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
|
+
})
|
|
@@ -1,30 +1,61 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { findSimplePreset } from './registry.js'
|
|
4
|
+
import { findSimplePreset, PI_AUTH_FILE, PI_SETTINGS_FILE } from './registry.js'
|
|
5
5
|
|
|
6
6
|
export interface SniffResult {
|
|
7
7
|
found: boolean
|
|
8
|
-
/** Absolute path that satisfied the sniff (the config directory). */
|
|
8
|
+
/** Absolute path that satisfied the sniff (the config directory, or file for pi). */
|
|
9
9
|
path?: string
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Checks whether the operator's per-CLI config directory
|
|
14
|
-
* any file inside — only `existsSync
|
|
15
|
-
* macOS Keychain, and OS keyring without per-backend
|
|
16
|
-
* where the dir exists but the operator isn't actually
|
|
17
|
-
* the spawned shim will surface a clearer error on
|
|
13
|
+
* Checks whether the operator's per-CLI config directory (or, for pi, the
|
|
14
|
+
* credential file) exists. NEVER reads any file inside — only `existsSync`.
|
|
15
|
+
* One rule covers Linux, macOS Keychain, and OS keyring without per-backend
|
|
16
|
+
* logic. The edge case where the dir exists but the operator isn't actually
|
|
17
|
+
* logged in is accepted; the spawned shim will surface a clearer error on
|
|
18
|
+
* first turn.
|
|
18
19
|
*
|
|
19
20
|
* `home` is overridable for tests.
|
|
20
21
|
*/
|
|
21
22
|
export function sniffCredentials(
|
|
22
|
-
preset: 'claude' | 'codex',
|
|
23
|
+
preset: 'claude' | 'codex' | 'pi',
|
|
23
24
|
home: string = homedir(),
|
|
24
25
|
): SniffResult {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
26
|
+
// pi's credential is a file (auth.json), not a directory; existsSync covers
|
|
27
|
+
// both, so the only difference is which relative path we join.
|
|
28
|
+
const rel = preset === 'pi' ? PI_AUTH_FILE : findSimplePreset(preset)?.credentialDir
|
|
29
|
+
if (!rel) return { found: false }
|
|
30
|
+
const full = join(home, rel)
|
|
28
31
|
if (existsSync(full)) return { found: true, path: full }
|
|
29
32
|
return { found: false }
|
|
30
33
|
}
|
|
34
|
+
|
|
35
|
+
export interface PiDefaults {
|
|
36
|
+
provider: string
|
|
37
|
+
model: string
|
|
38
|
+
/** Absolute path the pair came from, so the wizard can say where. */
|
|
39
|
+
source: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads the operator's global pi defaults. A pair they already run
|
|
44
|
+
* interactively is verified by use, which beats any pin this repo could guess.
|
|
45
|
+
*
|
|
46
|
+
* Returns undefined unless BOTH keys are present — half a pair cannot boot pi.
|
|
47
|
+
* Never throws: a corrupt personal config must not abort onboarding.
|
|
48
|
+
*/
|
|
49
|
+
export function sniffPiDefaults(home: string = homedir()): PiDefaults | undefined {
|
|
50
|
+
const source = join(home, PI_SETTINGS_FILE)
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(source, 'utf8')) as Record<string, unknown>
|
|
53
|
+
const provider = raw.defaultProvider
|
|
54
|
+
const model = raw.defaultModel
|
|
55
|
+
if (typeof provider !== 'string' || !provider) return undefined
|
|
56
|
+
if (typeof model !== 'string' || !model) return undefined
|
|
57
|
+
return { provider, model, source }
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
2
3
|
import { dirname, join, resolve } from 'node:path'
|
|
3
4
|
import {
|
|
4
5
|
generateAgentsMd,
|
|
@@ -8,22 +9,28 @@ import {
|
|
|
8
9
|
generateGitignore,
|
|
9
10
|
generateOpencodeJson,
|
|
10
11
|
generateOpencodeReadme,
|
|
12
|
+
generatePiSettings,
|
|
11
13
|
generateZooidYaml,
|
|
12
14
|
} from './init/generators.js'
|
|
13
15
|
import {
|
|
14
16
|
findOpencodeProvider,
|
|
17
|
+
findPiProvider,
|
|
15
18
|
findSimplePreset,
|
|
19
|
+
PI_AGENT_DIR,
|
|
20
|
+
PI_AUTH_FILE,
|
|
21
|
+
PI_DEFAULT_MODEL,
|
|
22
|
+
PI_DEFAULT_PROVIDER,
|
|
16
23
|
} from './init/registry.js'
|
|
17
|
-
import { sniffCredentials } from './init/sniff.js'
|
|
24
|
+
import { sniffCredentials, sniffPiDefaults } from './init/sniff.js'
|
|
18
25
|
|
|
19
26
|
export interface InitOptions {
|
|
20
27
|
dir: string
|
|
21
|
-
preset: 'claude' | 'codex' | 'opencode'
|
|
22
|
-
/** Required for claude/codex; ignored for opencode. */
|
|
28
|
+
preset: 'claude' | 'codex' | 'opencode' | 'pi'
|
|
29
|
+
/** Required for claude/codex/pi; ignored for opencode. */
|
|
23
30
|
auth?: 'subscription' | 'api-key'
|
|
24
31
|
/** Optional model pin. Omitted by default — the harness uses its own default. */
|
|
25
32
|
model?: string
|
|
26
|
-
/** opencode provider id; defaults to `opencode-go`
|
|
33
|
+
/** opencode/pi provider id; defaults to `opencode-go` for opencode. */
|
|
27
34
|
provider?: string
|
|
28
35
|
/** Required on api-key path; required for opencode. */
|
|
29
36
|
apiKey?: string
|
|
@@ -31,6 +38,8 @@ export interface InitOptions {
|
|
|
31
38
|
force?: boolean
|
|
32
39
|
/** Required with `force` to overwrite existing files. */
|
|
33
40
|
overwrite?: boolean
|
|
41
|
+
/** Overridable for tests; defaults to os.homedir(). */
|
|
42
|
+
home?: string
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
interface WriteSpec {
|
|
@@ -66,6 +75,7 @@ export async function runInit(opts: InitOptions): Promise<void> {
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
const writes: WriteSpec[] = []
|
|
78
|
+
let piInherited: { provider: string; model: string; source: string } | undefined
|
|
69
79
|
|
|
70
80
|
if (opts.preset === 'claude' || opts.preset === 'codex') {
|
|
71
81
|
if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for claude/codex')
|
|
@@ -123,6 +133,47 @@ export async function runInit(opts: InitOptions): Promise<void> {
|
|
|
123
133
|
content: generateEnv({ envVar: providerMeta!.apiKeyEnvVar, value: opts.apiKey! }),
|
|
124
134
|
})
|
|
125
135
|
}
|
|
136
|
+
} else if (opts.preset === 'pi') {
|
|
137
|
+
// Same binary as claude/codex, because pi has the same two tiers. The
|
|
138
|
+
// difference is that the api-key side also needs a provider.
|
|
139
|
+
if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for pi')
|
|
140
|
+
if (opts.auth === 'subscription' && !sniffCredentials('pi', opts.home).found) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'no pi login found — run `pi` and /login first, or use --auth api-key',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (opts.auth === 'api-key' && !opts.provider) {
|
|
146
|
+
throw new Error('--provider is required for pi --auth api-key')
|
|
147
|
+
}
|
|
148
|
+
const providerMeta = opts.provider ? findPiProvider(opts.provider) : undefined
|
|
149
|
+
if (opts.provider && !providerMeta) throw new Error(`unknown pi provider: ${opts.provider}`)
|
|
150
|
+
|
|
151
|
+
// Inherit before pinning: a pair the operator already runs interactively is
|
|
152
|
+
// verified by use. Explicit flags still win over an inherited value.
|
|
153
|
+
const inherited = sniffPiDefaults(opts.home)
|
|
154
|
+
const provider = opts.provider ?? inherited?.provider ?? PI_DEFAULT_PROVIDER
|
|
155
|
+
const model = opts.model ?? inherited?.model ?? PI_DEFAULT_MODEL
|
|
156
|
+
const settingsProvider = opts.model ? provider : (inherited?.provider ?? provider)
|
|
157
|
+
|
|
158
|
+
writes.push({ path: 'zooid.yaml', content: generateZooidYaml({ preset: 'pi' }) })
|
|
159
|
+
writes.push({ path: 'agents/zooid-assistant/AGENTS.md', content: generateAgentsMd() })
|
|
160
|
+
writes.push({
|
|
161
|
+
path: `agents/zooid-assistant/${PI_AGENT_DIR}/settings.json`,
|
|
162
|
+
content: generatePiSettings({ provider: settingsProvider, model }),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// PI_CODING_AGENT_DIR is relative on purpose: the agent's cwd is
|
|
166
|
+
// agents/<name> locally and /workspace in a container, and those are the
|
|
167
|
+
// same directory, so one value is correct under both runtimes (spike 1.1).
|
|
168
|
+
const envLines = [`PI_CODING_AGENT_DIR=${PI_AGENT_DIR}`]
|
|
169
|
+
// Only the api-key path writes a key. The subscription path shares the
|
|
170
|
+
// operator's auth.json instead (below) and writes no credential here.
|
|
171
|
+
if (opts.auth === 'api-key' && opts.apiKey) {
|
|
172
|
+
envLines.push(`${providerMeta!.apiKeyEnvVar}=${opts.apiKey}`)
|
|
173
|
+
}
|
|
174
|
+
writes.push({ path: '.env', content: envLines.join('\n') + '\n' })
|
|
175
|
+
|
|
176
|
+
piInherited = inherited
|
|
126
177
|
} else {
|
|
127
178
|
throw new Error(`unknown preset: ${String(opts.preset)}`)
|
|
128
179
|
}
|
|
@@ -152,5 +203,40 @@ export async function runInit(opts: InitOptions): Promise<void> {
|
|
|
152
203
|
}
|
|
153
204
|
}
|
|
154
205
|
|
|
206
|
+
if (opts.preset === 'pi') {
|
|
207
|
+
if (piInherited) {
|
|
208
|
+
console.log(
|
|
209
|
+
`✓ Inherited defaultProvider=${piInherited.provider} defaultModel=${piInherited.model} from ${piInherited.source}`,
|
|
210
|
+
)
|
|
211
|
+
} else {
|
|
212
|
+
console.log(
|
|
213
|
+
`✓ Pinned defaultProvider=${PI_DEFAULT_PROVIDER} defaultModel=${PI_DEFAULT_MODEL} (pi's own default returns an empty turn)`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
const s = sniffCredentials('pi', opts.home)
|
|
217
|
+
if (opts.auth === 'subscription' && s.found) {
|
|
218
|
+
// Symlink, not copy: pi rotates OAuth tokens via temp-file-and-rename
|
|
219
|
+
// (spike 1.2), which a symlink survives because it names a path, not an
|
|
220
|
+
// inode. Host-path only — this wizard only ever scaffolds `runtime:
|
|
221
|
+
// local`, so the operator's real ~/.pi/agent/auth.json is the same file
|
|
222
|
+
// the local agent process resolves against.
|
|
223
|
+
const authSource = join(opts.home ?? homedir(), PI_AUTH_FILE)
|
|
224
|
+
const linkPath = join(dir, `agents/zooid-assistant/${PI_AGENT_DIR}/auth.json`)
|
|
225
|
+
if (!existsSync(linkPath)) {
|
|
226
|
+
mkdirSync(dirname(linkPath), { recursive: true })
|
|
227
|
+
symlinkSync(authSource, linkPath)
|
|
228
|
+
}
|
|
229
|
+
console.log(
|
|
230
|
+
`✓ Shared pi login from ${s.path} — the agent uses your subscription via a symlink at ${PI_AGENT_DIR}/auth.json`,
|
|
231
|
+
)
|
|
232
|
+
} else if (s.found) {
|
|
233
|
+
console.log(
|
|
234
|
+
`✓ Found pi login at ${s.path} — the agent gets its own config at ${PI_AGENT_DIR}/ and will not touch it`,
|
|
235
|
+
)
|
|
236
|
+
} else {
|
|
237
|
+
console.warn(`⚠ No pi login detected — the agent uses its own API key from .env`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
155
241
|
console.log('\nNext: zooid dev')
|
|
156
242
|
}
|
|
@@ -54,6 +54,21 @@ describe('collectStatus', () => {
|
|
|
54
54
|
])
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('reports the VAPID public key when vapid.json exists in the data dir', async () => {
|
|
58
|
+
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
59
|
+
writeFileSync(join(dir, 'vapid.json'), JSON.stringify({ publicKey: 'BPk', privateKey: 'priv' }))
|
|
60
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
|
|
61
|
+
const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
|
|
62
|
+
expect(s.vapidPublicKey).toBe('BPk')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('omits the VAPID key when no daemon has run yet', async () => {
|
|
66
|
+
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
67
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
|
|
68
|
+
const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
|
|
69
|
+
expect(s.vapidPublicKey).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
57
72
|
it('reports daemon down when the AS callback port refuses the connection', async () => {
|
|
58
73
|
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
59
74
|
vi.stubGlobal(
|
package/src/commands/status.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
|
|
5
5
|
import { deriveHomeserverShape } from '../bootstrap/derive.js'
|
|
6
|
+
import { VAPID_FILENAME } from '../push-gateway/vapid.js'
|
|
6
7
|
|
|
7
8
|
export interface StatusFlags {
|
|
8
9
|
cwd?: string
|
|
@@ -15,6 +16,24 @@ export interface StatusReport {
|
|
|
15
16
|
tuwunel: { status: 'up' | 'down'; url: string }
|
|
16
17
|
daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
|
|
17
18
|
agents: { name: string; userId: string; trigger: string }[]
|
|
19
|
+
/** VAPID public key read from `<dataDir>/vapid.json`, when it exists. */
|
|
20
|
+
vapidPublicKey?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the daemon's VAPID public key straight off disk. Operators
|
|
25
|
+
* hand-editing a box's config.json need it and can't start a daemon just to
|
|
26
|
+
* ask, so this reads the persisted file rather than starting one.
|
|
27
|
+
*/
|
|
28
|
+
export function readVapidPublicKey(dataDir: string): string | undefined {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(join(dataDir, VAPID_FILENAME), 'utf8')) as {
|
|
31
|
+
publicKey?: string
|
|
32
|
+
}
|
|
33
|
+
return typeof parsed.publicKey === 'string' ? parsed.publicKey : undefined
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
|
|
@@ -29,12 +48,14 @@ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
|
|
|
29
48
|
export async function collectStatus(opts: {
|
|
30
49
|
cwd: string
|
|
31
50
|
tuwunelUrl: string
|
|
51
|
+
dataDir?: string
|
|
32
52
|
}): Promise<StatusReport> {
|
|
33
53
|
const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
|
|
34
54
|
const tuwunel: StatusReport['tuwunel'] = {
|
|
35
55
|
status: tuwunelUp ? 'up' : 'down',
|
|
36
56
|
url: opts.tuwunelUrl,
|
|
37
57
|
}
|
|
58
|
+
const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : undefined
|
|
38
59
|
|
|
39
60
|
const found = findConfigFile(opts.cwd)
|
|
40
61
|
if (!found) {
|
|
@@ -42,6 +63,7 @@ export async function collectStatus(opts: {
|
|
|
42
63
|
tuwunel,
|
|
43
64
|
daemon: { status: 'unknown', reason: 'no zooid.yaml' },
|
|
44
65
|
agents: [],
|
|
66
|
+
...(vapidPublicKey ? { vapidPublicKey } : {}),
|
|
45
67
|
}
|
|
46
68
|
}
|
|
47
69
|
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
|
|
@@ -67,6 +89,7 @@ export async function collectStatus(opts: {
|
|
|
67
89
|
tuwunel,
|
|
68
90
|
daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
|
|
69
91
|
agents,
|
|
92
|
+
...(vapidPublicKey ? { vapidPublicKey } : {}),
|
|
70
93
|
}
|
|
71
94
|
}
|
|
72
95
|
|
|
@@ -89,7 +112,8 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
|
|
|
89
112
|
}
|
|
90
113
|
}
|
|
91
114
|
const tuwunelUrl = `http://localhost:${port ?? 8448}`
|
|
92
|
-
const
|
|
115
|
+
const dataDir = resolve(cwd, flags.dataDir ?? './data')
|
|
116
|
+
const s = await collectStatus({ cwd, tuwunelUrl, dataDir })
|
|
93
117
|
const fmt = (st: 'up' | 'down' | 'unknown'): string =>
|
|
94
118
|
st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
|
|
95
119
|
process.stdout.write(
|
|
@@ -99,6 +123,7 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
|
|
|
99
123
|
...s.agents.map(
|
|
100
124
|
(a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
|
|
101
125
|
),
|
|
126
|
+
...(s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : []),
|
|
102
127
|
'',
|
|
103
128
|
].join('\n'),
|
|
104
129
|
)
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from '@zooid/context-mcp'
|
|
34
34
|
import { buildAcpRegistry } from '../build-registry.js'
|
|
35
35
|
import { prepullImages } from '../prepull-images.js'
|
|
36
|
+
import { mountPushGateway } from '../push-gateway/index.js'
|
|
36
37
|
import { makeSyncCursorStore } from './sync-cursors.js'
|
|
37
38
|
import { shouldBindHttpListener } from './pull-wiring.js'
|
|
38
39
|
|
|
@@ -79,6 +80,8 @@ export interface StartDaemonOpts {
|
|
|
79
80
|
export interface DaemonHandle {
|
|
80
81
|
port: number
|
|
81
82
|
agentNames: string[]
|
|
83
|
+
/** VAPID public key for web push, when the gateway bound (appservice mode with a data dir). */
|
|
84
|
+
vapidPublicKey?: string
|
|
82
85
|
stop(): Promise<void>
|
|
83
86
|
whenStopped: Promise<void>
|
|
84
87
|
}
|
|
@@ -166,6 +169,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
166
169
|
|
|
167
170
|
const matrix = findMatrixTransport(config)
|
|
168
171
|
let port: number
|
|
172
|
+
let vapidPublicKey: string | undefined
|
|
169
173
|
|
|
170
174
|
if (matrix) {
|
|
171
175
|
const mode = matrix.transport.mode ?? 'appservice'
|
|
@@ -232,6 +236,16 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
232
236
|
})
|
|
233
237
|
if (shouldBindHttpListener(mode)) {
|
|
234
238
|
const requestedPort = matrix.transport.port ?? 9000
|
|
239
|
+
// The gateway rides the appservice listener, not webStatic: webStatic
|
|
240
|
+
// exists only under `zooid dev`, and on a deployed box Caddy serves the
|
|
241
|
+
// dist directly with the daemon out of the serving path. This is the
|
|
242
|
+
// one HTTP surface bound in both modes.
|
|
243
|
+
if (dataDir) {
|
|
244
|
+
vapidPublicKey = mountPushGateway(transport.app, {
|
|
245
|
+
dataDir,
|
|
246
|
+
subject: `https://${serverName}`,
|
|
247
|
+
}).publicKey
|
|
248
|
+
}
|
|
235
249
|
// Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
|
|
236
250
|
// macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
|
|
237
251
|
// events back to host.docker.internal:<port>.
|
|
@@ -347,5 +361,5 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
347
361
|
process.on('SIGTERM', () => handler('SIGTERM'))
|
|
348
362
|
}
|
|
349
363
|
|
|
350
|
-
return { port, agentNames, stop, whenStopped }
|
|
364
|
+
return { port, agentNames, vapidPublicKey, stop, whenStopped }
|
|
351
365
|
}
|