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.
@@ -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 exists. NEVER reads
14
- * any file inside — only `existsSync` on the dir. One rule covers Linux,
15
- * macOS Keychain, and OS keyring without per-backend logic. The edge case
16
- * where the dir exists but the operator isn't actually logged in is accepted;
17
- * the spawned shim will surface a clearer error on first turn.
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
- const meta = findSimplePreset(preset)
26
- if (!meta) return { found: false }
27
- const full = join(home, meta.credentialDir)
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
+ }
@@ -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` in the wizard. */
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
  }