typeclaw 0.1.5 → 0.1.6

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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +183 -62
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
package/src/cli/init.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import type { DockerAvailability } from '@/container'
12
12
  import {
13
13
  findAgentDir,
14
+ hasExistingChannelSecrets,
14
15
  isDirectoryNonEmpty,
15
16
  isHatched,
16
17
  readExistingProviderApiKey,
@@ -26,6 +27,20 @@ import { makeOAuthLoginRunner } from '@/init/oauth-login'
26
27
 
27
28
  import { c, done, errorLine } from './ui'
28
29
 
30
+ // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
31
+ // aliases both to the same "cancel" action — there's no way to tell them
32
+ // apart through @clack/prompts). The wizard treats every cancel as "go
33
+ // back to the previous step": each step that runs an interactive prompt
34
+ // either advances with a value or rewinds. There is no "go back" target
35
+ // on the very first step (pick-provider), so a `back` there is a no-op
36
+ // that re-displays the same prompt rather than aborting. Users who want
37
+ // to bail out of the wizard kill the process from outside (close the
38
+ // terminal, send SIGTERM); inside an active clack prompt Ctrl+C is also
39
+ // aliased to cancel, so there is no in-wizard abort hotkey.
40
+ export type StepResult<T> = { kind: 'value'; value: T } | { kind: 'back' }
41
+ const back = <T>(): StepResult<T> => ({ kind: 'back' })
42
+ const value = <T>(v: T): StepResult<T> => ({ kind: 'value', value: v })
43
+
29
44
  export const init = defineCommand({
30
45
  meta: {
31
46
  name: 'init',
@@ -61,238 +76,58 @@ export const init = defineCommand({
61
76
  }
62
77
 
63
78
  intro('Initializing TypeClaw...')
79
+ log.info('Press ESC at any prompt to go back to the previous step.')
64
80
 
65
- const selectedModel = await pickModel()
66
- const provider = KNOWN_PROVIDERS[selectedModel.providerId]
67
-
68
- const existingApiKey = await readExistingProviderApiKey(cwd, selectedModel.providerId)
69
- const llmAuth = await collectLLMAuth(provider, existingApiKey)
70
-
71
- const channelChoice = await select({
72
- message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
73
- options: [
74
- { value: 'slack', label: 'Slack' },
75
- { value: 'discord', label: 'Discord' },
76
- { value: 'telegram', label: 'Telegram' },
77
- { value: 'kakaotalk', label: 'KakaoTalk' },
78
- { value: 'none', label: 'Skip — no channel right now' },
79
- ],
80
- initialValue: 'slack' as const,
81
- })
82
- if (isCancel(channelChoice)) {
83
- cancel('Aborted.')
84
- process.exit(0)
85
- }
86
-
87
- let discordBotToken: string | undefined
88
- let slackBotToken: string | undefined
89
- let slackAppToken: string | undefined
90
- let telegramBotToken: string | undefined
91
- let kakaotalkEmail: string | undefined
92
- let kakaotalkPassword: string | undefined
93
-
94
- if (channelChoice === 'discord') {
95
- note(
96
- [
97
- 'https://discord.com/developers/applications',
98
- 'New Application → Bot tab → Reset Token.',
99
- 'Enable the MESSAGE CONTENT intent.',
100
- ].join('\n'),
101
- 'Get a Discord bot token',
102
- )
103
- const token = await password({
104
- message: 'Discord bot token',
105
- validate: (value) => (value && value.length > 0 ? undefined : 'Token is required'),
106
- })
107
- if (isCancel(token)) {
108
- cancel('Aborted.')
109
- process.exit(0)
110
- }
111
- discordBotToken = token
112
- }
113
-
114
- if (channelChoice === 'kakaotalk') {
115
- note(
116
- [
117
- 'KakaoTalk authentication uses a personal account, registered as a',
118
- 'tablet sub-device. Messages will be sent and received under this',
119
- 'account. Use a non-primary account if possible.',
120
- '',
121
- 'After you submit the password, KakaoTalk may ask you to confirm a',
122
- 'passcode on your phone. Watch the screen for the code.',
123
- ].join('\n'),
124
- 'About to log in to KakaoTalk',
125
- )
126
- const email = await text({
127
- message: 'KakaoTalk email',
128
- validate: (value) => (value && value.length > 0 ? undefined : 'Email is required'),
129
- })
130
- if (isCancel(email)) {
131
- cancel('Aborted.')
132
- process.exit(0)
133
- }
134
- const pwd = await password({
135
- message: 'KakaoTalk password',
136
- validate: (value) => (value && value.length > 0 ? undefined : 'Password is required'),
137
- })
138
- if (isCancel(pwd)) {
139
- cancel('Aborted.')
140
- process.exit(0)
141
- }
142
- kakaotalkEmail = email
143
- kakaotalkPassword = pwd
144
- }
145
-
146
- if (channelChoice === 'slack') {
147
- note(
148
- [
149
- '1. https://api.slack.com/apps → Create New App → From a manifest.',
150
- ' Pick your workspace, then paste this JSON manifest:',
151
- '',
152
- ' {',
153
- ' "display_information": { "name": "TypeClaw" },',
154
- ' "features": {',
155
- ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
156
- ' },',
157
- ' "oauth_config": {',
158
- ' "scopes": {',
159
- ' "bot": [',
160
- ' "app_mentions:read", "chat:write", "users:read", "files:read",',
161
- ' "channels:history", "channels:read",',
162
- ' "groups:history", "groups:read",',
163
- ' "im:history", "im:read",',
164
- ' "mpim:history", "mpim:read"',
165
- ' ]',
166
- ' }',
167
- ' },',
168
- ' "settings": {',
169
- ' "event_subscriptions": {',
170
- ' "bot_events": [',
171
- ' "app_mention",',
172
- ' "message.channels", "message.groups",',
173
- ' "message.im", "message.mpim"',
174
- ' ]',
175
- ' },',
176
- ' "socket_mode_enabled": true',
177
- ' }',
178
- ' }',
179
- '',
180
- '2. Install to Workspace, then OAuth & Permissions →',
181
- ' copy the Bot User OAuth Token (xoxb-...).',
182
- '3. Basic Information → App-Level Tokens → Generate Token and',
183
- ' Scopes, add the connections:write scope, and copy the',
184
- ' token (xapp-...). Socket Mode needs this; the manifest',
185
- ' cannot grant it.',
186
- '4. Invite the bot to any private channel or DM you want it in:',
187
- ' /invite @TypeClaw',
188
- ].join('\n'),
189
- 'Get a Slack bot',
190
- )
191
- const botToken = await password({
192
- message: 'Slack bot token (xoxb-...)',
193
- validate: (value) =>
194
- value && value.length > 0
195
- ? value.startsWith('xoxb-')
196
- ? undefined
197
- : 'Bot token must start with "xoxb-"'
198
- : 'Token is required',
199
- })
200
- if (isCancel(botToken)) {
201
- cancel('Aborted.')
202
- process.exit(0)
203
- }
204
- slackBotToken = botToken
205
- note(
206
- [
207
- 'Slack does not accept connections:write inside the manifest, so',
208
- 'this token has to be generated by hand:',
209
- '',
210
- '1. Basic Information → App-Level Tokens → Generate Token and Scopes.',
211
- '2. Token Name: anything (e.g. "socket-mode").',
212
- '3. Add Scope → connections:write → Generate.',
213
- '4. Copy the xapp-... token shown once on screen.',
214
- ' (You cannot retrieve it later — only revoke and regenerate.)',
215
- ].join('\n'),
216
- 'Generate the Slack app-level token',
217
- )
218
- const appToken = await password({
219
- message: 'Slack app-level token (xapp-...) — Socket Mode requires this',
220
- validate: (value) =>
221
- value && value.length > 0
222
- ? value.startsWith('xapp-')
223
- ? undefined
224
- : 'App-level token must start with "xapp-"'
225
- : 'Token is required',
226
- })
227
- if (isCancel(appToken)) {
228
- cancel('Aborted.')
229
- process.exit(0)
230
- }
231
- slackAppToken = appToken
232
- }
233
-
234
- if (channelChoice === 'telegram') {
235
- note(
236
- [
237
- 'Open Telegram and message @BotFather.',
238
- '/newbot → pick a name and username, copy the HTTP API token',
239
- ' (looks like 1234567890:ABCdef...).',
240
- 'In @BotFather: /setprivacy → Disable, so the bot can see group messages.',
241
- ].join('\n'),
242
- 'Get a Telegram bot token',
243
- )
244
- const token = await password({
245
- message: 'Telegram bot token',
246
- validate: (value) =>
247
- value && value.length > 0
248
- ? /^\d+:/.test(value)
249
- ? undefined
250
- : 'Bot token must look like "<digits>:<secret>" (from @BotFather)'
251
- : 'Token is required',
252
- })
253
- if (isCancel(token)) {
254
- cancel('Aborted.')
255
- process.exit(0)
256
- }
257
- telegramBotToken = token
258
- note(
259
- [
260
- 'Open https://t.me/<your_bot_username> (the username you picked in /newbot, ends in "bot").',
261
- 'Tap Start in the chat — the agent will reply once it hatches.',
262
- 'For groups: add the bot to the group, then @mention it or reply to its messages.',
263
- ].join('\n'),
264
- 'Send your first message',
265
- )
266
- }
81
+ const collected = await collectWizardInputs(cwd, defaultWizardPrompts)
82
+ const { model, llmAuth, vision, channelChoice, reuseExistingChannel, channelSecrets } = collected
83
+ const { discordBotToken, slackBotToken, slackAppToken, telegramBotToken, kakaotalkEmail, kakaotalkPassword } =
84
+ channelSecrets
267
85
 
268
86
  // TODO: add remaining wizard steps from TypeClaw.md once their runtime lands:
269
87
  // - git backup (url + PAT) — Phase 10
270
88
  // - cron.json scaffolding — Phase 9
271
89
  // - compose.yml registration in $HOME/.typeclaw — Phase 12
272
- const wantsKakaotalk = kakaotalkEmail !== undefined && kakaotalkPassword !== undefined
90
+
91
+ // Reuse means: wire the adapter in typeclaw.json but skip the prompt for
92
+ // fresh tokens / fresh kakaotalk login. `with<Adapter>` flags carry that
93
+ // intent down to scaffold(); writeSecrets / runKakaotalkAuth see no new
94
+ // input and leave the existing secrets.json slot untouched.
95
+ const reuseDiscord = reuseExistingChannel && channelChoice === 'discord'
96
+ const reuseSlack = reuseExistingChannel && channelChoice === 'slack'
97
+ const reuseTelegram = reuseExistingChannel && channelChoice === 'telegram'
98
+ const reuseKakaotalk = reuseExistingChannel && channelChoice === 'kakaotalk'
99
+ const wantsKakaotalk = (kakaotalkEmail !== undefined && kakaotalkPassword !== undefined) || reuseKakaotalk
273
100
  let hatchingOk = false
274
101
  let preflightFailure: Extract<DockerAvailability, { ok: false }> | null = null
275
102
  try {
276
103
  await runInit({
277
104
  cwd,
278
105
  llmAuth,
279
- model: selectedModel.ref,
106
+ model: model.ref,
107
+ ...(vision !== undefined ? { visionModel: vision.model.ref, visionAuth: vision.llmAuth } : {}),
280
108
  cliEntry: process.argv[1],
281
109
  ...(discordBotToken !== undefined ? { discordBotToken } : {}),
282
110
  ...(slackBotToken !== undefined ? { slackBotToken, slackAppToken } : {}),
283
111
  ...(telegramBotToken !== undefined ? { telegramBotToken } : {}),
112
+ ...(reuseDiscord ? { withDiscord: true } : {}),
113
+ ...(reuseSlack ? { withSlack: true } : {}),
114
+ ...(reuseTelegram ? { withTelegram: true } : {}),
284
115
  ...(wantsKakaotalk
285
116
  ? {
286
117
  withKakaotalk: true,
287
- runKakaotalkAuth: ({ cwd: agentDir }) =>
288
- runKakaotalkBootstrap({
289
- email: kakaotalkEmail!,
290
- password: kakaotalkPassword!,
291
- agentDir,
292
- callbacks: {
293
- onPasscode: (code) => log.info(`Confirm this passcode on your phone: ${code}`),
294
- },
295
- }),
118
+ ...(reuseKakaotalk
119
+ ? {}
120
+ : {
121
+ runKakaotalkAuth: ({ cwd: agentDir }) =>
122
+ runKakaotalkBootstrap({
123
+ email: kakaotalkEmail!,
124
+ password: kakaotalkPassword!,
125
+ agentDir,
126
+ callbacks: {
127
+ onPasscode: (code) => log.info(`Confirm this passcode on your phone: ${code}`),
128
+ },
129
+ }),
130
+ }),
296
131
  }
297
132
  : {}),
298
133
  onProgress: reportProgress(
@@ -327,6 +162,756 @@ export const init = defineCommand({
327
162
  },
328
163
  })
329
164
 
165
+ interface WizardState {
166
+ catalog?: { options: ModelOption[]; source: 'models.dev' | 'curated'; warning?: string }
167
+ providerId?: KnownProviderId
168
+ model?: ModelOption
169
+ reuseExisting?: boolean
170
+ authMethod?: 'api-key' | 'oauth'
171
+ llmAuth?: LLMAuth
172
+ visionProviderId?: KnownProviderId
173
+ visionModel?: ModelOption
174
+ visionReuseExisting?: boolean
175
+ visionAuthMethod?: 'api-key' | 'oauth'
176
+ visionLlmAuth?: LLMAuth
177
+ channelChoice?: ChannelChoice
178
+ channelReuseOffered?: boolean
179
+ channelReuseExisting?: boolean
180
+ }
181
+
182
+ type ChannelChoice = 'slack' | 'discord' | 'telegram' | 'kakaotalk' | 'none'
183
+
184
+ interface CollectedInputs {
185
+ model: ModelOption
186
+ llmAuth: LLMAuth
187
+ // Set only when the default model is text-only and the user picked a
188
+ // vision-capable model for the `vision` profile. `llmAuth` is reused from
189
+ // the default provider's credentials when the vision provider matches, so
190
+ // tests can still mint a single auth object and have it cover both.
191
+ vision?: {
192
+ model: ModelOption
193
+ llmAuth: LLMAuth
194
+ }
195
+ channelChoice: ChannelChoice
196
+ reuseExistingChannel: boolean
197
+ channelSecrets: {
198
+ discordBotToken?: string
199
+ slackBotToken?: string
200
+ slackAppToken?: string
201
+ telegramBotToken?: string
202
+ kakaotalkEmail?: string
203
+ kakaotalkPassword?: string
204
+ }
205
+ }
206
+
207
+ type StepId =
208
+ | 'pick-provider'
209
+ | 'pick-model'
210
+ | 'reuse-existing-key'
211
+ | 'pick-auth-method'
212
+ | 'enter-api-key'
213
+ | 'pick-vision-provider'
214
+ | 'pick-vision-model'
215
+ | 'pick-vision-auth-method'
216
+ | 'enter-vision-api-key'
217
+ | 'pick-channel'
218
+ | 'reuse-existing-channel'
219
+ | 'channel-flow'
220
+
221
+ export interface WizardPrompts {
222
+ loadCatalog: () => Promise<NonNullable<WizardState['catalog']>>
223
+ readExistingApiKey: (cwd: string, providerId: KnownProviderId) => Promise<string | null>
224
+ pickProvider: (options: ModelOption[], initial: KnownProviderId | undefined) => Promise<StepResult<KnownProviderId>>
225
+ pickModel: (
226
+ options: ModelOption[],
227
+ providerId: KnownProviderId,
228
+ initial: KnownModelRef | undefined,
229
+ ) => Promise<StepResult<ModelOption>>
230
+ askReuseExistingKey: (
231
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
232
+ existingApiKey: string | null,
233
+ initial: boolean | undefined,
234
+ ) => Promise<StepResult<'reuse' | 'prompt'>>
235
+ pickAuthMethod: (
236
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
237
+ initial: 'api-key' | 'oauth' | undefined,
238
+ ) => Promise<StepResult<'api-key' | 'oauth'>>
239
+ askApiKey: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => Promise<StepResult<string>>
240
+ pickVisionProvider: (
241
+ options: ModelOption[],
242
+ initial: KnownProviderId | undefined,
243
+ ) => Promise<StepResult<KnownProviderId | 'skip'>>
244
+ pickVisionModel: (
245
+ options: ModelOption[],
246
+ providerId: KnownProviderId,
247
+ initial: KnownModelRef | undefined,
248
+ ) => Promise<StepResult<ModelOption>>
249
+ pickChannel: (initial: ChannelChoice | undefined) => Promise<StepResult<ChannelChoice>>
250
+ hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
251
+ askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
252
+ runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
253
+ buildOAuthAuth: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => LLMAuth
254
+ }
255
+
256
+ export const defaultWizardPrompts: WizardPrompts = {
257
+ loadCatalog,
258
+ readExistingApiKey: readExistingProviderApiKey,
259
+ pickProvider,
260
+ pickModel: pickModelForProvider,
261
+ askReuseExistingKey,
262
+ pickAuthMethod,
263
+ askApiKey,
264
+ pickVisionProvider,
265
+ pickVisionModel,
266
+ pickChannel,
267
+ hasExistingChannelSecrets,
268
+ askReuseExistingChannel,
269
+ runChannelFlow,
270
+ buildOAuthAuth: (provider) => ({
271
+ kind: 'oauth',
272
+ runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)),
273
+ }),
274
+ }
275
+
276
+ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
277
+ const catalog = await prompts.loadCatalog()
278
+ const state: WizardState = { catalog }
279
+ let step: StepId = 'pick-provider'
280
+
281
+ while (true) {
282
+ switch (step) {
283
+ case 'pick-provider': {
284
+ const result = await prompts.pickProvider(catalog.options, state.providerId)
285
+ if (result.kind === 'back') {
286
+ break
287
+ }
288
+ if (state.providerId !== result.value) {
289
+ state.model = undefined
290
+ state.reuseExisting = undefined
291
+ state.authMethod = undefined
292
+ state.llmAuth = undefined
293
+ }
294
+ state.providerId = result.value
295
+ step = 'pick-model'
296
+ break
297
+ }
298
+
299
+ case 'pick-model': {
300
+ const result = await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref)
301
+ if (result.kind === 'back') {
302
+ step = 'pick-provider'
303
+ break
304
+ }
305
+ state.model = result.value
306
+ step = 'reuse-existing-key'
307
+ break
308
+ }
309
+
310
+ case 'reuse-existing-key': {
311
+ const provider = KNOWN_PROVIDERS[state.providerId!]
312
+ const existingApiKey = await prompts.readExistingApiKey(cwd, state.providerId!)
313
+ const decision = await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting)
314
+ if (decision.kind === 'back') {
315
+ step = 'pick-model'
316
+ break
317
+ }
318
+ if (decision.value === 'reuse' && existingApiKey !== null) {
319
+ log.info(`Using existing ${provider.name} API key from secrets.json.`)
320
+ state.llmAuth = { kind: 'api-key', apiKey: existingApiKey }
321
+ state.reuseExisting = true
322
+ step = stepAfterDefaultAuth(state)
323
+ break
324
+ }
325
+ state.reuseExisting = false
326
+ state.llmAuth = undefined
327
+ step = 'pick-auth-method'
328
+ break
329
+ }
330
+
331
+ case 'pick-auth-method': {
332
+ const provider = KNOWN_PROVIDERS[state.providerId!]
333
+ const result = await prompts.pickAuthMethod(provider, state.authMethod)
334
+ if (result.kind === 'back') {
335
+ step = 'reuse-existing-key'
336
+ break
337
+ }
338
+ state.authMethod = result.value
339
+ if (result.value === 'oauth') {
340
+ state.llmAuth = prompts.buildOAuthAuth(provider)
341
+ step = stepAfterDefaultAuth(state)
342
+ } else {
343
+ step = 'enter-api-key'
344
+ }
345
+ break
346
+ }
347
+
348
+ case 'enter-api-key': {
349
+ const provider = KNOWN_PROVIDERS[state.providerId!]
350
+ const result = await prompts.askApiKey(provider)
351
+ if (result.kind === 'back') {
352
+ step = 'pick-auth-method'
353
+ break
354
+ }
355
+ state.llmAuth = { kind: 'api-key', apiKey: result.value }
356
+ step = stepAfterDefaultAuth(state)
357
+ break
358
+ }
359
+
360
+ case 'pick-vision-provider': {
361
+ const visionOptions = catalog.options.filter((o) => o.supportsVision)
362
+ const result = await prompts.pickVisionProvider(visionOptions, state.visionProviderId)
363
+ if (result.kind === 'back') {
364
+ step = stepBeforeVision(state)
365
+ break
366
+ }
367
+ if (result.value === 'skip') {
368
+ state.visionProviderId = undefined
369
+ state.visionModel = undefined
370
+ state.visionLlmAuth = undefined
371
+ state.visionReuseExisting = undefined
372
+ state.visionAuthMethod = undefined
373
+ step = 'pick-channel'
374
+ break
375
+ }
376
+ if (state.visionProviderId !== result.value) {
377
+ state.visionModel = undefined
378
+ state.visionReuseExisting = undefined
379
+ state.visionAuthMethod = undefined
380
+ state.visionLlmAuth = undefined
381
+ }
382
+ state.visionProviderId = result.value
383
+ step = 'pick-vision-model'
384
+ break
385
+ }
386
+
387
+ case 'pick-vision-model': {
388
+ const visionOptions = catalog.options.filter((o) => o.supportsVision)
389
+ const result = await prompts.pickVisionModel(visionOptions, state.visionProviderId!, state.visionModel?.ref)
390
+ if (result.kind === 'back') {
391
+ step = 'pick-vision-provider'
392
+ break
393
+ }
394
+ state.visionModel = result.value
395
+ if (state.visionProviderId === state.providerId) {
396
+ log.info(`Using ${KNOWN_PROVIDERS[state.providerId!].name} credentials for the vision profile.`)
397
+ state.visionLlmAuth = state.llmAuth
398
+ state.visionReuseExisting = true
399
+ step = 'pick-channel'
400
+ break
401
+ }
402
+ const existingVisionKey = await prompts.readExistingApiKey(cwd, state.visionProviderId!)
403
+ if (existingVisionKey !== null) {
404
+ log.info(`Using existing ${KNOWN_PROVIDERS[state.visionProviderId!].name} API key from secrets.json.`)
405
+ state.visionLlmAuth = { kind: 'api-key', apiKey: existingVisionKey }
406
+ state.visionReuseExisting = true
407
+ step = 'pick-channel'
408
+ break
409
+ }
410
+ state.visionReuseExisting = false
411
+ state.visionLlmAuth = undefined
412
+ step = 'pick-vision-auth-method'
413
+ break
414
+ }
415
+
416
+ case 'pick-vision-auth-method': {
417
+ const provider = KNOWN_PROVIDERS[state.visionProviderId!]
418
+ const result = await prompts.pickAuthMethod(provider, state.visionAuthMethod)
419
+ if (result.kind === 'back') {
420
+ step = 'pick-vision-model'
421
+ break
422
+ }
423
+ state.visionAuthMethod = result.value
424
+ if (result.value === 'oauth') {
425
+ state.visionLlmAuth = prompts.buildOAuthAuth(provider)
426
+ step = 'pick-channel'
427
+ } else {
428
+ step = 'enter-vision-api-key'
429
+ }
430
+ break
431
+ }
432
+
433
+ case 'enter-vision-api-key': {
434
+ const provider = KNOWN_PROVIDERS[state.visionProviderId!]
435
+ const result = await prompts.askApiKey(provider)
436
+ if (result.kind === 'back') {
437
+ step = 'pick-vision-auth-method'
438
+ break
439
+ }
440
+ state.visionLlmAuth = { kind: 'api-key', apiKey: result.value }
441
+ step = 'pick-channel'
442
+ break
443
+ }
444
+
445
+ case 'pick-channel': {
446
+ const result = await prompts.pickChannel(state.channelChoice)
447
+ if (result.kind === 'back') {
448
+ step = stepBeforePickChannel(state)
449
+ break
450
+ }
451
+ if (state.channelChoice !== result.value) {
452
+ state.channelReuseOffered = undefined
453
+ state.channelReuseExisting = undefined
454
+ }
455
+ state.channelChoice = result.value
456
+ step = result.value === 'none' ? 'channel-flow' : 'reuse-existing-channel'
457
+ break
458
+ }
459
+
460
+ case 'reuse-existing-channel': {
461
+ const choice = state.channelChoice as Exclude<ChannelChoice, 'none'>
462
+ const present = await prompts.hasExistingChannelSecrets(cwd, choice)
463
+ if (!present) {
464
+ state.channelReuseOffered = false
465
+ state.channelReuseExisting = false
466
+ step = 'channel-flow'
467
+ break
468
+ }
469
+ state.channelReuseOffered = true
470
+ const decision = await prompts.askReuseExistingChannel(choice)
471
+ if (decision.kind === 'back') {
472
+ step = 'pick-channel'
473
+ break
474
+ }
475
+ if (decision.value === 'reuse') {
476
+ log.info(`Using existing ${channelDisplayName(choice)} credentials from secrets.json.`)
477
+ state.channelReuseExisting = true
478
+ return finalize(state, {})
479
+ }
480
+ state.channelReuseExisting = false
481
+ step = 'channel-flow'
482
+ break
483
+ }
484
+
485
+ case 'channel-flow': {
486
+ const result = await prompts.runChannelFlow(state.channelChoice!)
487
+ if (result.kind === 'back') {
488
+ step = state.channelReuseOffered === true ? 'reuse-existing-channel' : 'pick-channel'
489
+ break
490
+ }
491
+ return finalize(state, result.value)
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSecrets']): CollectedInputs {
498
+ return {
499
+ model: state.model!,
500
+ llmAuth: state.llmAuth!,
501
+ ...(state.visionModel !== undefined && state.visionLlmAuth !== undefined
502
+ ? { vision: { model: state.visionModel, llmAuth: state.visionLlmAuth } }
503
+ : {}),
504
+ channelChoice: state.channelChoice ?? 'none',
505
+ reuseExistingChannel: state.channelReuseExisting === true,
506
+ channelSecrets,
507
+ }
508
+ }
509
+
510
+ function channelDisplayName(choice: Exclude<ChannelChoice, 'none'>): string {
511
+ switch (choice) {
512
+ case 'slack':
513
+ return 'Slack'
514
+ case 'discord':
515
+ return 'Discord'
516
+ case 'telegram':
517
+ return 'Telegram'
518
+ case 'kakaotalk':
519
+ return 'KakaoTalk'
520
+ }
521
+ }
522
+
523
+ function stepAfterDefaultAuth(state: WizardState): StepId {
524
+ return state.model?.supportsVision === false ? 'pick-vision-provider' : 'pick-channel'
525
+ }
526
+
527
+ function stepBeforeVision(state: WizardState): StepId {
528
+ if (state.reuseExisting === true) return 'reuse-existing-key'
529
+ if (state.authMethod === 'api-key') return 'enter-api-key'
530
+ return 'pick-auth-method'
531
+ }
532
+
533
+ function stepBeforePickChannel(state: WizardState): StepId {
534
+ if (state.visionModel !== undefined) {
535
+ if (state.visionProviderId === state.providerId) return 'pick-vision-model'
536
+ if (state.visionReuseExisting === true) return 'pick-vision-model'
537
+ if (state.visionAuthMethod === 'api-key') return 'enter-vision-api-key'
538
+ if (state.visionAuthMethod === 'oauth') return 'pick-vision-auth-method'
539
+ return 'pick-vision-model'
540
+ }
541
+ if (state.model?.supportsVision === false) return 'pick-vision-provider'
542
+ return stepBeforeVision(state)
543
+ }
544
+
545
+ async function loadCatalog(): Promise<NonNullable<WizardState['catalog']>> {
546
+ const s = spinner()
547
+ s.start('Loading model catalog from models.dev...')
548
+ const { options, source, warning } = await fetchModelOptions()
549
+ if (source === 'curated') {
550
+ s.stop(`Using built-in catalog (models.dev unavailable: ${warning ?? 'unknown'})`)
551
+ } else {
552
+ s.stop('Loaded model catalog.')
553
+ }
554
+ return warning !== undefined ? { options, source, warning } : { options, source }
555
+ }
556
+
557
+ async function pickProvider(
558
+ options: ModelOption[],
559
+ initial: KnownProviderId | undefined,
560
+ ): Promise<StepResult<KnownProviderId>> {
561
+ const providers = uniqueProviders(options)
562
+ const choice = await select({
563
+ message: 'Pick an LLM provider',
564
+ options: providers.map((id) => ({ value: id, label: KNOWN_PROVIDERS[id].name, hint: providerAuthHint(id) })),
565
+ initialValue: initial ?? providers[0],
566
+ })
567
+ if (isCancel(choice)) return back()
568
+ return value(choice)
569
+ }
570
+
571
+ async function pickModelForProvider(
572
+ options: ModelOption[],
573
+ providerId: KnownProviderId,
574
+ initial: KnownModelRef | undefined,
575
+ ): Promise<StepResult<ModelOption>> {
576
+ const candidates = options.filter((o) => o.providerId === providerId)
577
+ const choice = await select<KnownModelRef>({
578
+ message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
579
+ options: candidates.map((o) => ({
580
+ value: o.ref,
581
+ label: o.modelName,
582
+ hint: formatModelHint(o),
583
+ })),
584
+ initialValue: initial ?? candidates[0]?.ref,
585
+ })
586
+ if (isCancel(choice)) return back()
587
+ const picked = candidates.find((o) => o.ref === choice)
588
+ if (!picked) throw new Error(`Internal error: picked model ${choice} not in candidates`)
589
+ return value(picked)
590
+ }
591
+
592
+ async function askReuseExistingKey(
593
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
594
+ existingApiKey: string | null,
595
+ initial: boolean | undefined,
596
+ ): Promise<StepResult<'reuse' | 'prompt'>> {
597
+ if (!providerSupportsApiKey(provider) || existingApiKey === null) return value('prompt')
598
+ const reuse = await confirm({
599
+ message: `Reuse existing ${provider.name} API key from secrets.json?`,
600
+ initialValue: initial ?? true,
601
+ })
602
+ if (isCancel(reuse)) return back()
603
+ return value(reuse === true ? 'reuse' : 'prompt')
604
+ }
605
+
606
+ async function askReuseExistingChannel(
607
+ channel: Exclude<ChannelChoice, 'none'>,
608
+ ): Promise<StepResult<'reuse' | 'prompt'>> {
609
+ const reuse = await confirm({
610
+ message: `Reuse existing ${channelDisplayName(channel)} credentials from secrets.json?`,
611
+ initialValue: true,
612
+ })
613
+ if (isCancel(reuse)) return back()
614
+ return value(reuse === true ? 'reuse' : 'prompt')
615
+ }
616
+
617
+ async function pickAuthMethod(
618
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
619
+ initial: 'api-key' | 'oauth' | undefined,
620
+ ): Promise<StepResult<'api-key' | 'oauth'>> {
621
+ const supportsApiKey = providerSupportsApiKey(provider)
622
+ const supportsOAuth = providerSupportsOAuth(provider)
623
+ if (supportsApiKey && supportsOAuth) {
624
+ const choice = await select<'api-key' | 'oauth'>({
625
+ message: `How do you want to authenticate to ${provider.name}?`,
626
+ options: [
627
+ { value: 'api-key', label: 'API key', hint: 'saved to secrets.json' },
628
+ { value: 'oauth', label: 'OAuth (browser login)', hint: 'saved to secrets.json' },
629
+ ],
630
+ initialValue: initial ?? 'api-key',
631
+ })
632
+ if (isCancel(choice)) return back()
633
+ return value(choice)
634
+ }
635
+ // Single-method providers: no prompt to back out of, so always advance.
636
+ return value(supportsOAuth ? 'oauth' : 'api-key')
637
+ }
638
+
639
+ async function pickVisionProvider(
640
+ options: ModelOption[],
641
+ initial: KnownProviderId | undefined,
642
+ ): Promise<StepResult<KnownProviderId | 'skip'>> {
643
+ const providers = uniqueProviders(options)
644
+ if (providers.length === 0) {
645
+ log.warn('No vision-capable models available; skipping vision profile.')
646
+ return value('skip')
647
+ }
648
+ const choice = await select<KnownProviderId | 'skip'>({
649
+ message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
650
+ options: [
651
+ ...providers.map((id) => ({
652
+ value: id as KnownProviderId | 'skip',
653
+ label: KNOWN_PROVIDERS[id].name,
654
+ hint: providerAuthHint(id),
655
+ })),
656
+ { value: 'skip', label: 'Skip — no vision support', hint: 'add later with `typeclaw model set vision <ref>`' },
657
+ ],
658
+ initialValue: initial ?? providers[0],
659
+ })
660
+ if (isCancel(choice)) return back()
661
+ return value(choice)
662
+ }
663
+
664
+ async function pickVisionModel(
665
+ options: ModelOption[],
666
+ providerId: KnownProviderId,
667
+ initial: KnownModelRef | undefined,
668
+ ): Promise<StepResult<ModelOption>> {
669
+ const candidates = options.filter((o) => o.providerId === providerId)
670
+ const choice = await select<KnownModelRef>({
671
+ message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
672
+ options: candidates.map((o) => ({
673
+ value: o.ref,
674
+ label: o.modelName,
675
+ hint: formatModelHint(o),
676
+ })),
677
+ initialValue: initial ?? candidates[0]?.ref,
678
+ })
679
+ if (isCancel(choice)) return back()
680
+ const picked = candidates.find((o) => o.ref === choice)
681
+ if (!picked) throw new Error(`Internal error: picked vision model ${choice} not in candidates`)
682
+ return value(picked)
683
+ }
684
+
685
+ async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): Promise<StepResult<string>> {
686
+ const apiKey = await password({
687
+ message: `Put your ${provider.name} API key (will be saved to secrets.json)`,
688
+ validate: (v) => (v && v.length > 0 ? undefined : 'API key is required'),
689
+ })
690
+ if (isCancel(apiKey)) return back()
691
+ return value(apiKey)
692
+ }
693
+
694
+ async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResult<ChannelChoice>> {
695
+ const choice = await select<ChannelChoice>({
696
+ message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
697
+ options: [
698
+ { value: 'slack', label: 'Slack' },
699
+ { value: 'discord', label: 'Discord' },
700
+ { value: 'telegram', label: 'Telegram' },
701
+ { value: 'kakaotalk', label: 'KakaoTalk' },
702
+ { value: 'none', label: 'Skip — no channel right now' },
703
+ ],
704
+ initialValue: initial ?? 'slack',
705
+ })
706
+ if (isCancel(choice)) return back()
707
+ return value(choice)
708
+ }
709
+
710
+ async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<CollectedInputs['channelSecrets']>> {
711
+ switch (choice) {
712
+ case 'none':
713
+ return value({})
714
+ case 'discord':
715
+ return runDiscordFlow()
716
+ case 'kakaotalk':
717
+ return runKakaotalkFlow()
718
+ case 'slack':
719
+ return runSlackFlow()
720
+ case 'telegram':
721
+ return runTelegramFlow()
722
+ }
723
+ }
724
+
725
+ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
726
+ note(
727
+ [
728
+ 'https://discord.com/developers/applications',
729
+ 'New Application → Bot tab → Reset Token.',
730
+ 'Enable the MESSAGE CONTENT intent.',
731
+ ].join('\n'),
732
+ 'Get a Discord bot token',
733
+ )
734
+ const token = await password({
735
+ message: 'Discord bot token',
736
+ validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
737
+ })
738
+ if (isCancel(token)) return back()
739
+ return value({ discordBotToken: token })
740
+ }
741
+
742
+ async function runKakaotalkFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
743
+ // Sub-flow with its own back-aware loop: ESC on the password prompt
744
+ // returns to the email prompt; ESC on the email prompt unwinds to the
745
+ // channel picker.
746
+ type SubStep = 'email' | 'password'
747
+ let sub: SubStep = 'email'
748
+ let email: string | undefined
749
+ let pwd: string | undefined
750
+
751
+ note(
752
+ [
753
+ 'KakaoTalk authentication uses a personal account, registered as a',
754
+ 'tablet sub-device. Messages will be sent and received under this',
755
+ 'account. Use a non-primary account if possible.',
756
+ '',
757
+ 'After you submit the password, KakaoTalk may ask you to confirm a',
758
+ 'passcode on your phone. Watch the screen for the code.',
759
+ ].join('\n'),
760
+ 'About to log in to KakaoTalk',
761
+ )
762
+
763
+ while (true) {
764
+ if (sub === 'email') {
765
+ const input = await text({
766
+ message: 'KakaoTalk email',
767
+ ...(email !== undefined ? { initialValue: email } : {}),
768
+ validate: (v) => (v && v.length > 0 ? undefined : 'Email is required'),
769
+ })
770
+ if (isCancel(input)) return back()
771
+ email = input
772
+ sub = 'password'
773
+ continue
774
+ }
775
+ const input = await password({
776
+ message: 'KakaoTalk password',
777
+ validate: (v) => (v && v.length > 0 ? undefined : 'Password is required'),
778
+ })
779
+ if (isCancel(input)) {
780
+ sub = 'email'
781
+ continue
782
+ }
783
+ pwd = input
784
+ return value({ kakaotalkEmail: email, kakaotalkPassword: pwd })
785
+ }
786
+ }
787
+
788
+ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
789
+ type SubStep = 'bot' | 'app'
790
+ let sub: SubStep = 'bot'
791
+ let botToken: string | undefined
792
+
793
+ note(
794
+ [
795
+ '1. https://api.slack.com/apps → Create New App → From a manifest.',
796
+ ' Pick your workspace, then paste this JSON manifest:',
797
+ '',
798
+ ' {',
799
+ ' "display_information": { "name": "TypeClaw" },',
800
+ ' "features": {',
801
+ ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
802
+ ' },',
803
+ ' "oauth_config": {',
804
+ ' "scopes": {',
805
+ ' "bot": [',
806
+ ' "app_mentions:read", "chat:write", "users:read", "files:read",',
807
+ ' "channels:history", "channels:read",',
808
+ ' "groups:history", "groups:read",',
809
+ ' "im:history", "im:read",',
810
+ ' "mpim:history", "mpim:read"',
811
+ ' ]',
812
+ ' }',
813
+ ' },',
814
+ ' "settings": {',
815
+ ' "event_subscriptions": {',
816
+ ' "bot_events": [',
817
+ ' "app_mention",',
818
+ ' "message.channels", "message.groups",',
819
+ ' "message.im", "message.mpim"',
820
+ ' ]',
821
+ ' },',
822
+ ' "socket_mode_enabled": true',
823
+ ' }',
824
+ ' }',
825
+ '',
826
+ '2. Install to Workspace, then OAuth & Permissions →',
827
+ ' copy the Bot User OAuth Token (xoxb-...).',
828
+ '3. Basic Information → App-Level Tokens → Generate Token and',
829
+ ' Scopes, add the connections:write scope, and copy the',
830
+ ' token (xapp-...). Socket Mode needs this; the manifest',
831
+ ' cannot grant it.',
832
+ '4. Invite the bot to any private channel or DM you want it in:',
833
+ ' /invite @TypeClaw',
834
+ ].join('\n'),
835
+ 'Get a Slack bot',
836
+ )
837
+
838
+ while (true) {
839
+ if (sub === 'bot') {
840
+ const input = await password({
841
+ message: 'Slack bot token (xoxb-...)',
842
+ validate: (v) =>
843
+ v && v.length > 0
844
+ ? v.startsWith('xoxb-')
845
+ ? undefined
846
+ : 'Bot token must start with "xoxb-"'
847
+ : 'Token is required',
848
+ })
849
+ if (isCancel(input)) return back()
850
+ botToken = input
851
+ note(
852
+ [
853
+ 'Slack does not accept connections:write inside the manifest, so',
854
+ 'this token has to be generated by hand:',
855
+ '',
856
+ '1. Basic Information → App-Level Tokens → Generate Token and Scopes.',
857
+ '2. Token Name: anything (e.g. "socket-mode").',
858
+ '3. Add Scope → connections:write → Generate.',
859
+ '4. Copy the xapp-... token shown once on screen.',
860
+ ' (You cannot retrieve it later — only revoke and regenerate.)',
861
+ ].join('\n'),
862
+ 'Generate the Slack app-level token',
863
+ )
864
+ sub = 'app'
865
+ continue
866
+ }
867
+ const input = await password({
868
+ message: 'Slack app-level token (xapp-...) — Socket Mode requires this',
869
+ validate: (v) =>
870
+ v && v.length > 0
871
+ ? v.startsWith('xapp-')
872
+ ? undefined
873
+ : 'App-level token must start with "xapp-"'
874
+ : 'Token is required',
875
+ })
876
+ if (isCancel(input)) {
877
+ sub = 'bot'
878
+ continue
879
+ }
880
+ return value({ slackBotToken: botToken!, slackAppToken: input })
881
+ }
882
+ }
883
+
884
+ async function runTelegramFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
885
+ note(
886
+ [
887
+ 'Open Telegram and message @BotFather.',
888
+ '/newbot → pick a name and username, copy the HTTP API token',
889
+ ' (looks like 1234567890:ABCdef...).',
890
+ 'In @BotFather: /setprivacy → Disable, so the bot can see group messages.',
891
+ ].join('\n'),
892
+ 'Get a Telegram bot token',
893
+ )
894
+ const token = await password({
895
+ message: 'Telegram bot token',
896
+ validate: (v) =>
897
+ v && v.length > 0
898
+ ? /^\d+:/.test(v)
899
+ ? undefined
900
+ : 'Bot token must look like "<digits>:<secret>" (from @BotFather)'
901
+ : 'Token is required',
902
+ })
903
+ if (isCancel(token)) return back()
904
+ note(
905
+ [
906
+ 'Open https://t.me/<your_bot_username> (the username you picked in /newbot, ends in "bot").',
907
+ 'Tap Start in the chat — the agent will reply once it hatches.',
908
+ 'For groups: add the bot to the group, then @mention it or reply to its messages.',
909
+ ].join('\n'),
910
+ 'Send your first message',
911
+ )
912
+ return value({ telegramBotToken: token })
913
+ }
914
+
330
915
  function reportProgress(
331
916
  onHatchingDone: (ok: boolean) => void,
332
917
  onPreflightFail: (result: Extract<DockerAvailability, { ok: false }>) => void,
@@ -432,69 +1017,8 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
432
1017
  if (event.result.ok) {
433
1018
  console.log('Hatched. 🐣')
434
1019
  } else {
435
- console.error(`Hatching failed: ${event.result.reason}`)
436
- }
437
- }
438
-
439
- // Resolves how the user wants to authenticate to the chosen provider:
440
- // - api-key only (e.g. Fireworks): prompt for the key, write to secrets.json.
441
- // - oauth only (e.g. openai-codex): run the browser flow inline, write
442
- // secrets.json. No API key prompt at all.
443
- // - both supported (no providers ship this today, but Anthropic will when
444
- // wired): ask "API key or OAuth?" first, then dispatch to the chosen path.
445
- async function collectLLMAuth(
446
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
447
- existingApiKey: string | null,
448
- ): Promise<LLMAuth> {
449
- const supportsApiKey = providerSupportsApiKey(provider)
450
- const supportsOAuth = providerSupportsOAuth(provider)
451
-
452
- const existingKeyDecision = await decideExistingApiKeyReuse(provider, existingApiKey, (message) =>
453
- confirm({ message, initialValue: true }),
454
- )
455
- if (existingKeyDecision === 'cancel') {
456
- cancel('Aborted.')
457
- process.exit(0)
458
- }
459
- if (existingKeyDecision === 'reuse' && existingApiKey !== null) {
460
- log.info(`Using existing ${provider.name} API key from secrets.json.`)
461
- return { kind: 'api-key', apiKey: existingApiKey }
462
- }
463
-
464
- let method: 'api-key' | 'oauth'
465
- if (supportsApiKey && supportsOAuth) {
466
- const choice = await select<'api-key' | 'oauth'>({
467
- message: `How do you want to authenticate to ${provider.name}?`,
468
- options: [
469
- { value: 'api-key', label: 'API key', hint: 'saved to secrets.json' },
470
- { value: 'oauth', label: 'OAuth (browser login)', hint: 'saved to secrets.json' },
471
- ],
472
- initialValue: 'api-key',
473
- })
474
- if (isCancel(choice)) {
475
- cancel('Aborted.')
476
- process.exit(0)
477
- }
478
- method = choice
479
- } else if (supportsOAuth) {
480
- method = 'oauth'
481
- } else {
482
- method = 'api-key'
483
- }
484
-
485
- if (method === 'api-key') {
486
- const apiKey = await password({
487
- message: `Put your ${provider.name} API key (will be saved to secrets.json)`,
488
- validate: (value) => (value && value.length > 0 ? undefined : 'API key is required'),
489
- })
490
- if (isCancel(apiKey)) {
491
- cancel('Aborted.')
492
- process.exit(0)
493
- }
494
- return { kind: 'api-key', apiKey }
1020
+ console.error(errorLine(`Hatching failed: ${event.result.reason}`))
495
1021
  }
496
-
497
- return { kind: 'oauth', runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)) }
498
1022
  }
