typeclaw 0.1.4 → 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 (134) hide show
  1. package/README.md +15 -13
  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 +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  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 +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  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 +491 -19
  67. package/src/config/index.ts +15 -1
  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 +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
package/src/cli/init.ts CHANGED
@@ -11,8 +11,10 @@ import {
11
11
  import type { DockerAvailability } from '@/container'
12
12
  import {
13
13
  findAgentDir,
14
+ hasExistingChannelSecrets,
14
15
  isDirectoryNonEmpty,
15
16
  isHatched,
17
+ readExistingProviderApiKey,
16
18
  runInit,
17
19
  type InitStep,
18
20
  type InitStepEvent,
@@ -25,6 +27,20 @@ import { makeOAuthLoginRunner } from '@/init/oauth-login'
25
27
 
26
28
  import { c, done, errorLine } from './ui'
27
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
+
28
44
  export const init = defineCommand({
29
45
  meta: {
30
46
  name: 'init',
@@ -60,237 +76,58 @@ export const init = defineCommand({
60
76
  }
61
77
 
62
78
  intro('Initializing TypeClaw...')
79
+ log.info('Press ESC at any prompt to go back to the previous step.')
63
80
 
64
- const selectedModel = await pickModel()
65
- const provider = KNOWN_PROVIDERS[selectedModel.providerId]
66
-
67
- const llmAuth = await collectLLMAuth(provider)
68
-
69
- const channelChoice = await select({
70
- message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + .env)',
71
- options: [
72
- { value: 'slack', label: 'Slack' },
73
- { value: 'discord', label: 'Discord' },
74
- { value: 'telegram', label: 'Telegram' },
75
- { value: 'kakaotalk', label: 'KakaoTalk' },
76
- { value: 'none', label: 'Skip — no channel right now' },
77
- ],
78
- initialValue: 'slack' as const,
79
- })
80
- if (isCancel(channelChoice)) {
81
- cancel('Aborted.')
82
- process.exit(0)
83
- }
84
-
85
- let discordBotToken: string | undefined
86
- let slackBotToken: string | undefined
87
- let slackAppToken: string | undefined
88
- let telegramBotToken: string | undefined
89
- let kakaotalkEmail: string | undefined
90
- let kakaotalkPassword: string | undefined
91
-
92
- if (channelChoice === 'discord') {
93
- note(
94
- [
95
- 'https://discord.com/developers/applications',
96
- 'New Application → Bot tab → Reset Token.',
97
- 'Enable the MESSAGE CONTENT intent.',
98
- ].join('\n'),
99
- 'Get a Discord bot token',
100
- )
101
- const token = await password({
102
- message: 'Discord bot token',
103
- validate: (value) => (value && value.length > 0 ? undefined : 'Token is required'),
104
- })
105
- if (isCancel(token)) {
106
- cancel('Aborted.')
107
- process.exit(0)
108
- }
109
- discordBotToken = token
110
- }
111
-
112
- if (channelChoice === 'kakaotalk') {
113
- note(
114
- [
115
- 'KakaoTalk authentication uses a personal account, registered as a',
116
- 'tablet sub-device. Messages will be sent and received under this',
117
- 'account. Use a non-primary account if possible.',
118
- '',
119
- 'After you submit the password, KakaoTalk may ask you to confirm a',
120
- 'passcode on your phone. Watch the screen for the code.',
121
- ].join('\n'),
122
- 'About to log in to KakaoTalk',
123
- )
124
- const email = await text({
125
- message: 'KakaoTalk email',
126
- validate: (value) => (value && value.length > 0 ? undefined : 'Email is required'),
127
- })
128
- if (isCancel(email)) {
129
- cancel('Aborted.')
130
- process.exit(0)
131
- }
132
- const pwd = await password({
133
- message: 'KakaoTalk password',
134
- validate: (value) => (value && value.length > 0 ? undefined : 'Password is required'),
135
- })
136
- if (isCancel(pwd)) {
137
- cancel('Aborted.')
138
- process.exit(0)
139
- }
140
- kakaotalkEmail = email
141
- kakaotalkPassword = pwd
142
- }
143
-
144
- if (channelChoice === 'slack') {
145
- note(
146
- [
147
- '1. https://api.slack.com/apps → Create New App → From a manifest.',
148
- ' Pick your workspace, then paste this JSON manifest:',
149
- '',
150
- ' {',
151
- ' "display_information": { "name": "TypeClaw" },',
152
- ' "features": {',
153
- ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
154
- ' },',
155
- ' "oauth_config": {',
156
- ' "scopes": {',
157
- ' "bot": [',
158
- ' "app_mentions:read", "chat:write", "users:read", "files:read",',
159
- ' "channels:history", "channels:read",',
160
- ' "groups:history", "groups:read",',
161
- ' "im:history", "im:read",',
162
- ' "mpim:history", "mpim:read"',
163
- ' ]',
164
- ' }',
165
- ' },',
166
- ' "settings": {',
167
- ' "event_subscriptions": {',
168
- ' "bot_events": [',
169
- ' "app_mention",',
170
- ' "message.channels", "message.groups",',
171
- ' "message.im", "message.mpim"',
172
- ' ]',
173
- ' },',
174
- ' "socket_mode_enabled": true',
175
- ' }',
176
- ' }',
177
- '',
178
- '2. Install to Workspace, then OAuth & Permissions →',
179
- ' copy the Bot User OAuth Token (xoxb-...).',
180
- '3. Basic Information → App-Level Tokens → Generate Token and',
181
- ' Scopes, add the connections:write scope, and copy the',
182
- ' token (xapp-...). Socket Mode needs this; the manifest',
183
- ' cannot grant it.',
184
- '4. Invite the bot to any private channel or DM you want it in:',
185
- ' /invite @TypeClaw',
186
- ].join('\n'),
187
- 'Get a Slack bot',
188
- )
189
- const botToken = await password({
190
- message: 'Slack bot token (xoxb-...)',
191
- validate: (value) =>
192
- value && value.length > 0
193
- ? value.startsWith('xoxb-')
194
- ? undefined
195
- : 'Bot token must start with "xoxb-"'
196
- : 'Token is required',
197
- })
198
- if (isCancel(botToken)) {
199
- cancel('Aborted.')
200
- process.exit(0)
201
- }
202
- slackBotToken = botToken
203
- note(
204
- [
205
- 'Slack does not accept connections:write inside the manifest, so',
206
- 'this token has to be generated by hand:',
207
- '',
208
- '1. Basic Information → App-Level Tokens → Generate Token and Scopes.',
209
- '2. Token Name: anything (e.g. "socket-mode").',
210
- '3. Add Scope → connections:write → Generate.',
211
- '4. Copy the xapp-... token shown once on screen.',
212
- ' (You cannot retrieve it later — only revoke and regenerate.)',
213
- ].join('\n'),
214
- 'Generate the Slack app-level token',
215
- )
216
- const appToken = await password({
217
- message: 'Slack app-level token (xapp-...) — Socket Mode requires this',
218
- validate: (value) =>
219
- value && value.length > 0
220
- ? value.startsWith('xapp-')
221
- ? undefined
222
- : 'App-level token must start with "xapp-"'
223
- : 'Token is required',
224
- })
225
- if (isCancel(appToken)) {
226
- cancel('Aborted.')
227
- process.exit(0)
228
- }
229
- slackAppToken = appToken
230
- }
231
-
232
- if (channelChoice === 'telegram') {
233
- note(
234
- [
235
- 'Open Telegram and message @BotFather.',
236
- '/newbot → pick a name and username, copy the HTTP API token',
237
- ' (looks like 1234567890:ABCdef...).',
238
- 'In @BotFather: /setprivacy → Disable, so the bot can see group messages.',
239
- ].join('\n'),
240
- 'Get a Telegram bot token',
241
- )
242
- const token = await password({
243
- message: 'Telegram bot token',
244
- validate: (value) =>
245
- value && value.length > 0
246
- ? /^\d+:/.test(value)
247
- ? undefined
248
- : 'Bot token must look like "<digits>:<secret>" (from @BotFather)'
249
- : 'Token is required',
250
- })
251
- if (isCancel(token)) {
252
- cancel('Aborted.')
253
- process.exit(0)
254
- }
255
- telegramBotToken = token
256
- note(
257
- [
258
- 'Open https://t.me/<your_bot_username> (the username you picked in /newbot, ends in "bot").',
259
- 'Tap Start in the chat — the agent will reply once it hatches.',
260
- 'For groups: add the bot to the group, then @mention it or reply to its messages.',
261
- ].join('\n'),
262
- 'Send your first message',
263
- )
264
- }
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
265
85
 
266
86
  // TODO: add remaining wizard steps from TypeClaw.md once their runtime lands:
267
87
  // - git backup (url + PAT) — Phase 10
268
88
  // - cron.json scaffolding — Phase 9
269
89
  // - compose.yml registration in $HOME/.typeclaw — Phase 12
270
- 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
271
100
  let hatchingOk = false
272
101
  let preflightFailure: Extract<DockerAvailability, { ok: false }> | null = null
273
102
  try {
274
103
  await runInit({
275
104
  cwd,
276
105
  llmAuth,
277
- model: selectedModel.ref,
106
+ model: model.ref,
107
+ ...(vision !== undefined ? { visionModel: vision.model.ref, visionAuth: vision.llmAuth } : {}),
278
108
  cliEntry: process.argv[1],
279
109
  ...(discordBotToken !== undefined ? { discordBotToken } : {}),
280
110
  ...(slackBotToken !== undefined ? { slackBotToken, slackAppToken } : {}),
281
111
  ...(telegramBotToken !== undefined ? { telegramBotToken } : {}),
112
+ ...(reuseDiscord ? { withDiscord: true } : {}),
113
+ ...(reuseSlack ? { withSlack: true } : {}),
114
+ ...(reuseTelegram ? { withTelegram: true } : {}),
282
115
  ...(wantsKakaotalk
283
116
  ? {
284
117
  withKakaotalk: true,
285
- runKakaotalkAuth: ({ cwd: agentDir }) =>
286
- runKakaotalkBootstrap({
287
- email: kakaotalkEmail!,
288
- password: kakaotalkPassword!,
289
- agentDir,
290
- callbacks: {
291
- onPasscode: (code) => log.info(`Confirm this passcode on your phone: ${code}`),
292
- },
293
- }),
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
+ }),
294
131
  }
295
132
  : {}),
296
133
  onProgress: reportProgress(
@@ -325,6 +162,756 @@ export const init = defineCommand({
325
162
  },
326
163
  })
327
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
+
328
915
  function reportProgress(
329
916
  onHatchingDone: (ok: boolean) => void,
330
917
  onPreflightFail: (result: Extract<DockerAvailability, { ok: false }>) => void,
@@ -430,54 +1017,20 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
430
1017
  if (event.result.ok) {
431
1018
  console.log('Hatched. 🐣')
432
1019
  } else {
433
- console.error(`Hatching failed: ${event.result.reason}`)
1020
+ console.error(errorLine(`Hatching failed: ${event.result.reason}`))
434
1021
  }
435
1022
  }
436
1023
 
437
- // Resolves how the user wants to authenticate to the chosen provider:
438
- // - api-key only (e.g. Fireworks): prompt for the key, write to .env.
439
- // - oauth only (e.g. openai-codex): run the browser flow inline, write
440
- // secrets.json. No API key prompt at all.
441
- // - both supported (no providers ship this today, but Anthropic will when
442
- // wired): ask "API key or OAuth?" first, then dispatch to the chosen path.
443
- async function collectLLMAuth(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): Promise<LLMAuth> {
444
- const supportsApiKey = providerSupportsApiKey(provider)
445
- const supportsOAuth = providerSupportsOAuth(provider)
1024
+ export async function decideExistingApiKeyReuse(
1025
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
1026
+ existingApiKey: string | null,
1027
+ askReuse: (message: string) => Promise<unknown>,
1028
+ ): Promise<'reuse' | 'prompt' | 'cancel'> {
1029
+ if (!providerSupportsApiKey(provider) || existingApiKey === null) return 'prompt'
446
1030
 
447
- let method: 'api-key' | 'oauth'
448
- if (supportsApiKey && supportsOAuth) {
449
- const choice = await select<'api-key' | 'oauth'>({
450
- message: `How do you want to authenticate to ${provider.name}?`,
451
- options: [
452
- { value: 'api-key', label: 'API key', hint: `saved to .env as ${provider.apiKeyEnv}` },
453
- { value: 'oauth', label: 'OAuth (browser login)', hint: 'saved to secrets.json' },
454
- ],
455
- initialValue: 'api-key',
456
- })
457
- if (isCancel(choice)) {
458
- cancel('Aborted.')
459
- process.exit(0)
460
- }
461
- method = choice
462
- } else if (supportsOAuth) {
463
- method = 'oauth'
464
- } else {
465
- method = 'api-key'
466
- }
467
-
468
- if (method === 'api-key') {
469
- const apiKey = await password({
470
- message: `Put your ${provider.name} API key (will be saved to .env as ${provider.apiKeyEnv})`,
471
- validate: (value) => (value && value.length > 0 ? undefined : 'API key is required'),
472
- })
473
- if (isCancel(apiKey)) {
474
- cancel('Aborted.')
475
- process.exit(0)
476
- }
477
- return { kind: 'api-key', apiKey }
478
- }
479
-
480
- return { kind: 'oauth', runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)) }
1031
+ const reuse = await askReuse(`Reuse existing ${provider.name} API key from secrets.json?`)
1032
+ if (isCancel(reuse)) return 'cancel'
1033
+ return reuse === true ? 'reuse' : 'prompt'
481
1034
  }
482
1035
 
483
1036
  // Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
@@ -511,50 +1064,6 @@ function buildOAuthCallbacks(providerName: string) {
511
1064
  }
512
1065
  }
513
1066
 
514
- // Two-step provider+model picker. We split it because most users have a key
515
- // for exactly one provider — asking them to scroll through a flat list of
516
- // every (provider, model) pair would surface options they can't use.
517
- async function pickModel(): Promise<ModelOption> {
518
- const s = spinner()
519
- s.start('Loading model catalog from models.dev...')
520
- const { options, source, warning } = await fetchModelOptions()
521
- if (source === 'curated') {
522
- s.stop(`Using built-in catalog (models.dev unavailable: ${warning ?? 'unknown'})`)
523
- } else {
524
- s.stop('Loaded model catalog.')
525
- }
526
-
527
- const providers = uniqueProviders(options)
528
- const providerChoice = await select({
529
- message: 'Pick an LLM provider',
530
- options: providers.map((id) => ({ value: id, label: KNOWN_PROVIDERS[id].name, hint: providerAuthHint(id) })),
531
- initialValue: providers[0],
532
- })
533
- if (isCancel(providerChoice)) {
534
- cancel('Aborted.')
535
- process.exit(0)
536
- }
537
-
538
- const candidates = options.filter((o) => o.providerId === providerChoice)
539
- const modelChoice = await select<KnownModelRef>({
540
- message: `Pick a ${KNOWN_PROVIDERS[providerChoice].name} model`,
541
- options: candidates.map((o) => ({
542
- value: o.ref,
543
- label: o.modelName,
544
- hint: formatModelHint(o),
545
- })),
546
- initialValue: candidates[0]?.ref,
547
- })
548
- if (isCancel(modelChoice)) {
549
- cancel('Aborted.')
550
- process.exit(0)
551
- }
552
-
553
- const picked = candidates.find((o) => o.ref === modelChoice)
554
- if (!picked) throw new Error(`Internal error: picked model ${modelChoice} not in candidates`)
555
- return picked
556
- }
557
-
558
1067
  function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
559
1068
  const seen = new Set<KnownProviderId>()
560
1069
  const out: KnownProviderId[] = []