typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,786 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname, join, relative, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { config, configSchema, type Config } from '@/config'
7
+ import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
8
+ import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
9
+ import { createTui } from '@/tui'
10
+
11
+ import { buildDockerfile, DOCKERFILE } from './dockerfile'
12
+ import { buildGitignore, GITIGNORE_FILE } from './gitignore'
13
+ import { HATCHING_PROMPT } from './hatching'
14
+ import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
15
+ import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
16
+ import { runBunInstall, type InstallResult } from './run-bun-install'
17
+
18
+ export { runBunInstall, type InstallResult } from './run-bun-install'
19
+
20
+ export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
21
+
22
+ const CONFIG_FILE = 'typeclaw.json'
23
+ const CRON_FILE = 'cron.json'
24
+ const SECRETS_FILE = '.env'
25
+ const PACKAGE_FILE = 'package.json'
26
+
27
+ const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
28
+
29
+ // `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
30
+ // Reusable systems the agent builds — including custom plugins wired into
31
+ // typeclaw.json — live there as standalone packages, while one-off scripts
32
+ // stay in `workspace/`. The directory is scaffolded empty so the layout is
33
+ // discoverable on day one; a `.gitkeep` is written below so it survives the
34
+ // initial commit.
35
+ const DIRECTORIES = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
36
+
37
+ export type GitInitResult = { ok: true; skipped: boolean } | { ok: false; reason: string }
38
+ export type DockerAssetsResult = { ok: true; devMode: boolean } | { ok: false; reason: string }
39
+ export type HatchingResult = { ok: true } | { ok: false; reason: string }
40
+
41
+ export type InitStep =
42
+ | 'preflight'
43
+ | 'oauth-login'
44
+ | 'scaffold'
45
+ | 'kakaotalk-auth'
46
+ | 'install'
47
+ | 'dockerfile'
48
+ | 'git'
49
+ | 'hatching'
50
+
51
+ export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
52
+
53
+ export type InitStepEvent =
54
+ | { step: 'preflight'; phase: 'start' }
55
+ | { step: 'preflight'; phase: 'done'; result: DockerAvailability }
56
+ | { step: 'oauth-login'; phase: 'start' }
57
+ | { step: 'oauth-login'; phase: 'done'; result: OAuthLoginResult }
58
+ | { step: 'scaffold'; phase: 'start' }
59
+ | { step: 'scaffold'; phase: 'done' }
60
+ | { step: 'kakaotalk-auth'; phase: 'start' }
61
+ | { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
62
+ | { step: 'install'; phase: 'start' }
63
+ | { step: 'install'; phase: 'done'; result: InstallResult }
64
+ | { step: 'dockerfile'; phase: 'start' }
65
+ | { step: 'dockerfile'; phase: 'done'; result: DockerAssetsResult }
66
+ | { step: 'git'; phase: 'start' }
67
+ | { step: 'git'; phase: 'done'; result: GitInitResult }
68
+ | { step: 'hatching'; phase: 'start' }
69
+ | { step: 'hatching'; phase: 'done'; result: HatchingResult }
70
+
71
+ export type HatchRunner = (options: { cwd: string; port: number }) => Promise<HatchingResult>
72
+
73
+ export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
74
+
75
+ // Discriminated by `kind` so the type system enforces "you can't pass an
76
+ // API key to an OAuth provider, and you can't pass an OAuth runner to an
77
+ // API-key provider". Optional model defaults to DEFAULT_MODEL_REF, which is
78
+ // an OpenAI api-key provider — so test fixtures that omit both fields keep
79
+ // working under the api-key path.
80
+ export type LLMAuth = { kind: 'api-key'; apiKey: string } | { kind: 'oauth'; runLogin: OAuthLoginRunner }
81
+
82
+ export type InitOptions = {
83
+ cwd: string
84
+ // Selected `provider/model` ref written into typeclaw.json. Defaults to
85
+ // DEFAULT_MODEL_REF when callers (or older test fixtures) omit it.
86
+ model?: KnownModelRef
87
+ // How the agent will authenticate to the LLM provider. When omitted,
88
+ // defaults to the api-key path with `apiKey` (legacy field, still
89
+ // supported for backwards compat with the old `runInit` signature).
90
+ llmAuth?: LLMAuth
91
+ apiKey?: string
92
+ discordBotToken?: string
93
+ discordAllowAll?: boolean
94
+ slackBotToken?: string
95
+ slackAppToken?: string
96
+ slackAllowAll?: boolean
97
+ telegramBotToken?: string
98
+ telegramAllowAll?: boolean
99
+ withKakaotalk?: boolean
100
+ kakaotalkAllowAll?: boolean
101
+ runKakaotalkAuth?: KakaotalkAuthRunner
102
+ onProgress?: (event: InitStepEvent) => void
103
+ runHatching?: HatchRunner
104
+ dockerExec?: DockerExec
105
+ }
106
+
107
+ export async function runInit({
108
+ cwd,
109
+ apiKey,
110
+ llmAuth,
111
+ model = DEFAULT_MODEL_REF,
112
+ discordBotToken,
113
+ discordAllowAll = true,
114
+ slackBotToken,
115
+ slackAppToken,
116
+ slackAllowAll = true,
117
+ telegramBotToken,
118
+ telegramAllowAll = true,
119
+ withKakaotalk = false,
120
+ kakaotalkAllowAll = false,
121
+ runKakaotalkAuth,
122
+ onProgress,
123
+ runHatching = defaultRunHatching,
124
+ dockerExec,
125
+ }: InitOptions): Promise<void> {
126
+ const emit = onProgress ?? (() => {})
127
+
128
+ // Docker preflight runs BEFORE any scaffolding so a missing-binary or
129
+ // daemon-down failure leaves the user's directory untouched. Without this
130
+ // gate, init would lay the egg, write the Dockerfile, init git, and then
131
+ // fail at hatching with a raw "Executable not found in $PATH: docker" —
132
+ // leaving a half-initialized agent folder the user has to clean up by hand.
133
+ emit({ step: 'preflight', phase: 'start' })
134
+ const preflight = await checkDockerAvailable(dockerExec)
135
+ emit({ step: 'preflight', phase: 'done', result: preflight })
136
+ if (!preflight.ok) return
137
+
138
+ // Resolve the auth contract: explicit `llmAuth` wins; otherwise, fall back
139
+ // to the legacy `apiKey` field (api-key path). Throwing here instead of
140
+ // proceeding with bad data prevents writing a half-initialized agent
141
+ // folder for a doomed config.
142
+ const resolvedAuth = resolveLLMAuth(llmAuth, apiKey)
143
+
144
+ // OAuth login runs BEFORE scaffold so a failed/aborted browser flow leaves
145
+ // the user's directory untouched (same rationale as the docker preflight).
146
+ // Same trap as kakaotalk-auth: scaffold-then-fail-auth would leave
147
+ // typeclaw.json without working credentials and the runtime would silently
148
+ // refuse to boot. The login itself doesn't need the agent folder to exist
149
+ // — pi-ai's OAuth helper just needs a writable path for secrets.json, which
150
+ // we create on demand inside scaffold().
151
+ if (resolvedAuth.kind === 'oauth') {
152
+ emit({ step: 'oauth-login', phase: 'start' })
153
+ await mkdir(cwd, { recursive: true })
154
+ const result = await resolvedAuth.runLogin({ cwd, model })
155
+ emit({ step: 'oauth-login', phase: 'done', result })
156
+ if (!result.ok) {
157
+ throw new Error(`OAuth login failed: ${result.reason}`)
158
+ }
159
+ }
160
+
161
+ const wantsDiscord = discordBotToken !== undefined && discordBotToken !== ''
162
+ const wantsSlack = slackBotToken !== undefined && slackBotToken !== ''
163
+ const wantsTelegram = telegramBotToken !== undefined && telegramBotToken !== ''
164
+ emit({ step: 'scaffold', phase: 'start' })
165
+ await scaffold(cwd, {
166
+ model,
167
+ withDiscord: wantsDiscord,
168
+ discordAllowAll,
169
+ withSlack: wantsSlack,
170
+ slackAllowAll,
171
+ withTelegram: wantsTelegram,
172
+ telegramAllowAll,
173
+ withKakaotalk,
174
+ kakaotalkAllowAll,
175
+ })
176
+ // Only write the LLM API key on the api-key path. OAuth providers persist
177
+ // their credentials to secrets.json (via the OAuth login step above); writing
178
+ // an empty FIREWORKS_API_KEY/OPENAI_API_KEY would just confuse users.
179
+ await writeSecrets(cwd, {
180
+ model,
181
+ apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
182
+ discordBotToken,
183
+ slackBotToken,
184
+ slackAppToken,
185
+ telegramBotToken,
186
+ })
187
+ emit({ step: 'scaffold', phase: 'done' })
188
+
189
+ if (withKakaotalk && runKakaotalkAuth !== undefined) {
190
+ emit({ step: 'kakaotalk-auth', phase: 'start' })
191
+ const result = await runKakaotalkAuth({ cwd })
192
+ emit({ step: 'kakaotalk-auth', phase: 'done', result })
193
+ if (!result.ok) {
194
+ // Abort the rest of the pipeline. Continuing would leave the agent
195
+ // folder with `channels.kakaotalk` in typeclaw.json but no credentials
196
+ // file, which `typeclaw start` later treats as "missing credentials,
197
+ // skip adapter" — confusing the user about whether KakaoTalk works.
198
+ // The user can re-run `typeclaw init` after fixing the auth issue;
199
+ // the scaffold/Dockerfile work above is idempotent.
200
+ throw new Error(`KakaoTalk authentication failed: ${result.reason}`)
201
+ }
202
+ }
203
+
204
+ emit({ step: 'install', phase: 'start' })
205
+ const install = await runBunInstall(cwd)
206
+ emit({ step: 'install', phase: 'done', result: install })
207
+
208
+ emit({ step: 'dockerfile', phase: 'start' })
209
+ const docker = await writeDockerAssets(cwd)
210
+ emit({ step: 'dockerfile', phase: 'done', result: docker })
211
+
212
+ emit({ step: 'git', phase: 'start' })
213
+ const git = await initGitRepo(cwd)
214
+ emit({ step: 'git', phase: 'done', result: git })
215
+
216
+ emit({ step: 'hatching', phase: 'start' })
217
+ const hatching = await runHatching({ cwd, port: config.port })
218
+ emit({ step: 'hatching', phase: 'done', result: hatching })
219
+ }
220
+
221
+ async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }): Promise<HatchingResult> {
222
+ try {
223
+ const launch = await start({ cwd, preferredHostPort: port })
224
+ if (!launch.ok) return { ok: false, reason: launch.reason }
225
+
226
+ // start() may have allocated a different host port (the preferred one was
227
+ // bound). Use the actually-published port for the TUI handshake instead of
228
+ // the preferred port, otherwise we'd connect to the wrong service.
229
+ const hostPort = launch.hostPort
230
+
231
+ await waitForAgent(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
232
+
233
+ const tui = createTui({
234
+ url: `ws://localhost:${hostPort}`,
235
+ initialPrompt: HATCHING_PROMPT,
236
+ })
237
+ await tui.run()
238
+ return { ok: true }
239
+ } catch (error) {
240
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
241
+ }
242
+ }
243
+
244
+ // Probe the server's plain HTTP fallback (non-upgrade requests get a 200 with
245
+ // body "typeclaw agent") instead of opening a WebSocket. Opening a WS here
246
+ // would trigger createSession on the server and burn an LLM session just to
247
+ // learn the port is up.
248
+ async function waitForAgent(httpUrl: string, { timeoutMs }: { timeoutMs: number }): Promise<void> {
249
+ const deadline = Date.now() + timeoutMs
250
+ let lastError: unknown
251
+ while (Date.now() < deadline) {
252
+ try {
253
+ const res = await fetch(httpUrl)
254
+ if (res.status === 200) return
255
+ lastError = new Error(`unexpected status ${res.status}`)
256
+ } catch (error) {
257
+ lastError = error
258
+ }
259
+ await new Promise((r) => setTimeout(r, 250))
260
+ }
261
+ throw new Error(`timed out waiting for agent at ${httpUrl}: ${lastError instanceof Error ? lastError.message : ''}`)
262
+ }
263
+
264
+ export function isDirectoryNonEmpty(dir: string): boolean {
265
+ try {
266
+ return readdirSync(dir).some((entry) => !entry.startsWith('.'))
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ export function isInitialized(dir: string): boolean {
273
+ return existsSync(join(dir, CONFIG_FILE))
274
+ }
275
+
276
+ // Walks upward from `start` looking for the agent folder (the dir containing
277
+ // typeclaw.json). Returns the found dir, or null if nothing is found before
278
+ // the walk hits a stop boundary.
279
+ //
280
+ // Stop boundaries (whichever comes first, checked at every level):
281
+ // 1. The current dir contains typeclaw.json — return it.
282
+ // 2. The current dir contains .git — return null. A .git boundary marks a
283
+ // project root; refusing to cross it prevents accidentally picking up an
284
+ // unrelated parent project, and matches how typeclaw itself initializes
285
+ // one .git per agent folder.
286
+ // 3. We've reached the filesystem root — return null.
287
+ //
288
+ // The `.git` check fires AFTER the typeclaw.json check at the same level so
289
+ // that walking up from a subdir of the agent (e.g. `<agent>/workspace/`) still
290
+ // resolves to the agent root, even though the agent root itself contains both
291
+ // typeclaw.json and .git.
292
+ export function findAgentDir(start: string): string | null {
293
+ let dir = resolve(start)
294
+ const root = resolve(dir, '/')
295
+ while (true) {
296
+ if (existsSync(join(dir, CONFIG_FILE))) return dir
297
+ if (existsSync(join(dir, '.git'))) return null
298
+ if (dir === root) return null
299
+ const parent = dirname(dir)
300
+ if (parent === dir) return null
301
+ dir = parent
302
+ }
303
+ }
304
+
305
+ const HATCHED_COMMIT_SUBJECT = 'Hatched 🐣'
306
+
307
+ export async function isHatched(dir: string): Promise<boolean> {
308
+ if (!existsSync(join(dir, '.git'))) return false
309
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
310
+ if (!bun) return false
311
+ try {
312
+ const proc = bun.spawn({ cmd: ['git', 'log', '--format=%s'], cwd: dir, stdout: 'pipe', stderr: 'pipe' })
313
+ if ((await proc.exited) !== 0) return false
314
+ const subjects = (await new Response(proc.stdout).text()).split('\n')
315
+ return subjects.includes(HATCHED_COMMIT_SUBJECT)
316
+ } catch {
317
+ return false
318
+ }
319
+ }
320
+
321
+ export type ScaffoldOptions = {
322
+ model?: KnownModelRef
323
+ withDiscord?: boolean
324
+ discordAllowAll?: boolean
325
+ withSlack?: boolean
326
+ slackAllowAll?: boolean
327
+ withTelegram?: boolean
328
+ telegramAllowAll?: boolean
329
+ withKakaotalk?: boolean
330
+ kakaotalkAllowAll?: boolean
331
+ }
332
+
333
+ export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
334
+ await Promise.all(DIRECTORIES.map((dir) => mkdir(join(root, dir), { recursive: true })))
335
+
336
+ // git does not track empty directories, so without this file the `packages/`
337
+ // workspace root would silently disappear from the initial commit and confuse
338
+ // the agent (its workspaces glob would resolve to nothing). The other
339
+ // DIRECTORIES are either gitignored (workspace, sessions, mounts) or
340
+ // immediately populated, so packages/ is the only one that needs this.
341
+ await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
342
+
343
+ // Only fields without sensible defaults elsewhere are emitted. `mounts`
344
+ // defaults to `[]` in configSchema, and the bundled memory plugin owns its
345
+ // own defaults in src/bundled-plugins/memory/index.ts — re-emitting either here would
346
+ // be duplicate noise the user has to maintain in sync with the source of
347
+ // truth.
348
+ const config: Record<string, unknown> = {
349
+ $schema: './node_modules/typeclaw/typeclaw.schema.json',
350
+ model: options.model ?? DEFAULT_MODEL_REF,
351
+ }
352
+ const channels: Record<string, { allow: string[] }> = {}
353
+ if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
354
+ if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
355
+ if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
356
+ if (options.withKakaotalk) {
357
+ // KakaoTalk involves a personal account, so we default to a tighter
358
+ // allow list (DMs only) than Slack/Discord/Telegram which scope to a
359
+ // workspace the user explicitly admitted the bot into. The user can
360
+ // broaden to `kakao:*` later by editing typeclaw.json.
361
+ channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
362
+ }
363
+ if (Object.keys(channels).length > 0) config.channels = channels
364
+ await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
365
+
366
+ const cron = {
367
+ $schema: './node_modules/typeclaw/cron.schema.json',
368
+ jobs: [],
369
+ }
370
+ await writeFile(join(root, CRON_FILE), `${JSON.stringify(cron, null, 2)}\n`, { flag: 'wx' }).catch(ignoreExists)
371
+
372
+ const pkg = buildPackageJson(root, basename(root))
373
+ await writeFile(join(root, PACKAGE_FILE), `${JSON.stringify(pkg, null, 2)}\n`, { flag: 'wx' }).catch(ignoreExists)
374
+
375
+ await Promise.all(MARKDOWN_FILES.map((file) => writeFile(join(root, file), '', { flag: 'wx' }).catch(ignoreExists)))
376
+
377
+ await writeFile(join(root, GITIGNORE_FILE), buildGitignore(), { flag: 'wx' }).catch(ignoreExists)
378
+ }
379
+
380
+ // agent-browser ships in every agent: the bundled SKILL.md (src/skills/
381
+ // agent-browser/SKILL.md) is a discovery stub that calls `agent-browser
382
+ // skills get core` at runtime, so the CLI must be installed for the skill
383
+ // to function. The Dockerfile pre-downloads Chromium too, so the agent
384
+ // can drive a browser without any first-run setup.
385
+ const AGENT_BROWSER_VERSION = '^0.26.0'
386
+
387
+ function buildPackageJson(root: string, name: string): Record<string, unknown> {
388
+ const typeclawRoot = findTypeclawRoot()
389
+ // FIXME: temporary dev-stage wiring. Switch to a published version range
390
+ // (e.g. "typeclaw": "^x.y.z") once typeclaw is released. The `file:` spec is
391
+ // computed relative to the agent root because `file:` resolves relative to
392
+ // the consuming package.
393
+ const fileSpec = typeclawRoot ? `file:${toFileSpec(relative(root, typeclawRoot))}` : 'file:../typeclaw'
394
+ return {
395
+ name,
396
+ private: true,
397
+ type: 'module',
398
+ workspaces: [`${PACKAGES_DIR}/*`],
399
+ dependencies: {
400
+ typeclaw: fileSpec,
401
+ 'agent-browser': AGENT_BROWSER_VERSION,
402
+ },
403
+ }
404
+ }
405
+
406
+ function toFileSpec(rel: string): string {
407
+ if (rel === '') return '.'
408
+ // bun/npm accept POSIX-style paths in file: specifiers; normalize separators.
409
+ return rel.split(/[\\/]/).join('/')
410
+ }
411
+
412
+ function findTypeclawRoot(): string | null {
413
+ try {
414
+ let dir = dirname(fileURLToPath(import.meta.url))
415
+ const root = resolve('/')
416
+ while (dir !== root) {
417
+ const pkgPath = join(dir, 'package.json')
418
+ if (existsSync(pkgPath)) {
419
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string }
420
+ if (pkg.name === 'typeclaw') return dir
421
+ }
422
+ dir = dirname(dir)
423
+ }
424
+ } catch {}
425
+ return null
426
+ }
427
+
428
+ export async function writeDockerAssets(root: string): Promise<DockerAssetsResult> {
429
+ try {
430
+ const pkg = await readPackageJson(root)
431
+ const typeclawSpec = pkg.dependencies?.typeclaw ?? ''
432
+ const devMode = typeclawSpec.startsWith('file:')
433
+
434
+ const typeclawConfig = await readTypeclawConfig(root)
435
+ await writeFile(join(root, DOCKERFILE), buildDockerfile(typeclawConfig.dockerfile), { flag: 'wx' }).catch(
436
+ ignoreExists,
437
+ )
438
+
439
+ return { ok: true, devMode }
440
+ } catch (error) {
441
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
442
+ }
443
+ }
444
+
445
+ async function readPackageJson(root: string): Promise<{ name?: string; dependencies?: Record<string, string> }> {
446
+ const raw = await readFile(join(root, PACKAGE_FILE), 'utf8')
447
+ return JSON.parse(raw) as { name?: string; dependencies?: Record<string, string> }
448
+ }
449
+
450
+ async function readTypeclawConfig(root: string): Promise<Config> {
451
+ try {
452
+ const raw = await readFile(join(root, CONFIG_FILE), 'utf8')
453
+ return configSchema.parse(JSON.parse(raw))
454
+ } catch (error) {
455
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return configSchema.parse({})
456
+ throw error
457
+ }
458
+ }
459
+
460
+ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
461
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
462
+ if (!bun) return { ok: false, reason: 'bun runtime not available' }
463
+
464
+ if (existsSync(join(cwd, '.git'))) return { ok: true, skipped: true }
465
+
466
+ // Author the initial commit as TypeClaw itself. The agent is still unnamed
467
+ // (IDENTITY.md is empty and hatching hasn't run), so the agent identity will
468
+ // take over from the hatching commit onward. This also avoids depending on
469
+ // the user's global `user.name`/`user.email`.
470
+ const env = {
471
+ ...process.env,
472
+ GIT_AUTHOR_NAME: 'TypeClaw',
473
+ GIT_AUTHOR_EMAIL: 'hello@typeclaw.dev',
474
+ GIT_COMMITTER_NAME: 'TypeClaw',
475
+ GIT_COMMITTER_EMAIL: 'hello@typeclaw.dev',
476
+ }
477
+
478
+ try {
479
+ const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
480
+ if ((await init.exited) !== 0) {
481
+ const stderr = await new Response(init.stderr).text()
482
+ return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
483
+ }
484
+
485
+ const add = bun.spawn({ cmd: ['git', 'add', '.'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
486
+ if ((await add.exited) !== 0) {
487
+ const stderr = await new Response(add.stderr).text()
488
+ return { ok: false, reason: `git add failed: ${stderr.trim() || 'no stderr'}` }
489
+ }
490
+
491
+ const commit = bun.spawn({
492
+ cmd: ['git', 'commit', '-m', 'Initial commit 🥚'],
493
+ cwd,
494
+ env,
495
+ stdout: 'pipe',
496
+ stderr: 'pipe',
497
+ })
498
+ if ((await commit.exited) !== 0) {
499
+ const stderr = await new Response(commit.stderr).text()
500
+ return { ok: false, reason: `git commit failed: ${stderr.trim() || 'no stderr'}` }
501
+ }
502
+
503
+ return { ok: true, skipped: false }
504
+ } catch (error) {
505
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
506
+ }
507
+ }
508
+
509
+ // Writes the LLM provider's API key (under its provider-specific env var,
510
+ // e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) plus any channel adapter tokens.
511
+ // The provider env var is resolved from KNOWN_PROVIDERS via the model ref,
512
+ // so adding a new provider only requires touching providers.ts.
513
+ export async function writeSecrets(
514
+ root: string,
515
+ {
516
+ model = DEFAULT_MODEL_REF,
517
+ apiKey,
518
+ discordBotToken,
519
+ slackBotToken,
520
+ slackAppToken,
521
+ telegramBotToken,
522
+ }: {
523
+ model?: KnownModelRef
524
+ // Omitted on the OAuth path — credentials live in secrets.json instead. The
525
+ // .env file still gets written for any channel adapter tokens.
526
+ apiKey?: string
527
+ discordBotToken?: string
528
+ slackBotToken?: string
529
+ slackAppToken?: string
530
+ telegramBotToken?: string
531
+ },
532
+ ): Promise<void> {
533
+ const providerId = providerForModelRef(model)
534
+ const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
535
+ const lines: string[] = []
536
+ if (apiKey !== undefined && apiKeyEnv !== null) {
537
+ lines.push(`${apiKeyEnv}=${apiKey}`)
538
+ }
539
+ if (discordBotToken !== undefined && discordBotToken !== '') {
540
+ lines.push(`DISCORD_BOT_TOKEN=${discordBotToken}`)
541
+ }
542
+ if (slackBotToken !== undefined && slackBotToken !== '') {
543
+ lines.push(`SLACK_BOT_TOKEN=${slackBotToken}`)
544
+ }
545
+ if (slackAppToken !== undefined && slackAppToken !== '') {
546
+ lines.push(`SLACK_APP_TOKEN=${slackAppToken}`)
547
+ }
548
+ if (telegramBotToken !== undefined && telegramBotToken !== '') {
549
+ lines.push(`TELEGRAM_BOT_TOKEN=${telegramBotToken}`)
550
+ }
551
+ // Always write .env even when empty so existing callers that read it
552
+ // post-init (channel `add`, runtime startup) don't ENOENT-crash.
553
+ const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
554
+ await writeFile(join(root, SECRETS_FILE), body)
555
+ }
556
+
557
+ function resolveLLMAuth(llmAuth: LLMAuth | undefined, apiKey: string | undefined): LLMAuth {
558
+ if (llmAuth) return llmAuth
559
+ if (apiKey !== undefined) return { kind: 'api-key', apiKey }
560
+ throw new Error('runInit requires either `llmAuth` or `apiKey`')
561
+ }
562
+
563
+ function ignoreExists(error: NodeJS.ErrnoException): void {
564
+ if (error.code !== 'EEXIST') throw error
565
+ }
566
+
567
+ // ----------------------------------------------------------------------------
568
+ // `typeclaw channel add`
569
+ //
570
+ // `runAddChannel` is the post-init counterpart to `runInit`'s channel-related
571
+ // steps. It is intentionally a separate pipeline rather than a mode switch on
572
+ // `runInit` because the two have opposite file semantics:
573
+ //
574
+ // - `runInit` creates a fresh agent folder. Writes overwrite by design
575
+ // (typeclaw.json, .env), and idempotency comes from `wx`-flag guards on
576
+ // never-rewritten files (markdown stubs, cron.json, package.json).
577
+ //
578
+ // - `runAddChannel` mutates an already-initialized agent folder. It MUST
579
+ // preserve the user's existing channel config and existing .env values.
580
+ // The only writes are an additive merge of one new channel adapter and
581
+ // an append of that adapter's env vars.
582
+ //
583
+ // Sharing one function would pile mode flags on every helper and turn the
584
+ // "is this overwrite or merge?" question into a runtime branch the test
585
+ // suite would have to cover for both behaviors. The mass of independent
586
+ // scaffold-test cases above demonstrates how easy it is to lose a single
587
+ // behavior under a mode flag.
588
+
589
+ export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk'
590
+
591
+ // Public adapter names match the typeclaw.json `channels.*` keys exactly.
592
+ // The CLI takes these as the optional positional arg, the picker shows
593
+ // these labels, and they're the keys we use to detect "already configured"
594
+ // when reading typeclaw.json.
595
+ export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = ['slack-bot', 'discord-bot', 'telegram-bot', 'kakaotalk']
596
+
597
+ export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets'
598
+
599
+ export type AddChannelStepEvent =
600
+ | { step: 'config'; phase: 'start' }
601
+ | { step: 'config'; phase: 'done' }
602
+ | { step: 'kakaotalk-auth'; phase: 'start' }
603
+ | { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
604
+ | { step: 'secrets'; phase: 'start' }
605
+ | { step: 'secrets'; phase: 'done' }
606
+
607
+ // Discriminated union per channel so the type system enforces "you must pass
608
+ // the right credentials for the channel you're adding". The CLI builds these
609
+ // from prompts; tests build them inline.
610
+ export type AddChannelOptions = {
611
+ cwd: string
612
+ allowAll?: boolean
613
+ onProgress?: (event: AddChannelStepEvent) => void
614
+ } & (
615
+ | { channel: 'discord-bot'; discordBotToken: string }
616
+ | { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
617
+ | { channel: 'telegram-bot'; telegramBotToken: string }
618
+ | { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
619
+ )
620
+
621
+ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
622
+ const emit = options.onProgress ?? (() => {})
623
+
624
+ // Order: kakaotalk-auth (if applicable) -> config -> secrets.
625
+ //
626
+ // We run KakaoTalk auth FIRST so a failed login leaves typeclaw.json and
627
+ // .env untouched. The runtime treats `channels.kakaotalk` without a
628
+ // credentials file as "missing credentials, skip adapter", which silently
629
+ // drops messages — the same trap `runInit` already guards against. Aborting
630
+ // before any file write means the user's next `typeclaw channel add
631
+ // kakaotalk` retry has no half-applied state to clean up.
632
+ if (options.channel === 'kakaotalk') {
633
+ emit({ step: 'kakaotalk-auth', phase: 'start' })
634
+ const result = await options.runKakaotalkAuth({ cwd: options.cwd })
635
+ emit({ step: 'kakaotalk-auth', phase: 'done', result })
636
+ if (!result.ok) throw new Error(`KakaoTalk authentication failed: ${result.reason}`)
637
+ }
638
+
639
+ emit({ step: 'config', phase: 'start' })
640
+ await mergeChannelIntoConfig(options.cwd, options.channel, options.allowAll ?? defaultAllowAll(options.channel))
641
+ emit({ step: 'config', phase: 'done' })
642
+
643
+ emit({ step: 'secrets', phase: 'start' })
644
+ await appendChannelSecrets(options.cwd, channelSecretsFromOptions(options))
645
+ emit({ step: 'secrets', phase: 'done' })
646
+ }
647
+
648
+ // `channel add` mirrors `runInit`'s allow defaults: workspace-scoped adapters
649
+ // (discord/slack/telegram) default to `*` because the bot only sees what the
650
+ // operator invited it into, while KakaoTalk uses a personal account and
651
+ // defaults to DMs only.
652
+ function defaultAllowAll(channel: ChannelKind): boolean {
653
+ return channel !== 'kakaotalk'
654
+ }
655
+
656
+ function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
657
+ switch (options.channel) {
658
+ case 'discord-bot':
659
+ return { DISCORD_BOT_TOKEN: options.discordBotToken }
660
+ case 'slack-bot':
661
+ return { SLACK_BOT_TOKEN: options.slackBotToken, SLACK_APP_TOKEN: options.slackAppToken }
662
+ case 'telegram-bot':
663
+ return { TELEGRAM_BOT_TOKEN: options.telegramBotToken }
664
+ case 'kakaotalk':
665
+ // Credentials live in workspace/.agent-messenger/, not .env.
666
+ return {}
667
+ }
668
+ }
669
+
670
+ type ChannelSecrets = Record<string, string>
671
+
672
+ // Returns the set of channel keys already present in typeclaw.json. Used by
673
+ // the CLI's picker to hide already-configured adapters and to reject explicit
674
+ // re-adds with a clear error rather than silently merging.
675
+ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKind>> {
676
+ const path = join(cwd, CONFIG_FILE)
677
+ let raw: string
678
+ try {
679
+ raw = await readFile(path, 'utf8')
680
+ } catch (error) {
681
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Set()
682
+ throw error
683
+ }
684
+ const parsed = JSON.parse(raw) as { channels?: Record<string, unknown> }
685
+ const channels = parsed.channels ?? {}
686
+ const present = new Set<ChannelKind>()
687
+ for (const kind of CHANNEL_KINDS) {
688
+ if (kind in channels) present.add(kind)
689
+ }
690
+ return present
691
+ }
692
+
693
+ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAll: boolean): Promise<void> {
694
+ const path = join(cwd, CONFIG_FILE)
695
+ let parsed: Record<string, unknown>
696
+ try {
697
+ const raw = await readFile(path, 'utf8')
698
+ parsed = JSON.parse(raw) as Record<string, unknown>
699
+ } catch (error) {
700
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
701
+ throw new Error(
702
+ `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
703
+ )
704
+ }
705
+ throw error
706
+ }
707
+
708
+ const existingChannels =
709
+ typeof parsed.channels === 'object' && parsed.channels !== null && !Array.isArray(parsed.channels)
710
+ ? (parsed.channels as Record<string, unknown>)
711
+ : {}
712
+
713
+ if (channel in existingChannels) {
714
+ // Defense in depth — the CLI already filters configured channels out of
715
+ // the picker and rejects them as the positional arg. Hitting this branch
716
+ // means a programmatic caller passed a duplicate; better to fail loudly
717
+ // than silently overwrite the user's existing allow list.
718
+ throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
719
+ }
720
+
721
+ parsed.channels = {
722
+ ...existingChannels,
723
+ [channel]: { allow: buildAllow(channel, allowAll) },
724
+ }
725
+
726
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
727
+ }
728
+
729
+ function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
730
+ if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
731
+ return allowAll ? ['*'] : []
732
+ }
733
+
734
+ // Appends only keys that are not already present in .env. We never rewrite
735
+ // existing values: if the user has `SLACK_BOT_TOKEN=` left over from a manual
736
+ // edit, we surface that as a hard error rather than overwrite the user's
737
+ // hand-rolled value.
738
+ //
739
+ // `.env` parsing is intentionally line-based and dumb (matching dotenv's
740
+ // minimum surface): trim, skip blanks/comments, split on the first `=`. We do
741
+ // not unquote values because we only check for key presence.
742
+ async function appendChannelSecrets(cwd: string, secrets: ChannelSecrets): Promise<void> {
743
+ if (Object.keys(secrets).length === 0) return
744
+
745
+ const path = join(cwd, SECRETS_FILE)
746
+ let existing: string
747
+ try {
748
+ existing = await readFile(path, 'utf8')
749
+ } catch (error) {
750
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
751
+ throw new Error(
752
+ `${SECRETS_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
753
+ )
754
+ }
755
+ throw error
756
+ }
757
+
758
+ const presentKeys = parseEnvKeys(existing)
759
+ for (const key of Object.keys(secrets)) {
760
+ if (presentKeys.has(key)) {
761
+ throw new Error(
762
+ `${key} is already set in ${SECRETS_FILE}. Remove it before re-adding the channel, or edit the value by hand.`,
763
+ )
764
+ }
765
+ }
766
+
767
+ // Ensure exactly one trailing newline before our appended block so the
768
+ // resulting file remains POSIX-clean even if the user's editor stripped it.
769
+ const trailingNewline = existing.endsWith('\n') || existing === '' ? '' : '\n'
770
+ const appended = Object.entries(secrets)
771
+ .map(([k, v]) => `${k}=${v}`)
772
+ .join('\n')
773
+ await writeFile(path, `${existing}${trailingNewline}${appended}\n`)
774
+ }
775
+
776
+ function parseEnvKeys(content: string): Set<string> {
777
+ const keys = new Set<string>()
778
+ for (const rawLine of content.split('\n')) {
779
+ const line = rawLine.trim()
780
+ if (line === '' || line.startsWith('#')) continue
781
+ const eq = line.indexOf('=')
782
+ if (eq <= 0) continue
783
+ keys.add(line.slice(0, eq).trim())
784
+ }
785
+ return keys
786
+ }