typeclaw 0.10.0 → 0.11.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -4
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  14. package/src/channels/adapters/github/auth-app.ts +53 -9
  15. package/src/channels/adapters/github/auth-pat.ts +4 -1
  16. package/src/channels/adapters/github/auth.ts +10 -0
  17. package/src/channels/adapters/github/event-permissions.ts +83 -0
  18. package/src/channels/adapters/github/inbound.ts +126 -1
  19. package/src/channels/adapters/github/index.ts +60 -66
  20. package/src/channels/adapters/github/outbound.ts +65 -17
  21. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  22. package/src/channels/adapters/github/team-membership.ts +56 -0
  23. package/src/channels/router.ts +213 -32
  24. package/src/channels/schema.ts +8 -7
  25. package/src/channels/types.ts +1 -1
  26. package/src/cli/channel.ts +135 -38
  27. package/src/cli/init.ts +133 -86
  28. package/src/cli/inspect-controller.ts +66 -0
  29. package/src/cli/inspect.ts +24 -32
  30. package/src/cli/run.ts +24 -5
  31. package/src/cli/tui.ts +34 -10
  32. package/src/cli/tunnel.ts +453 -14
  33. package/src/config/config.ts +35 -7
  34. package/src/config/providers.ts +64 -56
  35. package/src/init/env-file.ts +66 -0
  36. package/src/init/hatching.ts +32 -5
  37. package/src/init/index.ts +131 -39
  38. package/src/init/validate-api-key.ts +31 -0
  39. package/src/inspect/index.ts +5 -1
  40. package/src/inspect/loop.ts +12 -1
  41. package/src/inspect/replay.ts +15 -1
  42. package/src/run/codex-fetch-observer.ts +377 -0
  43. package/src/run/index.ts +12 -2
  44. package/src/server/index.ts +59 -1
  45. package/src/shared/protocol.ts +1 -1
  46. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  47. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  48. package/src/tui/index.ts +17 -5
  49. package/src/tunnels/index.ts +1 -0
  50. package/src/tunnels/manager.ts +18 -0
  51. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  52. package/src/tunnels/types.ts +17 -1
  53. package/typeclaw.schema.json +25 -7
@@ -8,8 +8,10 @@ import { config } from '@/config'
8
8
  import { start, status, stop } from '@/container'
