typeclaw 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +190 -61
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- 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
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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[] = []
|