typeclaw 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
package/src/cli/init.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { readFile } from 'node:fs/promises'
3
+
1
4
  import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
2
5
  import { defineCommand } from 'citty'
3
6
 
@@ -8,14 +11,17 @@ import {
8
11
  type KnownModelRef,
9
12
  type KnownProviderId,
10
13
  } from '@/config/providers'
11
- import type { DockerAvailability } from '@/container'
14
+ import { checkDockerAvailable, type DockerAvailability } from '@/container'
12
15
  import {
13
16
  findAgentDir,
17
+ formatEagerGithubWebhookInstallResult,
14
18
  hasExistingChannelSecrets,
15
19
  isDirectoryNonEmpty,
16
20
  isHatched,
17
21
  readExistingProviderApiKey,
18
22
  runInit,
23
+ type GithubInitCredentials,
24
+ type GithubTunnelProvider,
19
25
  type InitStep,
20
26
  type InitStepEvent,
21
27
  type KakaotalkAuthResult,
@@ -23,23 +29,48 @@ import {
23
29
  } from '@/init'
24
30
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
25
31
  import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
26
- import { makeOAuthLoginRunner } from '@/init/oauth-login'
32
+ import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
27
33
 
34
+ import { buildOAuthCallbacks } from './oauth-callbacks'
28
35
  import { c, done, errorLine } from './ui'
29
36
 
30
37
  // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
31
38
  // aliases both to the same "cancel" action — there's no way to tell them
32
39
  // apart through @clack/prompts). The wizard treats every cancel as "go
33
40
  // 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
+ // either advances with a value or rewinds.
42
+ //
43
+ // Two cancel patterns must not trap the user:
44
+ // 1. Single-prompt cancel-loop. On the first step (pick-provider) there
45
+ // is no previous step, so `back` re-displays the same prompt. Two
46
+ // consecutive cancels on that same prompt = the user wants out.
47
+ // 2. Auto-advance round-trip. `back` from `enter-api-key` routes to
48
+ // `pick-auth-method`, which for single-method providers (e.g.
49
+ // Fireworks, api-key only) returns its value without prompting and
50
+ // sends the wizard straight back to `enter-api-key`. The user only
51
+ // ever sees the same api-key prompt and has no way to escape.
52
+ //
53
+ // Both patterns are detected in `collectWizardInputs` and surfaced as
54
+ // `WizardAbortedError`, which the `init` command catches and turns into
55
+ // a clean exit. Inside an active clack prompt Ctrl+C is still aliased to
56
+ // cancel, so the abort hotkey is "cancel twice in a row".
57
+ export class WizardAbortedError extends Error {
58
+ // When the wizard ran a successful eager OAuth login before aborting, the
59
+ // resulting credentials are already on disk at `<cwd>/secrets.json`. The
60
+ // CLI surfaces this on abort so the user knows to either re-run init in
61
+ // the same directory (the credentials will be reused) or delete the file.
62
+ readonly oauthCredentialsSaved: boolean
63
+ constructor(options: { oauthCredentialsSaved?: boolean } = {}) {
64
+ super('Wizard aborted by user')
65
+ this.name = 'WizardAbortedError'
66
+ this.oauthCredentialsSaved = options.oauthCredentialsSaved === true
67
+ }
68
+ }
69
+
70
+ export type StepResult<T> = { kind: 'value'; value: T; auto?: boolean } | { kind: 'back' }
41
71
  const back = <T>(): StepResult<T> => ({ kind: 'back' })
42
72
  const value = <T>(v: T): StepResult<T> => ({ kind: 'value', value: v })
73
+ const autoValue = <T>(v: T): StepResult<T> => ({ kind: 'value', value: v, auto: true })
43
74
 
44
75
  export const init = defineCommand({
45
76
  meta: {
@@ -76,12 +107,54 @@ export const init = defineCommand({
76
107
  }
77
108
 
78
109
  intro('Initializing TypeClaw...')
79
- log.info('Press ESC at any prompt to go back to the previous step.')
110
+ log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
111
+
112
+ // Docker preflight runs BEFORE the wizard so an OAuth login (which the
113
+ // wizard fires the moment the user picks "OAuth (browser login)") doesn't
114
+ // burn a real browser flow on an agent folder we can't actually start.
115
+ // `runInit` re-runs the preflight as a defense-in-depth gate, but
116
+ // surfacing the failure here lets the user fix Docker without re-doing
117
+ // every wizard step.
118
+ const preflightSpinner = spinner()
119
+ preflightSpinner.start('Checking Docker...')
120
+ const preflight = await checkDockerAvailable()
121
+ if (!preflight.ok) {
122
+ preflightSpinner.error(preflightFailureSummary(preflight))
123
+ note(preflightFailureGuidance(preflight).join('\n'), 'Docker check failed')
124
+ process.exit(1)
125
+ }
126
+ preflightSpinner.stop('Docker is reachable.')
80
127
 
81
- const collected = await collectWizardInputs(cwd, defaultWizardPrompts)
128
+ let collected: CollectedInputs
129
+ try {
130
+ collected = await collectWizardInputs(cwd, defaultWizardPrompts)
131
+ } catch (error) {
132
+ if (error instanceof WizardAbortedError) {
133
+ if (error.oauthCredentialsSaved) {
134
+ note(
135
+ [
136
+ 'OAuth credentials were saved to `secrets.json` before you aborted.',
137
+ 'Re-run `typeclaw init` here to pick up where you left off (the credentials',
138
+ 'will be reused), or delete `secrets.json` if you want a clean restart.',
139
+ ].join('\n'),
140
+ 'Saved OAuth credentials',
141
+ )
142
+ }
143
+ cancel('Aborted.')
144
+ process.exit(0)
145
+ }
146
+ throw error
147
+ }
82
148
  const { model, llmAuth, vision, channelChoice, reuseExistingChannel, channelSecrets } = collected
83
- const { discordBotToken, slackBotToken, slackAppToken, telegramBotToken, kakaotalkEmail, kakaotalkPassword } =
84
- channelSecrets
149
+ const {
150
+ discordBotToken,
151
+ slackBotToken,
152
+ slackAppToken,
153
+ telegramBotToken,
154
+ kakaotalkEmail,
155
+ kakaotalkPassword,
156
+ github: githubCredentials,
157
+ } = channelSecrets
85
158
 
86
159
  // TODO: add remaining wizard steps from TypeClaw.md once their runtime lands:
87
160
  // - git backup (url + PAT) — Phase 10
@@ -96,7 +169,9 @@ export const init = defineCommand({
96
169
  const reuseSlack = reuseExistingChannel && channelChoice === 'slack'
97
170
  const reuseTelegram = reuseExistingChannel && channelChoice === 'telegram'
98
171
  const reuseKakaotalk = reuseExistingChannel && channelChoice === 'kakaotalk'
172
+ const reuseGithub = reuseExistingChannel && channelChoice === 'github'
99
173
  const wantsKakaotalk = (kakaotalkEmail !== undefined && kakaotalkPassword !== undefined) || reuseKakaotalk
174
+ const wantsGithub = githubCredentials !== undefined || reuseGithub
100
175
  let hatchingOk = false
101
176
  let preflightFailure: Extract<DockerAvailability, { ok: false }> | null = null
102
177
  try {
@@ -130,6 +205,12 @@ export const init = defineCommand({
130
205
  }),
131
206
  }
132
207
  : {}),
208
+ ...(wantsGithub
209
+ ? {
210
+ withGithub: true,
211
+ ...(reuseGithub || githubCredentials === undefined ? {} : { githubCredentials }),
212
+ }
213
+ : {}),
133
214
  onProgress: reportProgress(
134
215
  (ok) => {
135
216
  hatchingOk = ok
@@ -149,6 +230,12 @@ export const init = defineCommand({
149
230
  process.exit(1)
150
231
  }
151
232
 
233
+ if (githubCredentials?.tunnelProvider === 'none') {
234
+ log.warn(
235
+ 'Webhook delivery is disabled until you add a `tunnels[]` entry or set `channels.github.webhookUrl` manually.',
236
+ )
237
+ }
238
+
152
239
  if (hatchingOk) {
153
240
  done({
154
241
  title: c.green('Hatched. Your agent is ready.'),
@@ -179,7 +266,7 @@ interface WizardState {
179
266
  channelReuseExisting?: boolean
180
267
  }
181
268
 
182
- type ChannelChoice = 'slack' | 'discord' | 'telegram' | 'kakaotalk' | 'none'
269
+ type ChannelChoice = 'slack' | 'discord' | 'telegram' | 'kakaotalk' | 'github' | 'none'
183
270
 
184
271
  interface CollectedInputs {
185
272
  model: ModelOption
@@ -201,6 +288,11 @@ interface CollectedInputs {
201
288
  telegramBotToken?: string
202
289
  kakaotalkEmail?: string
203
290
  kakaotalkPassword?: string
291
+ // Structured (auth union + webhook + repo allowlist) rather than flat
292
+ // tokens, so it rides as one sub-object instead of sibling fields.
293
+ // `runInit` delegates to `runAddChannel` for GitHub to keep the github
294
+ // config-writing in one place.
295
+ github?: GithubInitCredentials
204
296
  }
205
297
  }
206
298
 
@@ -250,9 +342,24 @@ export interface WizardPrompts {
250
342
  hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
251
343
  askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
252
344
  runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
253
- buildOAuthAuth: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => LLMAuth
345
+ runOAuthLogin: (
346
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
347
+ cwd: string,
348
+ model: KnownModelRef,
349
+ ) => Promise<OAuthLoginResult>
350
+ // Asked after a failed OAuth login. `apiKeyAvailable` is true when the
351
+ // provider also supports api-key auth (so the wizard can offer a fallback
352
+ // path); false for OAuth-only providers like openai-codex, where the only
353
+ // options are retry or abort.
354
+ askOAuthFailureRecovery: (
355
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
356
+ reason: string,
357
+ apiKeyAvailable: boolean,
358
+ ) => Promise<OAuthFailureRecovery>
254
359
  }
255
360
 
361
+ export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
362
+
256
363
  export const defaultWizardPrompts: WizardPrompts = {
257
364
  loadCatalog,
258
365
  readExistingApiKey: readExistingProviderApiKey,
@@ -267,21 +374,35 @@ export const defaultWizardPrompts: WizardPrompts = {
267
374
  hasExistingChannelSecrets,
268
375
  askReuseExistingChannel,
269
376
  runChannelFlow,
270
- buildOAuthAuth: (provider) => ({
271
- kind: 'oauth',
272
- runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)),
273
- }),
377
+ runOAuthLogin: (provider, cwd, model) => makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))({ cwd, model }),
378
+ askOAuthFailureRecovery,
274
379
  }
275
380
 
276
381
  export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
277
382
  const catalog = await prompts.loadCatalog()
278
383
  const state: WizardState = { catalog }
279
384
  let step: StepId = 'pick-provider'
385
+ let pendingBackOrigin: StepId | null = null
386
+ let oauthCredentialsSaved = false
387
+
388
+ const abort = (): never => {
389
+ throw new WizardAbortedError({ oauthCredentialsSaved })
390
+ }
391
+
392
+ const onResult = <T>(currentStep: StepId, result: StepResult<T>): StepResult<T> => {
393
+ if (result.kind === 'back') {
394
+ if (pendingBackOrigin === currentStep) abort()
395
+ pendingBackOrigin = currentStep
396
+ } else if (!result.auto) {
397
+ pendingBackOrigin = null
398
+ }
399
+ return result
400
+ }
280
401
 
281
402
  while (true) {
282
403
  switch (step) {
283
404
  case 'pick-provider': {
284
- const result = await prompts.pickProvider(catalog.options, state.providerId)
405
+ const result = onResult(step, await prompts.pickProvider(catalog.options, state.providerId))
285
406
  if (result.kind === 'back') {
286
407
  break
287
408
  }
@@ -297,7 +418,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
297
418
  }
298
419
 
299
420
  case 'pick-model': {
300
- const result = await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref)
421
+ const result = onResult(step, await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref))
301
422
  if (result.kind === 'back') {
302
423
  step = 'pick-provider'
303
424
  break
@@ -310,7 +431,10 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
310
431
  case 'reuse-existing-key': {
311
432
  const provider = KNOWN_PROVIDERS[state.providerId!]
312
433
  const existingApiKey = await prompts.readExistingApiKey(cwd, state.providerId!)
313
- const decision = await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting)
434
+ const decision = onResult(
435
+ step,
436
+ await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting),
437
+ )
314
438
  if (decision.kind === 'back') {
315
439
  step = 'pick-model'
316
440
  break
@@ -330,14 +454,39 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
330
454
 
331
455
  case 'pick-auth-method': {
332
456
  const provider = KNOWN_PROVIDERS[state.providerId!]
333
- const result = await prompts.pickAuthMethod(provider, state.authMethod)
457
+ const result = onResult(step, await prompts.pickAuthMethod(provider, state.authMethod))
334
458
  if (result.kind === 'back') {
335
459
  step = 'reuse-existing-key'
336
460
  break
337
461
  }
338
462
  state.authMethod = result.value
339
463
  if (result.value === 'oauth') {
340
- state.llmAuth = prompts.buildOAuthAuth(provider)
464
+ // Run the browser login eagerly so the user sees the OAuth URL the
465
+ // moment they pick "OAuth (browser login)" — not at the end of the
466
+ // wizard. On failure we ask the user how to recover (retry / fall
467
+ // back to API key / abort) instead of dumping them back into the
468
+ // auth method picker with no guidance.
469
+ const login = await runOAuthLoginSafely(prompts, provider, cwd, state.model!.ref)
470
+ if (!login.ok) {
471
+ const recovery = await prompts.askOAuthFailureRecovery(
472
+ provider,
473
+ login.reason,
474
+ providerSupportsApiKey(provider),
475
+ )
476
+ // The recovery prompt is a fresh user decision, so it must clear
477
+ // any back-token left over from an earlier step. Without this, a
478
+ // sequence like `enter-api-key → back → autoValue('oauth') →
479
+ // OAuth fails → recovery=api-key → enter-api-key` would treat the
480
+ // user's NEXT back press as a double-back and abort the wizard.
481
+ pendingBackOrigin = null
482
+ if (recovery === 'abort') abort()
483
+ state.authMethod = recovery === 'api-key' ? 'api-key' : undefined
484
+ state.llmAuth = undefined
485
+ step = recovery === 'api-key' ? 'enter-api-key' : 'pick-auth-method'
486
+ break
487
+ }
488
+ oauthCredentialsSaved = true
489
+ state.llmAuth = { kind: 'oauth-completed' }
341
490
  step = stepAfterDefaultAuth(state)
342
491
  } else {
343
492
  step = 'enter-api-key'
@@ -347,7 +496,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
347
496
 
348
497
  case 'enter-api-key': {
349
498
  const provider = KNOWN_PROVIDERS[state.providerId!]
350
- const result = await prompts.askApiKey(provider)
499
+ const result = onResult(step, await prompts.askApiKey(provider))
351
500
  if (result.kind === 'back') {
352
501
  step = 'pick-auth-method'
353
502
  break
@@ -359,7 +508,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
359
508
 
360
509
  case 'pick-vision-provider': {
361
510
  const visionOptions = catalog.options.filter((o) => o.supportsVision)
362
- const result = await prompts.pickVisionProvider(visionOptions, state.visionProviderId)
511
+ const result = onResult(step, await prompts.pickVisionProvider(visionOptions, state.visionProviderId))
363
512
  if (result.kind === 'back') {
364
513
  step = stepBeforeVision(state)
365
514
  break
@@ -386,7 +535,10 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
386
535
 
387
536
  case 'pick-vision-model': {
388
537
  const visionOptions = catalog.options.filter((o) => o.supportsVision)
389
- const result = await prompts.pickVisionModel(visionOptions, state.visionProviderId!, state.visionModel?.ref)
538
+ const result = onResult(
539
+ step,
540
+ await prompts.pickVisionModel(visionOptions, state.visionProviderId!, state.visionModel?.ref),
541
+ )
390
542
  if (result.kind === 'back') {
391
543
  step = 'pick-vision-provider'
392
544
  break
@@ -415,14 +567,32 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
415
567
 
416
568
  case 'pick-vision-auth-method': {
417
569
  const provider = KNOWN_PROVIDERS[state.visionProviderId!]
418
- const result = await prompts.pickAuthMethod(provider, state.visionAuthMethod)
570
+ const result = onResult(step, await prompts.pickAuthMethod(provider, state.visionAuthMethod))
419
571
  if (result.kind === 'back') {
420
572
  step = 'pick-vision-model'
421
573
  break
422
574
  }
423
575
  state.visionAuthMethod = result.value
424
576
  if (result.value === 'oauth') {
425
- state.visionLlmAuth = prompts.buildOAuthAuth(provider)
577
+ // Same eager-login + recovery-prompt rationale as the default-provider branch above.
578
+ const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
579
+ if (!login.ok) {
580
+ const recovery = await prompts.askOAuthFailureRecovery(
581
+ provider,
582
+ login.reason,
583
+ providerSupportsApiKey(provider),
584
+ )
585
+ // See the matching pendingBackOrigin reset in the default-provider
586
+ // branch above — same reasoning applies to vision auth recovery.
587
+ pendingBackOrigin = null
588
+ if (recovery === 'abort') abort()
589
+ state.visionAuthMethod = recovery === 'api-key' ? 'api-key' : undefined
590
+ state.visionLlmAuth = undefined
591
+ step = recovery === 'api-key' ? 'enter-vision-api-key' : 'pick-vision-auth-method'
592
+ break
593
+ }
594
+ oauthCredentialsSaved = true
595
+ state.visionLlmAuth = { kind: 'oauth-completed' }
426
596
  step = 'pick-channel'
427
597
  } else {
428
598
  step = 'enter-vision-api-key'
@@ -432,7 +602,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
432
602
 
433
603
  case 'enter-vision-api-key': {
434
604
  const provider = KNOWN_PROVIDERS[state.visionProviderId!]
435
- const result = await prompts.askApiKey(provider)
605
+ const result = onResult(step, await prompts.askApiKey(provider))
436
606
  if (result.kind === 'back') {
437
607
  step = 'pick-vision-auth-method'
438
608
  break
@@ -443,7 +613,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
443
613
  }
444
614
 
445
615
  case 'pick-channel': {
446
- const result = await prompts.pickChannel(state.channelChoice)
616
+ const result: StepResult<ChannelChoice> = onResult(step, await prompts.pickChannel(state.channelChoice))
447
617
  if (result.kind === 'back') {
448
618
  step = stepBeforePickChannel(state)
449
619
  break
@@ -467,7 +637,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
467
637
  break
468
638
  }
469
639
  state.channelReuseOffered = true
470
- const decision = await prompts.askReuseExistingChannel(choice)
640
+ const decision = onResult(step, await prompts.askReuseExistingChannel(choice))
471
641
  if (decision.kind === 'back') {
472
642
  step = 'pick-channel'
473
643
  break
@@ -483,7 +653,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
483
653
  }
484
654
 
485
655
  case 'channel-flow': {
486
- const result = await prompts.runChannelFlow(state.channelChoice!)
656
+ const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!))
487
657
  if (result.kind === 'back') {
488
658
  step = state.channelReuseOffered === true ? 'reuse-existing-channel' : 'pick-channel'
489
659
  break
@@ -494,6 +664,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
494
664
  }
495
665
  }
496
666
 
667
+ // Belt-and-suspenders wrapper: `makeOAuthLoginRunner` already catches the
668
+ // upstream pi-ai login flow and returns `{ ok: false, reason }`, but the
669
+ // wizard cannot afford ANY uncaught throw from a custom runner (test seam,
670
+ // future plugin-contributed runner) — it would bubble out of
671
+ // `collectWizardInputs` and exit the whole init. Coerce unexpected throws to
672
+ // the normal failure path so the recovery prompt always fires.
673
+ async function runOAuthLoginSafely(
674
+ prompts: WizardPrompts,
675
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
676
+ cwd: string,
677
+ model: KnownModelRef,
678
+ ): Promise<OAuthLoginResult> {
679
+ try {
680
+ return await prompts.runOAuthLogin(provider, cwd, model)
681
+ } catch (error) {
682
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
683
+ }
684
+ }
685
+
497
686
  function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSecrets']): CollectedInputs {
498
687
  return {
499
688
  model: state.model!,
@@ -517,6 +706,8 @@ function channelDisplayName(choice: Exclude<ChannelChoice, 'none'>): string {
517
706
  return 'Telegram'
518
707
  case 'kakaotalk':
519
708
  return 'KakaoTalk'
709
+ case 'github':
710
+ return 'GitHub'
520
711
  }
521
712
  }
522
713
 
@@ -632,8 +823,7 @@ async function pickAuthMethod(
632
823
  if (isCancel(choice)) return back()
633
824
  return value(choice)
634
825
  }
635
- // Single-method providers: no prompt to back out of, so always advance.
636
- return value(supportsOAuth ? 'oauth' : 'api-key')
826
+ return autoValue(supportsOAuth ? 'oauth' : 'api-key')
637
827
  }
638
828
 
639
829
  async function pickVisionProvider(
@@ -643,7 +833,7 @@ async function pickVisionProvider(
643
833
  const providers = uniqueProviders(options)
644
834
  if (providers.length === 0) {
645
835
  log.warn('No vision-capable models available; skipping vision profile.')
646
- return value('skip')
836
+ return autoValue('skip')
647
837
  }
648
838
  const choice = await select<KnownProviderId | 'skip'>({
649
839
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
@@ -691,6 +881,28 @@ async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): P
691
881
  return value(apiKey)
692
882
  }
693
883
 
884
+ async function askOAuthFailureRecovery(
885
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
886
+ reason: string,
887
+ apiKeyAvailable: boolean,
888
+ ): Promise<OAuthFailureRecovery> {
889
+ note(reason, `${provider.name} OAuth login failed`)
890
+ const options: Array<{ value: OAuthFailureRecovery; label: string; hint?: string }> = [
891
+ { value: 'retry', label: 'Retry OAuth login' },
892
+ ]
893
+ if (apiKeyAvailable) {
894
+ options.push({ value: 'api-key', label: `Use a ${provider.name} API key instead` })
895
+ }
896
+ options.push({ value: 'abort', label: 'Abort init', hint: 'you can re-run `typeclaw init` later' })
897
+ const choice = await select<OAuthFailureRecovery>({
898
+ message: 'What next?',
899
+ options,
900
+ initialValue: 'retry',
901
+ })
902
+ if (isCancel(choice)) return 'abort'
903
+ return choice
904
+ }
905
+
694
906
  async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResult<ChannelChoice>> {
695
907
  const choice = await select<ChannelChoice>({
696
908
  message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
@@ -699,6 +911,7 @@ async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResu
699
911
  { value: 'discord', label: 'Discord' },
700
912
  { value: 'telegram', label: 'Telegram' },
701
913
  { value: 'kakaotalk', label: 'KakaoTalk' },
914
+ { value: 'github', label: 'GitHub' },
702
915
  { value: 'none', label: 'Skip — no channel right now' },
703
916
  ],
704
917
  initialValue: initial ?? 'slack',
@@ -719,6 +932,8 @@ async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<Collect
719
932
  return runSlackFlow()
720
933
  case 'telegram':
721
934
  return runTelegramFlow()
935
+ case 'github':
936
+ return runGithubFlow()
722
937
  }
723
938
  }
724
939
 
@@ -881,6 +1096,151 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
881
1096
  }
882
1097
  }
883
1098
 
1099
+ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1100
+ note(
1101
+ [
1102
+ 'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
1103
+ 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
1104
+ 'Metadata read, and Webhooks read/write (TypeClaw will create and manage the repository webhooks for you).',
1105
+ ].join('\n'),
1106
+ 'Get GitHub credentials',
1107
+ )
1108
+ const authType = await select<'pat' | 'app'>({
1109
+ message: 'GitHub authentication type',
1110
+ options: [
1111
+ { value: 'pat', label: 'Fine-grained personal access token' },
1112
+ { value: 'app', label: 'GitHub App installation token' },
1113
+ ],
1114
+ })
1115
+ if (isCancel(authType)) return back()
1116
+ const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
1117
+ if (auth === null) return back()
1118
+ note('GitHub webhooks need a public URL. TypeClaw can manage a tunnel for you.', 'GitHub webhook tunnel')
1119
+ const tunnelProvider = await select<GithubTunnelProvider>({
1120
+ message: 'Tunnel provider',
1121
+ options: [
1122
+ {
1123
+ value: 'cloudflare-quick',
1124
+ label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
1125
+ },
1126
+ { value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
1127
+ { value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
1128
+ ],
1129
+ initialValue: 'cloudflare-quick',
1130
+ })
1131
+ if (isCancel(tunnelProvider)) return back()
1132
+ const webhookUrl =
1133
+ tunnelProvider === 'external'
1134
+ ? await text({
1135
+ message: 'Public webhook URL (GitHub will POST events here)',
1136
+ validate: (v) => validateGithubUrl(v ?? '', 'Webhook URL is required'),
1137
+ })
1138
+ : undefined
1139
+ if (isCancel(webhookUrl)) return back()
1140
+ const port = await text({
1141
+ message: 'Local webhook port inside the agent container',
1142
+ initialValue: '8975',
1143
+ validate: (v) => {
1144
+ const parsed = Number(v)
1145
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Port must be a positive integer'
1146
+ },
1147
+ })
1148
+ if (isCancel(port)) return back()
1149
+ const secret = await password({
1150
+ message: 'Webhook secret (leave blank to auto-generate)',
1151
+ })
1152
+ if (isCancel(secret)) return back()
1153
+ // clack's password() returns `undefined` on an empty submission (it has no
1154
+ // validate guard and never coerces to ''), so we normalize before the
1155
+ // length checks below to avoid a TypeError on the "leave blank" path.
1156
+ const enteredSecret = typeof secret === 'string' ? secret : ''
1157
+ const reposRaw = await text({
1158
+ message: 'Repositories to allow (comma-separated owner/repo)',
1159
+ validate: (v) => (parseGithubRepos(v ?? '').length > 0 ? undefined : 'At least one owner/repo is required'),
1160
+ })
1161
+ if (isCancel(reposRaw)) return back()
1162
+ const resolvedSecret = enteredSecret.length > 0 ? enteredSecret : randomBytes(32).toString('hex')
1163
+ return value({
1164
+ github: {
1165
+ webhookSecret: resolvedSecret,
1166
+ tunnelProvider,
1167
+ ...(webhookUrl !== undefined ? { webhookUrl } : {}),
1168
+ webhookPort: Number(port),
1169
+ repos: parseGithubRepos(reposRaw),
1170
+ auth,
1171
+ },
1172
+ })
1173
+ }
1174
+
1175
+ async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string } | null> {
1176
+ const pat = await password({
1177
+ message: 'GitHub fine-grained PAT',
1178
+ validate: (v) => (v && v.length > 0 ? undefined : 'PAT is required'),
1179
+ })
1180
+ if (isCancel(pat)) return null
1181
+ return { type: 'pat', pat }
1182
+ }
1183
+
1184
+ async function promptGithubAppAuth(): Promise<{
1185
+ type: 'app'
1186
+ appId: number
1187
+ privateKey: string
1188
+ installationId?: number
1189
+ } | null> {
1190
+ const appId = await text({
1191
+ message: 'GitHub App ID',
1192
+ validate: (v) => validatePositiveInteger(v ?? '', 'App ID is required'),
1193
+ })
1194
+ if (isCancel(appId)) return null
1195
+ const privateKeyInput = await text({
1196
+ message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
1197
+ validate: (v) => (v && v.length > 0 ? undefined : 'Private key is required'),
1198
+ })
1199
+ if (isCancel(privateKeyInput)) return null
1200
+ const installationId = await text({
1201
+ message: 'Installation ID (optional; leave blank to auto-discover)',
1202
+ validate: (v) =>
1203
+ v === undefined || v === '' ? undefined : validatePositiveInteger(v, 'Installation ID is required'),
1204
+ })
1205
+ if (isCancel(installationId)) return null
1206
+ const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
1207
+ return {
1208
+ type: 'app',
1209
+ appId: Number(appId),
1210
+ privateKey: await resolveGithubPrivateKey(privateKeyInput),
1211
+ ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
1212
+ }
1213
+ }
1214
+
1215
+ async function resolveGithubPrivateKey(input: string): Promise<string> {
1216
+ const normalized = input.replace(/\\n/g, '\n')
1217
+ if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
1218
+ return await readFile(input, 'utf8')
1219
+ }
1220
+
1221
+ function parseGithubRepos(input: string): string[] {
1222
+ return input
1223
+ .split(',')
1224
+ .map((v) => v.trim())
1225
+ .filter((v) => /^[^\s/]+\/[^\s/]+$/.test(v))
1226
+ }
1227
+
1228
+ function validateGithubUrl(v: string, requiredMessage: string): string | undefined {
1229
+ if (!v || v.length === 0) return requiredMessage
1230
+ try {
1231
+ new URL(v)
1232
+ return undefined
1233
+ } catch {
1234
+ return 'Must be a valid URL'
1235
+ }
1236
+ }
1237
+
1238
+ function validatePositiveInteger(v: string, requiredMessage: string): string | undefined {
1239
+ if (!v || v.length === 0) return requiredMessage
1240
+ const parsed = Number(v)
1241
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Must be a positive integer'
1242
+ }
1243
+
884
1244
  async function runTelegramFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
885
1245
  note(
886
1246
  [
@@ -950,6 +1310,9 @@ function reportProgress(
950
1310
  case 'kakaotalk-auth':
951
1311
  s.stop(reportKakaotalkAuth(event.result))
952
1312
  break
1313
+ case 'github-webhooks':
1314
+ s.stop(formatEagerGithubWebhookInstallResult(event.result))
1315
+ break
953
1316
  case 'oauth-login':
954
1317
  s.stop(event.result.ok ? 'Logged in.' : `OAuth login failed: ${event.result.reason}`)
955
1318
  break
@@ -1033,37 +1396,6 @@ export async function decideExistingApiKeyReuse(
1033
1396
  return reuse === true ? 'reuse' : 'prompt'
1034
1397
  }
1035
1398
 
1036
- // Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
1037
- // uses: a spinner over the "waiting for login" period, with onAuth printing
1038
- // the URL the user needs to open and onPrompt falling back to a `text`
1039
- // prompt for the manual code path. The spinner is started by onAuth and
1040
- // stopped by the caller (runInit) — we don't try to manage it here because
1041
- // the spinner lifecycle has to span emit('start') -> emit('done').
1042
- function buildOAuthCallbacks(providerName: string) {
1043
- return {
1044
- onAuth: (url: string, instructions?: string) => {
1045
- // Don't put the URL inside note(): clack wraps long lines with the box
1046
- // border `│` on each wrapped segment, which corrupts the URL when the
1047
- // user copy-pastes it. Keep instructional text in the box, but print
1048
- // the URL itself as a bare console.log line that any terminal will
1049
- // hyperlink intact.
1050
- const preamble = [`Open this URL in your browser to authorize ${providerName}.`]
1051
- if (instructions) preamble.push('', instructions)
1052
- note(preamble.join('\n'), 'Browser login')
1053
- console.log(url)
1054
- console.log('')
1055
- },
1056
- onProgress: (message: string) => {
1057
- log.info(message)
1058
- },
1059
- onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
1060
- const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
1061
- if (isCancel(value)) return null
1062
- return value
1063
- },
1064
- }
1065
- }
1066
-
1067
1399
  function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
1068
1400
  const seen = new Set<KnownProviderId>()
1069
1401
  const out: KnownProviderId[] = []
@@ -1096,6 +1428,7 @@ const START_MESSAGES: Record<Exclude<InitStep, 'hatching'>, string> = {
1096
1428
  'oauth-login': 'Waiting for browser login...',
1097
1429
  scaffold: 'Laying the egg...',
1098
1430
  'kakaotalk-auth': 'Logging in to KakaoTalk...',
1431
+ 'github-webhooks': 'Installing GitHub repository webhooks...',
1099
1432
  install: 'Installing dependencies with bun...',
1100
1433
  dockerfile: 'Writing Dockerfile...',
1101
1434
  git: 'Initializing git repository...',