9
9
  import {
10
10
  CHANNEL_KINDS,
11
+ appendOrReplaceEnvKey,
11
12
  findAgentDir,
12
13
  formatEagerGithubWebhookInstallResult,
14
+ hasEnvKey,
13
15
  isInitialized,
14
16
  readConfiguredChannels,
15
17
  readGithubAuthType,
@@ -61,7 +63,7 @@ const addSub = defineCommand({
61
63
 
62
64
  intro(`Adding channel: ${CHANNEL_LABELS[channel]}`)
63
65
 
64
- const credentials = await collectCredentials(channel)
66
+ const credentials = await collectCredentials(channel, cwd)
65
67
 
66
68
  const events: AddChannelStepEvent[] = []
67
69
  try {
@@ -518,14 +520,14 @@ async function runSetGithub(cwd: string): Promise<void> {
518
520
  }
519
521
  const authLabel =
520
522
  authType === 'pat'
521
- ? 'Personal access token (PAT) the authentication credential (recommended)'
522
- : 'GitHub App private key the authentication credential (recommended)'
523
+ ? 'Auth credential rotate the PAT, or switch to GitHub App auth (recommended)'
524
+ : 'Auth credential — rotate the App private key, or switch to PAT auth (recommended)'
523
525
  const choice = await select<GithubSetChoice>({
524
- message: 'Which GitHub secret do you want to rotate?',
526
+ message: 'Which GitHub secret do you want to update?',
525
527
  options: [
526
528
  { value: 'auth', label: authLabel },
527
529
  { value: 'webhook', label: 'Webhook secret — shared secret for verifying GitHub payloads' },
528
- { value: 'both', label: 'Both secrets — rotate the auth credential and the webhook secret' },
530
+ { value: 'both', label: 'Both secrets — update the auth credential and the webhook secret' },
529
531
  ],
530
532
  initialValue: 'auth',
531
533
  })
@@ -537,36 +539,7 @@ async function runSetGithub(cwd: string): Promise<void> {
537
539
  const patch: GithubCredentialPatch = {}
538
540
 
539
541
  if (choice === 'auth' || choice === 'both') {
540
- if (authType === 'pat') {
541
- note(
542
- [
543
- 'Rotate at https://github.com/settings/personal-access-tokens.',
544
- 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
545
- 'Metadata read, and Webhooks read/write.',
546
- ].join('\n'),
547
- 'Rotate the GitHub PAT',
548
- )
549
- const { pat } = await promptGithubPatAuth()
550
- patch.auth = { type: 'pat', pat }
551
- } else {
552
- note(
553
- [
554
- 'Rotate at https://github.com/settings/apps/<your-app> → Private keys → Generate a private key.',
555
- 'GitHub immediately downloads the new .pem. The previous key keeps working until you delete it,',
556
- 'so it is safe to rotate without downtime.',
557
- ].join('\n'),
558
- 'Rotate the GitHub App private key',
559
- )
560
- const privateKeyInput = await text({
561
- message: 'New GitHub App private key PEM, escaped PEM, or path to .pem file',
562
- validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
563
- })
564
- if (isCancel(privateKeyInput)) {
565
- cancel('Aborted.')
566
- process.exit(0)
567
- }
568
- patch.auth = { type: 'app', privateKey: await resolvePrivateKeyInput(privateKeyInput) }
569
- }
542
+ patch.auth = await promptGithubAuthUpdate(authType)
570
543
  }
571
544
 
572
545
  if (choice === 'webhook' || choice === 'both') {
@@ -604,7 +577,7 @@ type CollectedCredentials =
604
577
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
605
578
  }
606
579
 
607
- async function collectCredentials(channel: ChannelKind): Promise<CollectedCredentials> {
580
+ async function collectCredentials(channel: ChannelKind, cwd: string): Promise<CollectedCredentials> {
608
581
  switch (channel) {
609
582
  case 'discord-bot':
610
583
  return { channel, discordBotToken: await promptDiscordToken() }
@@ -628,17 +601,19 @@ async function collectCredentials(channel: ChannelKind): Promise<CollectedCreden
628
601
  }
629
602
  }
630
603
  case 'github': {
631
- const creds = await promptGithubCredentials()
604
+ const creds = await promptGithubCredentials(cwd)
632
605
  return { channel, ...creds }
633
606
  }
634
607
  }
635
608
  }
636
609
 
637
- async function promptGithubCredentials(): Promise<{
610
+ async function promptGithubCredentials(cwd: string): Promise<{
638
611
  webhookSecret: string
639
612
  tunnelProvider: GithubTunnelProvider
640
613
  webhookUrl?: string
641
614
  webhookPort?: number
615
+ hostname?: string
616
+ tokenEnv?: string
642
617
  repos: string[]
643
618
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
644
619
  }> {
@@ -670,6 +645,10 @@ async function promptGithubCredentials(): Promise<{
670
645
  value: 'cloudflare-quick',
671
646
  label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
672
647
  },
648
+ {
649
+ value: 'cloudflare-named',
650
+ label: 'Cloudflare Named Tunnel — stable URL, needs Cloudflare account + domain',
651
+ },
673
652
  { value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
674
653
  { value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
675
654
  ],
@@ -690,6 +669,7 @@ async function promptGithubCredentials(): Promise<{
690
669
  cancel('Aborted.')
691
670
  process.exit(0)
692
671
  }
672
+ const namedCreds = tunnelProvider === 'cloudflare-named' ? await promptCloudflareNamedTunnel(cwd) : undefined
693
673
  const port = await text({
694
674
  message: 'Local webhook port inside the agent container',
695
675
  initialValue: '8975',
@@ -727,11 +707,128 @@ async function promptGithubCredentials(): Promise<{
727
707
  tunnelProvider,
728
708
  ...(webhookUrl !== undefined ? { webhookUrl } : {}),
729
709
  webhookPort: Number(port),
710
+ ...(namedCreds !== undefined ? namedCreds : {}),
730
711
  repos: parseRepos(reposRaw),
731
712
  auth,
732
713
  }
733
714
  }
734
715
 
716
+ async function promptCloudflareNamedTunnel(cwd: string): Promise<{ hostname: string; tokenEnv: string }> {
717
+ const tokenEnv = 'CLOUDFLARE_TUNNEL_TOKEN'
718
+ note(
719
+ [
720
+ 'Cloudflare Named Tunnel needs a tunnel you created in the Zero Trust dashboard:',
721
+ ' 1. Networks → Tunnels → Create a tunnel → Cloudflared. Copy the token shown on the install screen.',
722
+ ' 2. Public Hostname tab → Add: subdomain + your-domain, service type HTTP, URL localhost:<webhook port>.',
723
+ ` 3. Paste the token below when prompted — TypeClaw will write it to .env as ${tokenEnv}.`,
724
+ 'A tunnel without a Public Hostname registers but routes nothing.',
725
+ ].join('\n'),
726
+ 'Cloudflare named tunnel',
727
+ )
728
+ const hostname = await text({
729
+ message: 'Public hostname configured in the dashboard (https://...)',
730
+ validate: (value) => validateUrl(value ?? '', 'Hostname is required'),
731
+ })
732
+ if (isCancel(hostname)) {
733
+ cancel('Aborted.')
734
+ process.exit(0)
735
+ }
736
+ if (!hasEnvKey(cwd, tokenEnv)) {
737
+ const token = await password({
738
+ message: `Cloudflare tunnel token (will be written to .env as ${tokenEnv})`,
739
+ validate: (value) => (value && value.length > 0 ? undefined : 'Token is required'),
740
+ })
741
+ if (isCancel(token)) {
742
+ cancel('Aborted.')
743
+ process.exit(0)
744
+ }
745
+ appendOrReplaceEnvKey(cwd, tokenEnv, token)
746
+ }
747
+ return { hostname, tokenEnv }
748
+ }
749
+
750
+ type GithubAuthUpdateAction = 'rotate' | 'switch'
751
+
752
+ async function promptGithubAuthUpdate(currentType: 'pat' | 'app'): Promise<GithubCredentialPatch['auth']> {
753
+ const rotateLabel =
754
+ currentType === 'pat'
755
+ ? 'Rotate the PAT (replace the current personal access token)'
756
+ : 'Rotate the App private key (replace the current GitHub App private key)'
757
+ const switchLabel =
758
+ currentType === 'pat'
759
+ ? 'Switch to GitHub App auth (replace the PAT with App credentials)'
760
+ : 'Switch to PAT auth (replace the App credentials with a personal access token)'
761
+ const action = await select<GithubAuthUpdateAction>({
762
+ message: 'Update the GitHub auth credential',
763
+ options: [
764
+ { value: 'rotate', label: rotateLabel },
765
+ { value: 'switch', label: switchLabel },
766
+ ],
767
+ initialValue: 'rotate',
768
+ })
769
+ if (isCancel(action)) {
770
+ cancel('Aborted.')
771
+ process.exit(0)
772
+ }
773
+
774
+ const nextType: 'pat' | 'app' = action === 'rotate' ? currentType : currentType === 'pat' ? 'app' : 'pat'
775
+
776
+ if (nextType === 'pat') {
777
+ if (action === 'rotate') {
778
+ note(
779
+ [
780
+ 'Rotate at https://github.com/settings/personal-access-tokens.',
781
+ 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
782
+ 'Metadata read, and Webhooks read/write.',
783
+ ].join('\n'),
784
+ 'Rotate the GitHub PAT',
785
+ )
786
+ } else {
787
+ note(
788
+ [
789
+ 'Create a fine-grained PAT at https://github.com/settings/personal-access-tokens.',
790
+ 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
791
+ 'Metadata read, and Webhooks read/write.',
792
+ ].join('\n'),
793
+ 'Switch to GitHub PAT auth',
794
+ )
795
+ }
796
+ return await promptGithubPatAuth()
797
+ }
798
+
799
+ if (action === 'rotate') {
800
+ note(
801
+ [
802
+ 'Rotate at https://github.com/settings/apps/<your-app> → Private keys → Generate a private key.',
803
+ 'GitHub immediately downloads the new .pem. The previous key keeps working until you delete it,',
804
+ 'so it is safe to rotate without downtime.',
805
+ ].join('\n'),
806
+ 'Rotate the GitHub App private key',
807
+ )
808
+ const privateKeyInput = await text({
809
+ message: 'New GitHub App private key PEM, escaped PEM, or path to .pem file',
810
+ validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
811
+ })
812
+ if (isCancel(privateKeyInput)) {
813
+ cancel('Aborted.')
814
+ process.exit(0)
815
+ }
816
+ return { type: 'app', privateKey: await resolvePrivateKeyInput(privateKeyInput) }
817
+ }
818
+
819
+ note(
820
+ [
821
+ 'Create a GitHub App at https://github.com/settings/apps/new and install it on your repositories.',
822
+ 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
823
+ 'Metadata read, and Webhooks read/write.',
824
+ 'Then collect the App ID, generate a private key (.pem), and grab the Installation ID from the URL',
825
+ 'of the installation page (https://github.com/settings/installations/<installation-id>).',
826
+ ].join('\n'),
827
+ 'Switch to GitHub App auth',
828
+ )
829
+ return await promptGithubAppAuth()
830
+ }
831
+
735
832
  async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string }> {
736
833
  const pat = await password({
737
834
  message: 'GitHub fine-grained PAT',
package/src/cli/init.ts CHANGED
@@ -6,7 +6,6 @@ import { defineCommand } from 'citty'
6
6
 
7
7
  import {
8
8
  KNOWN_PROVIDERS,
9
- providerForModelRef,
10
9
  supportsApiKey as providerSupportsApiKey,
11
10
  supportsOAuth as providerSupportsOAuth,
12
11
  type KnownModelRef,
@@ -14,9 +13,12 @@ import {
14
13
  } from '@/config/providers'
15
14
  import { checkDockerAvailable, type DockerAvailability } from '@/container'
16
15
  import {
16
+ appendOrReplaceEnvKey,
17
17
  findAgentDir,
18
18
  formatEagerGithubWebhookInstallResult,
19
+ hasEnvKey,
19
20
  hasExistingChannelSecrets,
21
+ hasExistingOAuthCredentials,
20
22
  isDirectoryNonEmpty,
21
23
  isHatched,
22
24
  readExistingProviderApiKey,
@@ -79,8 +81,17 @@ export const init = defineCommand({
79
81
  name: 'init',
80
82
  description: 'initialize a new typeclaw agent in the current directory',
81
83
  },
82
- async run() {
84
+ args: {
85
+ reset: {
86
+ type: 'boolean',
87
+ description:
88
+ 'ignore any partial secrets.json state from an earlier aborted run and re-prompt for every credential',
89
+ default: false,
90
+ },
91
+ },
92
+ async run({ args }) {
83
93
  const cwd = process.cwd()
94
+ const reset = args.reset === true
84
95
 
85
96
  const existingAgent = findAgentDir(cwd)
86
97
  if (existingAgent !== null && existingAgent !== cwd) {
@@ -129,7 +140,7 @@ export const init = defineCommand({
129
140
 
130
141
  let collected: CollectedInputs
131
142
  try {
132
- collected = await collectWizardInputs(cwd, defaultWizardPrompts)
143
+ collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset })
133
144
  } catch (error) {
134
145
  if (error instanceof WizardAbortedError) {
135
146
  if (error.oauthCredentialsSaved) {
@@ -330,17 +341,13 @@ type StepId =
330
341
  export interface WizardPrompts {
331
342
  loadCatalog: () => Promise<NonNullable<WizardState['catalog']>>
332
343
  readExistingApiKey: (cwd: string, providerId: KnownProviderId) => Promise<string | null>
344
+ hasExistingOAuthCredentials: (cwd: string, providerId: KnownProviderId) => Promise<boolean>
333
345
  pickProvider: (options: ModelOption[], initial: KnownProviderId | undefined) => Promise<StepResult<KnownProviderId>>
334
346
  pickModel: (
335
347
  options: ModelOption[],
336
348
  providerId: KnownProviderId,
337
349
  initial: KnownModelRef | undefined,
338
350
  ) => Promise<StepResult<ModelOption>>
339
- askReuseExistingKey: (
340
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
341
- existingApiKey: string | null,
342
- initial: boolean | undefined,
343
- ) => Promise<StepResult<'reuse' | 'prompt'>>
344
351
  pickAuthMethod: (
345
352
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
346
353
  initial: 'api-key' | 'oauth' | undefined,
@@ -358,17 +365,12 @@ export interface WizardPrompts {
358
365
  ) => Promise<StepResult<ModelOption>>
359
366
  pickChannel: (initial: ChannelChoice | undefined) => Promise<StepResult<ChannelChoice>>
360
367
  hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
361
- askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
362
- runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
368
+ runChannelFlow: (choice: ChannelChoice, cwd: string) => Promise<StepResult<CollectedInputs['channelSecrets']>>
363
369
  runOAuthLogin: (
364
370
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
365
371
  cwd: string,
366
372
  model: KnownModelRef,
367
373
  ) => Promise<OAuthLoginResult>
368
- // Asked after a failed OAuth login. `apiKeyAvailable` is true when the
369
- // provider also supports api-key auth (so the wizard can offer a fallback
370
- // path); false for OAuth-only providers like openai-codex, where the only
371
- // options are retry or abort.
372
374
  askOAuthFailureRecovery: (
373
375
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
374
376
  reason: string,
@@ -376,14 +378,18 @@ export interface WizardPrompts {
376
378
  ) => Promise<OAuthFailureRecovery>
377
379
  }
378
380
 
381
+ export type CollectWizardInputsOptions = {
382
+ reset?: boolean
383
+ }
384
+
379
385
  export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
380
386
 
381
387
  export const defaultWizardPrompts: WizardPrompts = {
382
388
  loadCatalog,
383
389
  readExistingApiKey: readExistingProviderApiKey,
390
+ hasExistingOAuthCredentials,
384
391
  pickProvider,
385
392
  pickModel: pickModelForProvider,
386
- askReuseExistingKey,
387
393
  pickAuthMethod,
388
394
  askApiKey,
389
395
  validateApiKey,
@@ -391,7 +397,6 @@ export const defaultWizardPrompts: WizardPrompts = {
391
397
  pickVisionModel,
392
398
  pickChannel,
393
399
  hasExistingChannelSecrets,
394
- askReuseExistingChannel,
395
400
  runChannelFlow,
396
401
  runOAuthLogin: async (provider, cwd, model) => {
397
402
  const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
@@ -404,7 +409,12 @@ export const defaultWizardPrompts: WizardPrompts = {
404
409
  askOAuthFailureRecovery,
405
410
  }
406
411
 
407
- export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
412
+ export async function collectWizardInputs(
413
+ cwd: string,
414
+ prompts: WizardPrompts,
415
+ options: CollectWizardInputsOptions = {},
416
+ ): Promise<CollectedInputs> {
417
+ const reset = options.reset === true
408
418
  const catalog = await prompts.loadCatalog()
409
419
  const state: WizardState = { catalog }
410
420
  let step: StepId = 'pick-provider'
@@ -425,6 +435,21 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
425
435
  return result
426
436
  }
427
437
 
438
+ const readExistingApiKey = async (providerId: KnownProviderId): Promise<string | null> => {
439
+ if (reset) return null
440
+ return await prompts.readExistingApiKey(cwd, providerId)
441
+ }
442
+
443
+ const hasExistingOAuth = async (providerId: KnownProviderId): Promise<boolean> => {
444
+ if (reset) return false
445
+ return await prompts.hasExistingOAuthCredentials(cwd, providerId)
446
+ }
447
+
448
+ const hasExistingChannel = async (channel: Exclude<ChannelChoice, 'none'>): Promise<boolean> => {
449
+ if (reset) return false
450
+ return await prompts.hasExistingChannelSecrets(cwd, channel)
451
+ }
452
+
428
453
  while (true) {
429
454
  switch (step) {
430
455
  case 'pick-provider': {
@@ -456,17 +481,15 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
456
481
 
457
482
  case 'reuse-existing-key': {
458
483
  const provider = KNOWN_PROVIDERS[state.providerId!]
459
- const existingApiKey = await prompts.readExistingApiKey(cwd, state.providerId!)
460
- const decision = onResult(
461
- step,
462
- await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting),
463
- )
464
- if (decision.kind === 'back') {
465
- step = 'pick-model'
466
- break
467
- }
468
- if (decision.value === 'reuse' && existingApiKey !== null) {
469
- log.info(`Using existing ${provider.name} API key from secrets.json.`)
484
+ // Auto-resume: if `secrets.json` already has a usable api-key for
485
+ // this provider, reuse it silently. Issue #330: re-running init
486
+ // after a partial abort should pick up where the user left off
487
+ // without re-prompting for credentials they already supplied.
488
+ // `--reset` bypasses this by making `readExistingApiKey` return
489
+ // null, falling through to the normal auth-method flow.
490
+ const existingApiKey = await readExistingApiKey(state.providerId!)
491
+ if (existingApiKey !== null) {
492
+ log.info(`Reusing existing ${provider.name} API key from secrets.json.`)
470
493
  state.llmAuth = { kind: 'api-key', apiKey: existingApiKey }
471
494
  state.reuseExisting = true
472
495
  step = stepAfterDefaultAuth(state)
@@ -482,11 +505,29 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
482
505
  const provider = KNOWN_PROVIDERS[state.providerId!]
483
506
  const result = onResult(step, await prompts.pickAuthMethod(provider, state.authMethod))
484
507
  if (result.kind === 'back') {
485
- step = 'reuse-existing-key'
508
+ // Skip past `reuse-existing-key` — it is a silent auto-resume
509
+ // step with no user prompt, so unwind directly to pick-model
510
+ // (the prior user-visible step). This only fires when
511
+ // pickAuthMethod was an interactive choice (dual-auth providers);
512
+ // single-method providers return autoValue and never reach the
513
+ // back branch.
514
+ step = 'pick-model'
486
515
  break
487
516
  }
488
517
  state.authMethod = result.value
489
518
  if (result.value === 'oauth') {
519
+ // Auto-resume: skip the browser flow when OAuth credentials are
520
+ // already on disk from a prior partial run. The fresh-tokens path
521
+ // and the resume path both leave `state.llmAuth = oauth-completed`,
522
+ // so downstream steps (vision, channel, scaffold) can't tell the
523
+ // difference. `--reset` short-circuits this by making
524
+ // `hasExistingOAuth` return false.
525
+ if (await hasExistingOAuth(state.providerId!)) {
526
+ log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
527
+ state.llmAuth = { kind: 'oauth-completed' }
528
+ step = stepAfterDefaultAuth(state)
529
+ break
530
+ }
490
531
  // Run the browser login eagerly so the user sees the OAuth URL the
491
532
  // moment they pick "OAuth (browser login)" — not at the end of the
492
533
  // wizard. On failure we ask the user how to recover (retry / fall
@@ -583,9 +624,9 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
583
624
  step = 'pick-channel'
584
625
  break
585
626
  }
586
- const existingVisionKey = await prompts.readExistingApiKey(cwd, state.visionProviderId!)
627
+ const existingVisionKey = await readExistingApiKey(state.visionProviderId!)
587
628
  if (existingVisionKey !== null) {
588
- log.info(`Using existing ${KNOWN_PROVIDERS[state.visionProviderId!].name} API key from secrets.json.`)
629
+ log.info(`Reusing existing ${KNOWN_PROVIDERS[state.visionProviderId!].name} API key from secrets.json.`)
589
630
  state.visionLlmAuth = { kind: 'api-key', apiKey: existingVisionKey }
590
631
  state.visionReuseExisting = true
591
632
  step = 'pick-channel'
@@ -606,6 +647,16 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
606
647
  }
607
648
  state.visionAuthMethod = result.value
608
649
  if (result.value === 'oauth') {
650
+ // Auto-resume mirror of the default-provider branch above: skip
651
+ // the browser flow when vision OAuth credentials are already on
652
+ // disk. The same `--reset` short-circuit applies via
653
+ // `hasExistingOAuth`.
654
+ if (await hasExistingOAuth(state.visionProviderId!)) {
655
+ log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
656
+ state.visionLlmAuth = { kind: 'oauth-completed' }
657
+ step = 'pick-channel'
658
+ break
659
+ }
609
660
  // Same eager-login + recovery-prompt rationale as the default-provider branch above.
610
661
  const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
611
662
  if (!login.ok) {
@@ -667,31 +718,26 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
667
718
 
668
719
  case 'reuse-existing-channel': {
669
720
  const choice = state.channelChoice as Exclude<ChannelChoice, 'none'>
670
- const present = await prompts.hasExistingChannelSecrets(cwd, choice)
721
+ // Auto-resume: when usable channel credentials already exist on
722
+ // disk, reuse them silently — mirrors the api-key and OAuth
723
+ // resume paths above. `--reset` short-circuits via
724
+ // `hasExistingChannel` returning false, falling through to the
725
+ // normal channel-flow prompts.
726
+ const present = await hasExistingChannel(choice)
671
727
  if (!present) {
672
728
  state.channelReuseOffered = false
673
729
  state.channelReuseExisting = false
674
730
  step = 'channel-flow'
675
731
  break
676
732
  }
733
+ log.info(`Reusing existing ${channelDisplayName(choice)} credentials from secrets.json.`)
677
734
  state.channelReuseOffered = true
678
- const decision = onResult(step, await prompts.askReuseExistingChannel(choice))
679
- if (decision.kind === 'back') {
680
- step = 'pick-channel'
681
- break
682
- }
683
- if (decision.value === 'reuse') {
684
- log.info(`Using existing ${channelDisplayName(choice)} credentials from secrets.json.`)
685
- state.channelReuseExisting = true
686
- return finalize(state, {})
687
- }
688
- state.channelReuseExisting = false
689
- step = 'channel-flow'
690
- break
735
+ state.channelReuseExisting = true
736
+ return finalize(state, {})
691
737
  }
692
738
 
693
739
  case 'channel-flow': {
694
- const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!))
740
+ const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!, cwd))
695
741
  if (result.kind === 'back') {
696
742
  step = state.channelReuseOffered === true ? 'reuse-existing-channel' : 'pick-channel'
697
743
  break
@@ -818,31 +864,6 @@ async function pickModelForProvider(
818
864
  return value(picked)
819
865
  }
820
866
 
821
- async function askReuseExistingKey(
822
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
823
- existingApiKey: string | null,
824
- initial: boolean | undefined,
825
- ): Promise<StepResult<'reuse' | 'prompt'>> {
826
- if (!providerSupportsApiKey(provider) || existingApiKey === null) return value('prompt')
827
- const reuse = await confirm({
828
- message: `Reuse existing ${provider.name} API key from secrets.json?`,
829
- initialValue: initial ?? true,
830
- })
831
- if (isCancel(reuse)) return back()
832
- return value(reuse === true ? 'reuse' : 'prompt')
833
- }
834
-
835
- async function askReuseExistingChannel(
836
- channel: Exclude<ChannelChoice, 'none'>,
837
- ): Promise<StepResult<'reuse' | 'prompt'>> {
838
- const reuse = await confirm({
839
- message: `Reuse existing ${channelDisplayName(channel)} credentials from secrets.json?`,
840
- initialValue: true,
841
- })
842
- if (isCancel(reuse)) return back()
843
- return value(reuse === true ? 'reuse' : 'prompt')
844
- }
845
-
846
867
  async function pickAuthMethod(
847
868
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
848
869
  initial: 'api-key' | 'oauth' | undefined,
@@ -1011,7 +1032,10 @@ async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResu
1011
1032
  return value(choice)
1012
1033
  }
1013
1034
 
1014
- async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1035
+ async function runChannelFlow(
1036
+ choice: ChannelChoice,
1037
+ cwd: string,
1038
+ ): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1015
1039
  switch (choice) {
1016
1040
  case 'none':
1017
1041
  return value({})
@@ -1024,7 +1048,7 @@ async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<Collect
1024
1048
  case 'telegram':
1025
1049
  return runTelegramFlow()
1026
1050
  case 'github':
1027
- return runGithubFlow()
1051
+ return runGithubFlow(cwd)
1028
1052
  }
1029
1053
  }
1030
1054
 
@@ -1144,7 +1168,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
1144
1168
  }
1145
1169
  }
1146
1170
 
1147
- async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1171
+ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1148
1172
  note(
1149
1173
  [
1150
1174
  'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
@@ -1171,6 +1195,10 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
1171
1195
  value: 'cloudflare-quick',
1172
1196
  label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
1173
1197
  },
1198
+ {
1199
+ value: 'cloudflare-named',
1200
+ label: 'Cloudflare Named Tunnel — stable URL, needs Cloudflare account + domain',
1201
+ },
1174
1202
  { value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
1175
1203
  { value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
1176
1204
  ],
@@ -1185,6 +1213,8 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
1185
1213
  })
1186
1214
  : undefined
1187
1215
  if (isCancel(webhookUrl)) return back()
1216
+ const namedCreds = tunnelProvider === 'cloudflare-named' ? await promptGithubCloudflareNamedTunnel(cwd) : undefined
1217
+ if (namedCreds === null) return back()
1188
1218
  const port = await text({
1189
1219
  message: 'Local webhook port inside the agent container',
1190
1220
  initialValue: '8975',
@@ -1214,12 +1244,41 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
1214
1244
  tunnelProvider,
1215
1245
  ...(webhookUrl !== undefined ? { webhookUrl } : {}),
1216
1246
  webhookPort: Number(port),
1247
+ ...(namedCreds !== undefined ? namedCreds : {}),
1217
1248
  repos: parseGithubRepos(reposRaw),
1218
1249
  auth,
1219
1250
  },
1220
1251
  })
1221
1252
  }
1222
1253
 
1254
+ async function promptGithubCloudflareNamedTunnel(cwd: string): Promise<{ hostname: string; tokenEnv: string } | null> {
1255
+ const tokenEnv = 'CLOUDFLARE_TUNNEL_TOKEN'
1256
+ note(
1257
+ [
1258
+ 'Cloudflare Named Tunnel needs a tunnel you created in the Zero Trust dashboard:',
1259
+ ' 1. Networks → Tunnels → Create a tunnel → Cloudflared. Copy the token shown on the install screen.',
1260
+ ' 2. Public Hostname tab → Add: subdomain + your-domain, service type HTTP, URL localhost:<webhook port>.',
1261
+ ` 3. Paste the token below when prompted — TypeClaw will write it to .env as ${tokenEnv}.`,
1262
+ 'A tunnel without a Public Hostname registers but routes nothing.',
1263
+ ].join('\n'),
1264
+ 'Cloudflare named tunnel',
1265
+ )
1266
+ const hostname = await text({
1267
+ message: 'Public hostname configured in the dashboard (https://...)',
1268
+ validate: (v) => validateGithubUrl(v ?? '', 'Hostname is required'),
1269
+ })
1270
+ if (isCancel(hostname)) return null
1271
+ if (!hasEnvKey(cwd, tokenEnv)) {
1272
+ const token = await password({
1273
+ message: `Cloudflare tunnel token (will be written to .env as ${tokenEnv})`,
1274
+ validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
1275
+ })
1276
+ if (isCancel(token)) return null
1277
+ appendOrReplaceEnvKey(cwd, tokenEnv, token)
1278
+ }
1279
+ return { hostname, tokenEnv }
1280
+ }
1281
+
1223
1282
  async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string } | null> {
1224
1283
  const pat = await password({
1225
1284
  message: 'GitHub fine-grained PAT',
@@ -1432,18 +1491,6 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
1432
1491
  }
1433
1492
  }
1434
1493
 
1435
- export async function decideExistingApiKeyReuse(
1436
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
1437
- existingApiKey: string | null,
1438
- askReuse: (message: string) => Promise<unknown>,
1439
- ): Promise<'reuse' | 'prompt' | 'cancel'> {
1440
- if (!providerSupportsApiKey(provider) || existingApiKey === null) return 'prompt'
1441
-
1442
- const reuse = await askReuse(`Reuse existing ${provider.name} API key from secrets.json?`)
1443
- if (isCancel(reuse)) return 'cancel'
1444
- return reuse === true ? 'reuse' : 'prompt'
1445
- }
1446
-
1447
1494
  function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
1448
1495
  const seen = new Set<KnownProviderId>()
1449
1496
  const out: KnownProviderId[] = []