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.
- package/README.md +15 -13
- 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 +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- 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 +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- 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 +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -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 +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- 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 +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- 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 +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- 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/reload/client.ts +25 -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 +68 -7
- 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 +83 -0
- package/src/server/index.ts +198 -71
- 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 +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- 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 +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
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
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[] = []
|