499
1023
 
500
1024
  export async function decideExistingApiKeyReuse(
@@ -540,50 +1064,6 @@ function buildOAuthCallbacks(providerName: string) {
540
1064
  }
541
1065
  }
542
1066
 
543
- // Two-step provider+model picker. We split it because most users have a key
544
- // for exactly one provider — asking them to scroll through a flat list of
545
- // every (provider, model) pair would surface options they can't use.
546
- async function pickModel(): Promise<ModelOption> {
547
- const s = spinner()
548
- s.start('Loading model catalog from models.dev...')
549
- const { options, source, warning } = await fetchModelOptions()
550
- if (source === 'curated') {
551
- s.stop(`Using built-in catalog (models.dev unavailable: ${warning ?? 'unknown'})`)
552
- } else {
553
- s.stop('Loaded model catalog.')
554
- }
555
-
556
- const providers = uniqueProviders(options)
557
- const providerChoice = await select({
558
- message: 'Pick an LLM provider',
559
- options: providers.map((id) => ({ value: id, label: KNOWN_PROVIDERS[id].name, hint: providerAuthHint(id) })),
560
- initialValue: providers[0],
561
- })
562
- if (isCancel(providerChoice)) {
563
- cancel('Aborted.')
564
- process.exit(0)
565
- }
566
-
567
- const candidates = options.filter((o) => o.providerId === providerChoice)
568
- const modelChoice = await select<KnownModelRef>({
569
- message: `Pick a ${KNOWN_PROVIDERS[providerChoice].name} model`,
570
- options: candidates.map((o) => ({
571
- value: o.ref,
572
- label: o.modelName,
573
- hint: formatModelHint(o),
574
- })),
575
- initialValue: candidates[0]?.ref,
576
- })
577
- if (isCancel(modelChoice)) {
578
- cancel('Aborted.')
579
- process.exit(0)
580
- }
581
-
582
- const picked = candidates.find((o) => o.ref === modelChoice)
583
- if (!picked) throw new Error(`Internal error: picked model ${modelChoice} not in candidates`)
584
- return picked
585
- }
586
-
587
1067
  function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
588
1068
  const seen = new Set<KnownProviderId>()
589
1069
  const out: KnownProviderId[] = []