zooid 0.6.0 → 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.
Files changed (78) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1797 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-N5POSZX5.js +31000 -0
  6. package/dist/chunk-N5POSZX5.js.map +1 -0
  7. package/dist/index.d.ts +40 -0
  8. package/dist/index.js +4 -3788
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-BT_v3DKu.js +295 -0
  14. package/dist/web/assets/index-DJTghnF-.css +1 -0
  15. package/dist/web/assets/index-JZMMlqDP.js +118066 -0
  16. package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +42 -22
  20. package/src/bin.ts +112 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +142 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/commands/dev-cascade.test.ts +38 -0
  38. package/src/commands/dev-cascade.ts +35 -0
  39. package/src/commands/dev-data-layout.test.ts +55 -0
  40. package/src/commands/dev.ts +308 -0
  41. package/src/commands/init/generators.test.ts +97 -0
  42. package/src/commands/init/generators.ts +119 -0
  43. package/src/commands/init/prompts.ts +123 -0
  44. package/src/commands/init/registry.test.ts +33 -0
  45. package/src/commands/init/registry.ts +75 -0
  46. package/src/commands/init/sniff.test.ts +41 -0
  47. package/src/commands/init/sniff.ts +30 -0
  48. package/src/commands/init.test.ts +131 -0
  49. package/src/commands/init.ts +158 -0
  50. package/src/commands/logs.test.ts +105 -0
  51. package/src/commands/logs.ts +108 -0
  52. package/src/commands/start.ts +28 -0
  53. package/src/commands/status.test.ts +73 -0
  54. package/src/commands/status.ts +100 -0
  55. package/src/daemon/start-daemon.test.ts +67 -0
  56. package/src/daemon/start-daemon.ts +243 -0
  57. package/src/index.ts +2 -0
  58. package/src/observability/capture-agent.ts +58 -0
  59. package/src/observability/capture-tuwunel.test.ts +57 -0
  60. package/src/observability/capture-tuwunel.ts +24 -0
  61. package/src/observability/file-sink.test.ts +68 -0
  62. package/src/observability/file-sink.ts +78 -0
  63. package/src/observability/observability.integration.test.ts +162 -0
  64. package/src/observability/paths.test.ts +104 -0
  65. package/src/observability/paths.ts +94 -0
  66. package/src/services/tuwunel.test.ts +64 -0
  67. package/src/services/tuwunel.ts +91 -0
  68. package/src/web/resolve.test.ts +50 -0
  69. package/src/web/resolve.ts +31 -0
  70. package/src/web/static.test.ts +51 -0
  71. package/src/web/static.ts +61 -0
  72. package/src/web/watch.ts +103 -0
  73. package/dist/chunk-67ZRMVHO.js +0 -174
  74. package/dist/chunk-AR456MHY.js +0 -29
  75. package/dist/chunk-VBGU2NST.js +0 -139
  76. package/dist/client-4VMFEFDX.js +0 -22
  77. package/dist/config-2KK5GX42.js +0 -27
  78. 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
+ }