zooid 0.6.1 → 0.7.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/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1797 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-N5POSZX5.js +31000 -0
- package/dist/chunk-N5POSZX5.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +4 -3790
- package/dist/index.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-BT_v3DKu.js +295 -0
- package/dist/web/assets/index-DJTghnF-.css +1 -0
- package/dist/web/assets/index-JZMMlqDP.js +118066 -0
- package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +42 -22
- package/src/bin.ts +112 -0
- package/src/bootstrap/admin.test.ts +69 -0
- package/src/bootstrap/admin.ts +37 -0
- package/src/bootstrap/configs.test.ts +24 -0
- package/src/bootstrap/configs.ts +64 -0
- package/src/bootstrap/data-layout.test.ts +25 -0
- package/src/bootstrap/data-layout.ts +20 -0
- package/src/bootstrap/derive.test.ts +74 -0
- package/src/bootstrap/derive.ts +40 -0
- package/src/bootstrap/paths.test.ts +22 -0
- package/src/bootstrap/paths.ts +27 -0
- package/src/bootstrap/tokens.test.ts +44 -0
- package/src/bootstrap/tokens.ts +43 -0
- package/src/build-registry.context.test.ts +61 -0
- package/src/build-registry.test.ts +59 -0
- package/src/build-registry.ts +142 -0
- package/src/build-registry.zod043.test.ts +64 -0
- package/src/commands/dev-cascade.test.ts +38 -0
- package/src/commands/dev-cascade.ts +35 -0
- package/src/commands/dev-data-layout.test.ts +55 -0
- package/src/commands/dev.ts +308 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +119 -0
- package/src/commands/init/prompts.ts +123 -0
- package/src/commands/init/registry.test.ts +33 -0
- package/src/commands/init/registry.ts +75 -0
- package/src/commands/init/sniff.test.ts +41 -0
- package/src/commands/init/sniff.ts +30 -0
- package/src/commands/init.test.ts +131 -0
- package/src/commands/init.ts +158 -0
- package/src/commands/logs.test.ts +105 -0
- package/src/commands/logs.ts +108 -0
- package/src/commands/start.ts +28 -0
- package/src/commands/status.test.ts +73 -0
- package/src/commands/status.ts +100 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +243 -0
- package/src/index.ts +2 -0
- package/src/observability/capture-agent.ts +58 -0
- package/src/observability/capture-tuwunel.test.ts +57 -0
- package/src/observability/capture-tuwunel.ts +24 -0
- package/src/observability/file-sink.test.ts +68 -0
- package/src/observability/file-sink.ts +78 -0
- package/src/observability/observability.integration.test.ts +162 -0
- package/src/observability/paths.test.ts +104 -0
- package/src/observability/paths.ts +94 -0
- package/src/services/tuwunel.test.ts +64 -0
- package/src/services/tuwunel.ts +91 -0
- package/src/web/resolve.test.ts +50 -0
- package/src/web/resolve.ts +31 -0
- package/src/web/static.test.ts +51 -0
- package/src/web/static.ts +61 -0
- package/src/web/watch.ts +103 -0
- package/dist/chunk-67ZRMVHO.js +0 -174
- package/dist/chunk-AR456MHY.js +0 -29
- package/dist/chunk-VBGU2NST.js +0 -139
- package/dist/client-4VMFEFDX.js +0 -22
- package/dist/config-2KK5GX42.js +0 -27
- package/dist/template-T5IB4YWC.js +0 -92
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface SimplePresetMeta {
|
|
2
|
+
preset: 'claude' | 'codex'
|
|
3
|
+
models: string[]
|
|
4
|
+
subscriptionLabel: string
|
|
5
|
+
apiKeyEnvVar: string
|
|
6
|
+
apiKeyPromptLabel: string
|
|
7
|
+
/** Home-relative directory checked on the subscription sniff (existence only). */
|
|
8
|
+
credentialDir: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SIMPLE_PRESETS: readonly SimplePresetMeta[] = [
|
|
12
|
+
{
|
|
13
|
+
preset: 'claude',
|
|
14
|
+
models: ['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5'],
|
|
15
|
+
subscriptionLabel: 'Claude subscription (Pro / Max / Team / Enterprise)',
|
|
16
|
+
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
|
|
17
|
+
apiKeyPromptLabel: 'Anthropic',
|
|
18
|
+
credentialDir: '.claude',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
preset: 'codex',
|
|
22
|
+
models: ['gpt-5.5'],
|
|
23
|
+
subscriptionLabel: 'ChatGPT subscription (Plus / Pro / Team)',
|
|
24
|
+
apiKeyEnvVar: 'OPENAI_API_KEY',
|
|
25
|
+
apiKeyPromptLabel: 'OpenAI',
|
|
26
|
+
credentialDir: '.codex',
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export interface OpencodeProviderMeta {
|
|
31
|
+
id: string
|
|
32
|
+
label: string
|
|
33
|
+
description: string
|
|
34
|
+
models: string[]
|
|
35
|
+
apiKeyEnvVar: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const OPENCODE_PROVIDERS: readonly OpencodeProviderMeta[] = [
|
|
39
|
+
{
|
|
40
|
+
id: 'opencode-go',
|
|
41
|
+
label: 'opencode-go',
|
|
42
|
+
description: '$10/mo subscription — Kimi, GLM, MiniMax',
|
|
43
|
+
models: ['kimi-k2.6', 'kimi-k2.5', 'glm-5.1', 'glm-5', 'minimax-m2.7'],
|
|
44
|
+
apiKeyEnvVar: 'OPENCODE_API_KEY',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'opencode',
|
|
48
|
+
label: 'opencode (Zen)',
|
|
49
|
+
description: 'free tier — curated models',
|
|
50
|
+
models: ['kimi-k2.5-free', 'glm-4.7-free', 'minimax-m2.1-free'],
|
|
51
|
+
apiKeyEnvVar: 'OPENCODE_API_KEY',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'anthropic',
|
|
55
|
+
label: 'Anthropic',
|
|
56
|
+
description: 'Claude via direct Anthropic API',
|
|
57
|
+
models: ['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5'],
|
|
58
|
+
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'openrouter',
|
|
62
|
+
label: 'OpenRouter',
|
|
63
|
+
description: 'many providers via OpenRouter',
|
|
64
|
+
models: ['anthropic/claude-sonnet-4-6', 'zhipuai/glm-5', 'moonshot/kimi-k2.6'],
|
|
65
|
+
apiKeyEnvVar: 'OPENROUTER_API_KEY',
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
export function findSimplePreset(name: string): SimplePresetMeta | undefined {
|
|
70
|
+
return SIMPLE_PRESETS.find((p) => p.preset === name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function findOpencodeProvider(id: string): OpencodeProviderMeta | undefined {
|
|
74
|
+
return OPENCODE_PROVIDERS.find((p) => p.id === id)
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { sniffCredentials } from './sniff.js'
|
|
6
|
+
|
|
7
|
+
let fakeHome: string
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
fakeHome = mkdtempSync(join(tmpdir(), 'zooid-sniff-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('sniffCredentials', () => {
|
|
18
|
+
it('reports found when ~/.claude/ exists', () => {
|
|
19
|
+
mkdirSync(join(fakeHome, '.claude'))
|
|
20
|
+
const r = sniffCredentials('claude', fakeHome)
|
|
21
|
+
expect(r.found).toBe(true)
|
|
22
|
+
expect(r.path).toMatch(/\.claude$/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reports found when ~/.codex/ exists', () => {
|
|
26
|
+
mkdirSync(join(fakeHome, '.codex'))
|
|
27
|
+
const r = sniffCredentials('codex', fakeHome)
|
|
28
|
+
expect(r.found).toBe(true)
|
|
29
|
+
expect(r.path).toMatch(/\.codex$/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('reports not-found when no config dir exists', () => {
|
|
33
|
+
const r = sniffCredentials('claude', fakeHome)
|
|
34
|
+
expect(r.found).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not read directory contents — empty dir still counts as found', () => {
|
|
38
|
+
mkdirSync(join(fakeHome, '.claude'))
|
|
39
|
+
expect(sniffCredentials('claude', fakeHome).found).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { findSimplePreset } from './registry.js'
|
|
5
|
+
|
|
6
|
+
export interface SniffResult {
|
|
7
|
+
found: boolean
|
|
8
|
+
/** Absolute path that satisfied the sniff (the config directory). */
|
|
9
|
+
path?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* `home` is overridable for tests.
|
|
20
|
+
*/
|
|
21
|
+
export function sniffCredentials(
|
|
22
|
+
preset: 'claude' | 'codex',
|
|
23
|
+
home: string = homedir(),
|
|
24
|
+
): SniffResult {
|
|
25
|
+
const meta = findSimplePreset(preset)
|
|
26
|
+
if (!meta) return { found: false }
|
|
27
|
+
const full = join(home, meta.credentialDir)
|
|
28
|
+
if (existsSync(full)) return { found: true, path: full }
|
|
29
|
+
return { found: false }
|
|
30
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { parse as parseYaml } from 'yaml'
|
|
6
|
+
import { runInit } from './init.js'
|
|
7
|
+
|
|
8
|
+
let dir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(join(tmpdir(), 'zooid-init-test-'))
|
|
12
|
+
})
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(dir, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function loadYaml(path: string): unknown {
|
|
18
|
+
return parseYaml(readFileSync(path, 'utf8'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('runInit — claude subscription path', () => {
|
|
22
|
+
it('writes yaml + AGENTS.md + .claude/settings.json + .gitignore (no .env, no opencode.json)', async () => {
|
|
23
|
+
await runInit({
|
|
24
|
+
dir,
|
|
25
|
+
preset: 'claude',
|
|
26
|
+
auth: 'subscription',
|
|
27
|
+
model: 'claude-sonnet-4-6',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(existsSync(join(dir, 'zooid.yaml'))).toBe(true)
|
|
31
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/AGENTS.md'))).toBe(true)
|
|
32
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/CLAUDE.md'))).toBe(true)
|
|
33
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/.claude/settings.json'))).toBe(true)
|
|
34
|
+
expect(existsSync(join(dir, '.gitignore'))).toBe(true)
|
|
35
|
+
expect(existsSync(join(dir, '.env'))).toBe(false)
|
|
36
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/opencode.json'))).toBe(false)
|
|
37
|
+
|
|
38
|
+
const cfg = loadYaml(join(dir, 'zooid.yaml')) as {
|
|
39
|
+
agents: { 'zooid-assistant': { acp: { preset: string; model: string } } }
|
|
40
|
+
}
|
|
41
|
+
expect(cfg.agents['zooid-assistant'].acp).toEqual({
|
|
42
|
+
preset: 'claude',
|
|
43
|
+
model: 'claude-sonnet-4-6',
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('runInit — claude api-key path', () => {
|
|
49
|
+
it('also writes .env with the ANTHROPIC_API_KEY line', async () => {
|
|
50
|
+
await runInit({
|
|
51
|
+
dir,
|
|
52
|
+
preset: 'claude',
|
|
53
|
+
auth: 'api-key',
|
|
54
|
+
model: 'claude-sonnet-4-6',
|
|
55
|
+
apiKey: 'sk-ant-xyz',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(readFileSync(join(dir, '.env'), 'utf8').trim()).toBe('ANTHROPIC_API_KEY=sk-ant-xyz')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('runInit — opencode opencode-go path', () => {
|
|
63
|
+
it('writes opencode.json with opencode-go provider + env-interpolated apiKey', async () => {
|
|
64
|
+
await runInit({
|
|
65
|
+
dir,
|
|
66
|
+
preset: 'opencode',
|
|
67
|
+
provider: 'opencode-go',
|
|
68
|
+
model: 'kimi-k2.6',
|
|
69
|
+
apiKey: 'sk-opencode-zzz',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/.claude'))).toBe(false)
|
|
73
|
+
const oc = JSON.parse(
|
|
74
|
+
readFileSync(join(dir, 'agents/zooid-assistant/opencode.json'), 'utf8'),
|
|
75
|
+
)
|
|
76
|
+
expect(oc.model).toBe('opencode-go/kimi-k2.6')
|
|
77
|
+
expect(oc.provider['opencode-go'].options.apiKey).toBe('{env:OPENCODE_API_KEY}')
|
|
78
|
+
expect(oc.permission.webfetch).toBe('allow')
|
|
79
|
+
|
|
80
|
+
expect(readFileSync(join(dir, '.env'), 'utf8').trim()).toBe('OPENCODE_API_KEY=sk-opencode-zzz')
|
|
81
|
+
|
|
82
|
+
const yamlCfg = loadYaml(join(dir, 'zooid.yaml')) as {
|
|
83
|
+
agents: { 'zooid-assistant': { acp: unknown } }
|
|
84
|
+
}
|
|
85
|
+
expect(yamlCfg.agents['zooid-assistant'].acp).toEqual({ preset: 'opencode' })
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('runInit — idempotency', () => {
|
|
90
|
+
it('errors on non-empty dir without --force', async () => {
|
|
91
|
+
writeFileSync(join(dir, 'preexisting.txt'), 'x')
|
|
92
|
+
await expect(
|
|
93
|
+
runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-sonnet-4-6' }),
|
|
94
|
+
).rejects.toThrow(/non-empty/i)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('ignores package-manager files (node_modules, package.json, lockfiles, .git)', async () => {
|
|
98
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"x"}')
|
|
99
|
+
writeFileSync(join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 9')
|
|
100
|
+
require('node:fs').mkdirSync(join(dir, 'node_modules'))
|
|
101
|
+
require('node:fs').mkdirSync(join(dir, '.git'))
|
|
102
|
+
await runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-sonnet-4-6' })
|
|
103
|
+
expect(existsSync(join(dir, 'zooid.yaml'))).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('preserves existing files under --force (additive create only)', async () => {
|
|
107
|
+
writeFileSync(join(dir, 'zooid.yaml'), 'preexisting: true')
|
|
108
|
+
await runInit({
|
|
109
|
+
dir,
|
|
110
|
+
preset: 'claude',
|
|
111
|
+
auth: 'subscription',
|
|
112
|
+
model: 'claude-sonnet-4-6',
|
|
113
|
+
force: true,
|
|
114
|
+
})
|
|
115
|
+
expect(readFileSync(join(dir, 'zooid.yaml'), 'utf8')).toContain('preexisting: true')
|
|
116
|
+
expect(existsSync(join(dir, 'agents/zooid-assistant/AGENTS.md'))).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('overwrites under --force --overwrite', async () => {
|
|
120
|
+
writeFileSync(join(dir, 'zooid.yaml'), 'preexisting: true')
|
|
121
|
+
await runInit({
|
|
122
|
+
dir,
|
|
123
|
+
preset: 'claude',
|
|
124
|
+
auth: 'subscription',
|
|
125
|
+
model: 'claude-sonnet-4-6',
|
|
126
|
+
force: true,
|
|
127
|
+
overwrite: true,
|
|
128
|
+
})
|
|
129
|
+
expect(readFileSync(join(dir, 'zooid.yaml'), 'utf8')).not.toContain('preexisting: true')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
generateAgentsMd,
|
|
5
|
+
generateClaudeMd,
|
|
6
|
+
generateClaudeSettings,
|
|
7
|
+
generateEnv,
|
|
8
|
+
generateGitignore,
|
|
9
|
+
generateOpencodeJson,
|
|
10
|
+
generateOpencodeReadme,
|
|
11
|
+
generateZooidYaml,
|
|
12
|
+
} from './init/generators.js'
|
|
13
|
+
import {
|
|
14
|
+
findOpencodeProvider,
|
|
15
|
+
findSimplePreset,
|
|
16
|
+
} from './init/registry.js'
|
|
17
|
+
import { sniffCredentials } from './init/sniff.js'
|
|
18
|
+
|
|
19
|
+
export interface InitOptions {
|
|
20
|
+
dir: string
|
|
21
|
+
preset: 'claude' | 'codex' | 'opencode'
|
|
22
|
+
/** Required for claude/codex; ignored for opencode. */
|
|
23
|
+
auth?: 'subscription' | 'api-key'
|
|
24
|
+
/** Required for claude/codex; required for opencode (paired with `provider`). */
|
|
25
|
+
model?: string
|
|
26
|
+
/** Required for opencode. */
|
|
27
|
+
provider?: string
|
|
28
|
+
/** Required on api-key path; required for opencode. */
|
|
29
|
+
apiKey?: string
|
|
30
|
+
/** Allow generating into a non-empty dir. */
|
|
31
|
+
force?: boolean
|
|
32
|
+
/** Required with `force` to overwrite existing files. */
|
|
33
|
+
overwrite?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface WriteSpec {
|
|
37
|
+
path: string
|
|
38
|
+
content: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Pre-existing entries that don't count toward "non-empty". Lets operators
|
|
42
|
+
// run `pnpm install` before `zooid init` without tripping the guard.
|
|
43
|
+
const IGNORED_PREEXISTING = new Set([
|
|
44
|
+
'.',
|
|
45
|
+
'..',
|
|
46
|
+
'.git',
|
|
47
|
+
'.DS_Store',
|
|
48
|
+
'node_modules',
|
|
49
|
+
'package.json',
|
|
50
|
+
'package-lock.json',
|
|
51
|
+
'pnpm-lock.yaml',
|
|
52
|
+
'yarn.lock',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
export async function runInit(opts: InitOptions): Promise<void> {
|
|
56
|
+
const dir = resolve(opts.dir)
|
|
57
|
+
mkdirSync(dir, { recursive: true })
|
|
58
|
+
|
|
59
|
+
if (!opts.force) {
|
|
60
|
+
const blocking = readdirSync(dir).filter((n) => !IGNORED_PREEXISTING.has(n))
|
|
61
|
+
if (blocking.length > 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${dir} is non-empty (use --force to allow scaffolding into it). Conflicting entries: ${blocking.join(', ')}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const writes: WriteSpec[] = []
|
|
69
|
+
|
|
70
|
+
if (opts.preset === 'claude' || opts.preset === 'codex') {
|
|
71
|
+
if (!opts.model) throw new Error(`--model is required for preset ${opts.preset}`)
|
|
72
|
+
if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for claude/codex')
|
|
73
|
+
const meta = findSimplePreset(opts.preset)!
|
|
74
|
+
|
|
75
|
+
writes.push({
|
|
76
|
+
path: 'zooid.yaml',
|
|
77
|
+
content: generateZooidYaml({ preset: opts.preset, model: opts.model }),
|
|
78
|
+
})
|
|
79
|
+
writes.push({ path: 'agents/zooid-assistant/AGENTS.md', content: generateAgentsMd() })
|
|
80
|
+
|
|
81
|
+
if (opts.preset === 'claude') {
|
|
82
|
+
writes.push({
|
|
83
|
+
path: 'agents/zooid-assistant/CLAUDE.md',
|
|
84
|
+
content: generateClaudeMd(),
|
|
85
|
+
})
|
|
86
|
+
writes.push({
|
|
87
|
+
path: 'agents/zooid-assistant/.claude/settings.json',
|
|
88
|
+
content: generateClaudeSettings(),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (opts.auth === 'api-key') {
|
|
93
|
+
if (!opts.apiKey) throw new Error('--api-key is required when --auth=api-key')
|
|
94
|
+
writes.push({
|
|
95
|
+
path: '.env',
|
|
96
|
+
content: generateEnv({ envVar: meta.apiKeyEnvVar, value: opts.apiKey }),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
} else if (opts.preset === 'opencode') {
|
|
100
|
+
if (!opts.provider) throw new Error('--provider is required for opencode')
|
|
101
|
+
const isCustom = opts.provider === 'custom'
|
|
102
|
+
const providerMeta = isCustom ? undefined : findOpencodeProvider(opts.provider)
|
|
103
|
+
if (!isCustom && !providerMeta) throw new Error(`unknown opencode provider: ${opts.provider}`)
|
|
104
|
+
if (!isCustom && !opts.model) throw new Error('--model is required (paired with --provider)')
|
|
105
|
+
if (!isCustom && !opts.apiKey) throw new Error('--api-key is required for opencode')
|
|
106
|
+
|
|
107
|
+
writes.push({ path: 'zooid.yaml', content: generateZooidYaml({ preset: 'opencode' }) })
|
|
108
|
+
writes.push({ path: 'agents/zooid-assistant/AGENTS.md', content: generateAgentsMd() })
|
|
109
|
+
writes.push({
|
|
110
|
+
path: 'agents/zooid-assistant/opencode.json',
|
|
111
|
+
content: generateOpencodeJson({
|
|
112
|
+
provider: opts.provider,
|
|
113
|
+
model: opts.model,
|
|
114
|
+
apiKeyEnvVar: providerMeta?.apiKeyEnvVar,
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
if (isCustom) {
|
|
118
|
+
writes.push({
|
|
119
|
+
path: 'agents/zooid-assistant/opencode.json.README',
|
|
120
|
+
content: generateOpencodeReadme(),
|
|
121
|
+
})
|
|
122
|
+
} else {
|
|
123
|
+
writes.push({
|
|
124
|
+
path: '.env',
|
|
125
|
+
content: generateEnv({ envVar: providerMeta!.apiKeyEnvVar, value: opts.apiKey! }),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
throw new Error(`unknown preset: ${String(opts.preset)}`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
writes.push({ path: '.gitignore', content: generateGitignore() })
|
|
133
|
+
|
|
134
|
+
for (const w of writes) {
|
|
135
|
+
const full = join(dir, w.path)
|
|
136
|
+
const exists = existsSync(full)
|
|
137
|
+
if (exists && !opts.overwrite) {
|
|
138
|
+
console.warn(`⚠ ${w.path} exists; left as-is (use --force --overwrite to replace)`)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
142
|
+
writeFileSync(full, w.content)
|
|
143
|
+
console.log(`✓ Created ${w.path}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if ((opts.preset === 'claude' || opts.preset === 'codex') && opts.auth === 'subscription') {
|
|
147
|
+
const s = sniffCredentials(opts.preset)
|
|
148
|
+
if (s.found) {
|
|
149
|
+
console.log(`✓ Found ${opts.preset} config at ${s.path}`)
|
|
150
|
+
} else {
|
|
151
|
+
console.warn(
|
|
152
|
+
`⚠ No ${opts.preset} config detected — run \`${opts.preset}\` to log in before \`zooid dev\``,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log('\nNext: zooid dev')
|
|
158
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, readdir } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { rmSync } from 'node:fs'
|
|
6
|
+
import { runLogs, type LogsFlags } from './logs.js'
|
|
7
|
+
|
|
8
|
+
async function fixtureDay(
|
|
9
|
+
dataDir: string,
|
|
10
|
+
day: string,
|
|
11
|
+
files: Record<string, string>,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const dir = join(dataDir, 'logs', day)
|
|
14
|
+
await mkdir(dir, { recursive: true })
|
|
15
|
+
for (const [name, content] of Object.entries(files)) {
|
|
16
|
+
await writeFile(join(dir, name), content)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('runLogs', () => {
|
|
21
|
+
let dataDir: string
|
|
22
|
+
let stdout: string[]
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
dataDir = await mkdtemp(join(tmpdir(), 'zooid-obs-cmd-'))
|
|
25
|
+
stdout = []
|
|
26
|
+
})
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function run(flags: Partial<LogsFlags> = {}): Promise<void> {
|
|
32
|
+
return runLogs({
|
|
33
|
+
dataDir,
|
|
34
|
+
writer: (s) => {
|
|
35
|
+
stdout.push(s)
|
|
36
|
+
},
|
|
37
|
+
now: new Date('2026-05-06T10:00:00Z'),
|
|
38
|
+
...flags,
|
|
39
|
+
} as LogsFlags)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it('lists known sources for the requested day', async () => {
|
|
43
|
+
await fixtureDay(dataDir, '2026-05-06', {
|
|
44
|
+
'tuwunel.log': 'tw line\n',
|
|
45
|
+
'daemon.log': '{"msg":"d"}\n',
|
|
46
|
+
'agent-docs.log': 'docs stderr\n',
|
|
47
|
+
'agent-docs.acp.jsonl': '{"kind":"turn_started","agent":"docs","turn_id":"t1"}\n',
|
|
48
|
+
})
|
|
49
|
+
await symlink('2026-05-06', join(dataDir, 'logs', 'today'))
|
|
50
|
+
await run({ source: undefined })
|
|
51
|
+
const out = stdout.join('')
|
|
52
|
+
expect(out).toContain('tuwunel')
|
|
53
|
+
expect(out).toContain('daemon')
|
|
54
|
+
expect(out).toContain('agent-docs')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reads a single source from today by default', async () => {
|
|
58
|
+
await fixtureDay(dataDir, '2026-05-06', { 'tuwunel.log': 'hello tuwunel\n' })
|
|
59
|
+
await symlink('2026-05-06', join(dataDir, 'logs', 'today'))
|
|
60
|
+
await run({ source: 'tuwunel' })
|
|
61
|
+
expect(stdout.join('')).toContain('hello tuwunel')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('--day overrides the default (today)', async () => {
|
|
65
|
+
await fixtureDay(dataDir, '2026-05-04', { 'tuwunel.log': 'old line\n' })
|
|
66
|
+
await fixtureDay(dataDir, '2026-05-06', { 'tuwunel.log': 'new line\n' })
|
|
67
|
+
await symlink('2026-05-06', join(dataDir, 'logs', 'today'))
|
|
68
|
+
await run({ source: 'tuwunel', day: '2026-05-04' })
|
|
69
|
+
const out = stdout.join('')
|
|
70
|
+
expect(out).toContain('old line')
|
|
71
|
+
expect(out).not.toContain('new line')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('--turn filters JSONL events across all agent taps for that day', async () => {
|
|
75
|
+
await fixtureDay(dataDir, '2026-05-06', {
|
|
76
|
+
'agent-docs.acp.jsonl':
|
|
77
|
+
[
|
|
78
|
+
'{"kind":"turn_started","agent":"docs","turn_id":"t1"}',
|
|
79
|
+
'{"kind":"session_update","agent":"docs","turn_id":"t1","notification":{"sessionUpdate":"tool_call"}}',
|
|
80
|
+
'{"kind":"turn_completed","agent":"docs","turn_id":"t1","stop_reason":"end_turn"}',
|
|
81
|
+
'{"kind":"turn_started","agent":"docs","turn_id":"t2"}',
|
|
82
|
+
].join('\n') + '\n',
|
|
83
|
+
'agent-echo.acp.jsonl': '{"kind":"turn_started","agent":"echo","turn_id":"t1"}\n',
|
|
84
|
+
})
|
|
85
|
+
await symlink('2026-05-06', join(dataDir, 'logs', 'today'))
|
|
86
|
+
await run({ turn: 't1' })
|
|
87
|
+
const out = stdout.join('')
|
|
88
|
+
expect(out).toContain('"turn_id":"t1"')
|
|
89
|
+
expect(out).toContain('"agent":"docs"')
|
|
90
|
+
expect(out).toContain('"agent":"echo"')
|
|
91
|
+
expect(out).not.toContain('"turn_id":"t2"')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('prune removes folders older than --keep, leaves today', async () => {
|
|
95
|
+
await fixtureDay(dataDir, '2026-04-20', { 'x.log': '' })
|
|
96
|
+
await fixtureDay(dataDir, '2026-05-01', { 'x.log': '' })
|
|
97
|
+
await fixtureDay(dataDir, '2026-05-06', { 'x.log': '' })
|
|
98
|
+
await symlink('2026-05-06', join(dataDir, 'logs', 'today'))
|
|
99
|
+
await run({ subcommand: 'prune', keep: 3 })
|
|
100
|
+
const remaining = (await readdir(join(dataDir, 'logs')))
|
|
101
|
+
.filter((n) => n !== 'today')
|
|
102
|
+
.sort()
|
|
103
|
+
expect(remaining).toEqual(['2026-05-06'])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFile, readdir, readlink } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { pruneOldDays } from '../observability/paths.js'
|
|
5
|
+
|
|
6
|
+
export interface LogsFlags {
|
|
7
|
+
dataDir: string
|
|
8
|
+
source?: string
|
|
9
|
+
day?: string
|
|
10
|
+
turn?: string
|
|
11
|
+
follow?: boolean
|
|
12
|
+
subcommand?: 'prune'
|
|
13
|
+
keep?: number
|
|
14
|
+
/** Tests inject an alternate writer. */
|
|
15
|
+
writer?: (s: string) => void
|
|
16
|
+
now?: Date
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const KNOWN_SOURCES = ['tuwunel', 'daemon', 'dev'] as const
|
|
20
|
+
|
|
21
|
+
export async function runLogs(flags: LogsFlags): Promise<void> {
|
|
22
|
+
const writer = flags.writer ?? ((s: string) => process.stdout.write(s))
|
|
23
|
+
|
|
24
|
+
if (flags.subcommand === 'prune') {
|
|
25
|
+
const removed = await pruneOldDays({
|
|
26
|
+
dataDir: flags.dataDir,
|
|
27
|
+
retainDays: flags.keep ?? 14,
|
|
28
|
+
now: flags.now,
|
|
29
|
+
})
|
|
30
|
+
writer(`pruned ${removed.length} day(s): ${removed.join(', ')}\n`)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const day = flags.day ?? (await resolveTodaySlug(flags.dataDir))
|
|
35
|
+
if (!day) {
|
|
36
|
+
writer('no logs yet\n')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const dayDir = join(flags.dataDir, 'logs', day)
|
|
40
|
+
if (!existsSync(dayDir)) {
|
|
41
|
+
writer(`no logs for ${day}\n`)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (flags.turn) {
|
|
46
|
+
await dumpByTurn(dayDir, flags.turn, writer)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!flags.source) {
|
|
51
|
+
const entries = await readdir(dayDir)
|
|
52
|
+
const sources = new Set<string>()
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
if (e.endsWith('.log')) sources.add(e.replace(/\.log$/, ''))
|
|
55
|
+
if (e.endsWith('.acp.jsonl')) sources.add(e.replace(/\.acp\.jsonl$/, '.acp'))
|
|
56
|
+
}
|
|
57
|
+
writer([...sources].sort().join('\n') + '\n')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const path = resolveSourcePath(dayDir, flags.source)
|
|
62
|
+
if (!existsSync(path)) {
|
|
63
|
+
writer(`no such source: ${flags.source}\n`)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
writer(await readFile(path, 'utf8'))
|
|
67
|
+
// -f is intentionally not implemented in this cycle; documented as a follow-up.
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveTodaySlug(dataDir: string): Promise<string | null> {
|
|
71
|
+
const link = join(dataDir, 'logs', 'today')
|
|
72
|
+
try {
|
|
73
|
+
return await readlink(link)
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveSourcePath(dayDir: string, source: string): string {
|
|
80
|
+
if (source.startsWith('agent-')) {
|
|
81
|
+
if (source.endsWith('.acp')) return join(dayDir, `${source.slice(0, -4)}.acp.jsonl`)
|
|
82
|
+
return join(dayDir, `${source}.log`)
|
|
83
|
+
}
|
|
84
|
+
if ((KNOWN_SOURCES as readonly string[]).includes(source))
|
|
85
|
+
return join(dayDir, `${source}.log`)
|
|
86
|
+
return join(dayDir, source)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function dumpByTurn(
|
|
90
|
+
dayDir: string,
|
|
91
|
+
turnId: string,
|
|
92
|
+
writer: (s: string) => void,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const entries = await readdir(dayDir)
|
|
95
|
+
const taps = entries.filter((e) => e.endsWith('.acp.jsonl')).sort()
|
|
96
|
+
for (const f of taps) {
|
|
97
|
+
const text = await readFile(join(dayDir, f), 'utf8')
|
|
98
|
+
for (const line of text.split('\n')) {
|
|
99
|
+
if (!line) continue
|
|
100
|
+
try {
|
|
101
|
+
const obj = JSON.parse(line)
|
|
102
|
+
if (obj.turn_id === turnId) writer(line + '\n')
|
|
103
|
+
} catch {
|
|
104
|
+
// skip malformed line
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { CliFlags } from '@zooid/core'
|
|
4
|
+
import { resolveDataLayout } from '../bootstrap/data-layout.js'
|
|
5
|
+
import { startDaemon } from '../daemon/start-daemon.js'
|
|
6
|
+
|
|
7
|
+
export interface StartFlags extends CliFlags {
|
|
8
|
+
printToken?: boolean
|
|
9
|
+
/** Data root dir; defaults to `./data` relative to cwd. */
|
|
10
|
+
dataDir?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runStart(flags: StartFlags): Promise<void> {
|
|
14
|
+
if (flags.printToken) {
|
|
15
|
+
process.stdout.write(`${randomBytes(32).toString('hex')}\n`)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
const dataRoot = resolve(process.cwd(), flags.dataDir ?? './data')
|
|
19
|
+
const layout = resolveDataLayout(dataRoot)
|
|
20
|
+
const handle = await startDaemon({
|
|
21
|
+
cliFlags: flags,
|
|
22
|
+
installSignalHandlers: true,
|
|
23
|
+
agentsDir: layout.agentsDir,
|
|
24
|
+
})
|
|
25
|
+
console.log(`zooid listening on http://localhost:${handle.port}`)
|
|
26
|
+
for (const name of handle.agentNames) console.log(` agent: ${name}`)
|
|
27
|
+
await handle.whenStopped
|
|
28
|
+
}
|