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
@@ -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
 
@@ -6,11 +9,17 @@ import { start, status, stop } from '@/container'
6
9
  import {
7
10
  CHANNEL_KINDS,
8
11
  findAgentDir,
12
+ formatEagerGithubWebhookInstallResult,
9
13
  isInitialized,
10
14
  readConfiguredChannels,
15
+ readGithubAuthType,
11
16
  runAddChannel,
17
+ setChannelSecrets,
18
+ setGithubSecrets,
12
19
  type AddChannelStepEvent,
13
20
  type ChannelKind,
21
+ type GithubCredentialPatch,
22
+ type GithubTunnelProvider,
14
23
  type KakaotalkAuthResult,
15
24
  } from '@/init'
16
25
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
@@ -23,6 +32,7 @@ const CHANNEL_LABELS: Record<ChannelKind, string> = {
23
32
  'discord-bot': 'Discord',
24
33
  'telegram-bot': 'Telegram',
25
34
  kakaotalk: 'KakaoTalk',
35
+ github: 'GitHub',
26
36
  }
27
37
 
28
38
  const addSub = defineCommand({
@@ -60,6 +70,11 @@ const addSub = defineCommand({
60
70
  ...credentials,
61
71
  onProgress: reportProgress(events),
62
72
  })
73
+ if (credentials.channel === 'github' && credentials.tunnelProvider === 'none') {
74
+ log.warn(
75
+ 'Webhook delivery is disabled until you add a `tunnels[]` entry or set `channels.github.webhookUrl` manually.',
76
+ )
77
+ }
63
78
  } catch (error) {
64
79
  console.error(errorLine(error instanceof Error ? error.message : String(error)))
65
80
  process.exit(1)
@@ -69,11 +84,58 @@ const addSub = defineCommand({
69
84
  },
70
85
  })
71
86
 
72
- // Only adapters with an interactive credential flow appear here. Bot tokens
73
- // (Discord/Slack/Telegram) are rotated by editing secrets.json or .env
74
- // directlythey don't need a guided CLI flow because there's no
75
- // passcode-on-phone equivalent. KakaoTalk is the only adapter that does, so
76
- // it's the only adapter that needs `reauth`.
87
+ // Adapters whose credentials are rotated via the generic `channel set` flow:
88
+ // one or more named token fields, no passcode-on-phone, no encryption envelope.
89
+ // KakaoTalk is excluded by design it has its own `channel reauth` flow that
90
+ // replays the full interactive login (see REAUTHABLE_ADAPTERS below). GitHub
91
+ // is included here but routed through its own prompt path because it has
92
+ // three independent secrets (PAT or App private key + webhook secret) and a
93
+ // structural auth-type flip is forbidden during rotation.
94
+ const SETTABLE_ADAPTERS = ['slack-bot', 'discord-bot', 'telegram-bot', 'github'] as const
95
+ type SettableAdapter = (typeof SETTABLE_ADAPTERS)[number]
96
+
97
+ const setSub = defineCommand({
98
+ meta: {
99
+ name: 'set',
100
+ description: 'rotate credentials of an already-configured channel adapter (symmetric with `typeclaw provider set`)',
101
+ },
102
+ args: {
103
+ adapter: {
104
+ type: 'positional',
105
+ description: `which adapter to rotate (${SETTABLE_ADAPTERS.join(' | ')}); omit to pick interactively`,
106
+ required: false,
107
+ },
108
+ },
109
+ async run({ args }) {
110
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
111
+
112
+ if (!isInitialized(cwd)) {
113
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
114
+ process.exit(1)
115
+ }
116
+
117
+ const configured = await readConfiguredChannels(cwd)
118
+
119
+ if (args.adapter === 'kakaotalk') {
120
+ console.error(
121
+ errorLine(
122
+ 'KakaoTalk uses an interactive auth flow (phone passcode + device_uuid). Use `typeclaw channel reauth kakaotalk` to rotate its credentials.',
123
+ ),
124
+ )
125
+ process.exit(1)
126
+ }
127
+
128
+ const adapter =
129
+ args.adapter === undefined
130
+ ? await pickSettableAdapter(configured)
131
+ : validateSetAdapterArg(args.adapter, configured)
132
+
133
+ intro(`Rotating channel: ${CHANNEL_LABELS[adapter]}`)
134
+
135
+ await runSet(cwd, adapter)
136
+ },
137
+ })
138
+
77
139
  const REAUTHABLE_ADAPTERS = ['kakaotalk'] as const
78
140
  type ReauthableAdapter = (typeof REAUTHABLE_ADAPTERS)[number]
79
141
 
@@ -114,6 +176,7 @@ export const channelCommand = defineCommand({
114
176
  },
115
177
  subCommands: {
116
178
  add: addSub,
179
+ set: setSub,
117
180
  reauth: reauthSub,
118
181
  },
119
182
  })
@@ -220,11 +283,18 @@ async function readExistingKakaotalkEmail(cwd: string): Promise<string | undefin
220
283
  // We can't reliably distinguish the last two cases from outside the container
221
284
  // without calling reload first, so the next-step hints surface both paths.
222
285
  async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter): Promise<void> {
223
- const label = CHANNEL_LABELS[adapter]
286
+ await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS[adapter], 're-authenticated')
287
+ }
288
+
289
+ async function maybePromptCredentialRefresh(
290
+ cwd: string,
291
+ label: string,
292
+ verbPast: 're-authenticated' | 'credentials updated',
293
+ ): Promise<void> {
224
294
  const current = await status({ cwd }).catch(() => null)
225
295
  if (current === null || current.kind !== 'running') {
226
296
  done({
227
- title: c.green(`${label} re-authenticated.`),
297
+ title: c.green(`${label} ${verbPast}.`),
228
298
  hints: [
229
299
  { label: 'Start the agent:', command: 'typeclaw start' },
230
300
  { label: 'Then check status:', command: 'typeclaw status' },
@@ -240,7 +310,7 @@ async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter)
240
310
  })
241
311
  if (isCancel(restartNow) || !restartNow) {
242
312
  done({
243
- title: c.green(`${label} re-authenticated.`),
313
+ title: c.green(`${label} ${verbPast}.`),
244
314
  hints: [
245
315
  { label: 'Try a live reload first:', command: 'typeclaw reload' },
246
316
  { label: 'If reload reports restart-required:', command: 'typeclaw restart' },
@@ -260,9 +330,7 @@ async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter)
260
330
  process.exit(1)
261
331
  }
262
332
  done({
263
- title: c.green(
264
- `${label} re-authenticated. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`,
265
- ),
333
+ title: c.green(`${label} ${verbPast}. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`),
266
334
  hints: [
267
335
  { label: 'Attach TUI:', command: 'typeclaw tui' },
268
336
  { label: 'Follow logs:', command: 'typeclaw logs -f' },
@@ -309,11 +377,232 @@ async function pickChannel(configured: Set<ChannelKind>): Promise<ChannelKind> {
309
377
  return selected
310
378
  }
311
379
 
380
+ function isSettableAdapter(value: string): value is SettableAdapter {
381
+ return (SETTABLE_ADAPTERS as ReadonlyArray<string>).includes(value)
382
+ }
383
+
384
+ function validateSetAdapterArg(adapter: string, configured: Set<ChannelKind>): SettableAdapter {
385
+ if (!isSettableAdapter(adapter)) {
386
+ if (isChannelKind(adapter)) {
387
+ console.error(
388
+ errorLine(
389
+ `Adapter "${adapter}" does not support \`channel set\`. Use \`typeclaw channel reauth ${adapter}\` instead.`,
390
+ ),
391
+ )
392
+ } else {
393
+ console.error(errorLine(`Unknown adapter "${adapter}". Expected one of: ${SETTABLE_ADAPTERS.join(', ')}.`))
394
+ }
395
+ process.exit(1)
396
+ }
397
+ if (!configured.has(adapter)) {
398
+ console.error(
399
+ errorLine(
400
+ `${CHANNEL_LABELS[adapter]} ("${adapter}") is not configured in typeclaw.json. Run \`typeclaw channel add ${adapter}\` first.`,
401
+ ),
402
+ )
403
+ process.exit(1)
404
+ }
405
+ return adapter
406
+ }
407
+
408
+ async function pickSettableAdapter(configured: Set<ChannelKind>): Promise<SettableAdapter> {
409
+ const available = SETTABLE_ADAPTERS.filter((kind) => configured.has(kind))
410
+ if (available.length === 0) {
411
+ console.error(
412
+ errorLine(
413
+ 'No rotatable channels are configured. Run `typeclaw channel add <adapter>` first, or use `typeclaw channel reauth kakaotalk` for KakaoTalk.',
414
+ ),
415
+ )
416
+ process.exit(1)
417
+ }
418
+ if (available.length === 1) return available[0]!
419
+
420
+ const selected = await select<SettableAdapter>({
421
+ message: 'Pick a channel to rotate credentials for',
422
+ options: available.map((kind) => ({ value: kind, label: CHANNEL_LABELS[kind] })),
423
+ initialValue: available[0],
424
+ })
425
+ if (isCancel(selected)) {
426
+ cancel('Aborted.')
427
+ process.exit(0)
428
+ }
429
+ return selected
430
+ }
431
+
432
+ async function runSet(cwd: string, adapter: SettableAdapter): Promise<void> {
433
+ switch (adapter) {
434
+ case 'discord-bot':
435
+ await runSetDiscord(cwd)
436
+ break
437
+ case 'telegram-bot':
438
+ await runSetTelegram(cwd)
439
+ break
440
+ case 'slack-bot':
441
+ await runSetSlack(cwd)
442
+ break
443
+ case 'github':
444
+ await runSetGithub(cwd)
445
+ break
446
+ }
447
+ }
448
+
449
+ async function runSetDiscord(cwd: string): Promise<void> {
450
+ const token = await promptDiscordToken()
451
+ const result = await setChannelSecrets(cwd, 'discord-bot', { token })
452
+ if (!result.ok) {
453
+ console.error(errorLine(result.reason))
454
+ process.exit(1)
455
+ }
456
+ await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS['discord-bot'], 'credentials updated')
457
+ }
458
+
459
+ async function runSetTelegram(cwd: string): Promise<void> {
460
+ const token = await promptTelegramToken()
461
+ const result = await setChannelSecrets(cwd, 'telegram-bot', { token })
462
+ if (!result.ok) {
463
+ console.error(errorLine(result.reason))
464
+ process.exit(1)
465
+ }
466
+ await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS['telegram-bot'], 'credentials updated')
467
+ }
468
+
469
+ type SlackSetChoice = 'bot' | 'app' | 'both'
470
+
471
+ async function runSetSlack(cwd: string): Promise<void> {
472
+ note(
473
+ [
474
+ 'Rotate at https://api.slack.com/apps → your app:',
475
+ ' Bot token (xoxb-...) — OAuth & Permissions → Reset Token.',
476
+ ' App-level token (xapp-...) — Basic Information → App-Level Tokens → Revoke and regenerate.',
477
+ 'Slack only shows the app-level token once on screen — copy it before closing.',
478
+ ].join('\n'),
479
+ 'Rotate the Slack tokens',
480
+ )
481
+ const choice = await select<SlackSetChoice>({
482
+ message: 'Which Slack token do you want to rotate?',
483
+ options: [
484
+ { value: 'bot', label: 'Bot user token (xoxb-...) — used to post messages as the bot (recommended)' },
485
+ { value: 'app', label: 'App-level token (xapp-...) — required for Socket Mode' },
486
+ { value: 'both', label: 'Both tokens — rotate the bot token and the app-level token' },
487
+ ],
488
+ initialValue: 'bot',
489
+ })
490
+ if (isCancel(choice)) {
491
+ cancel('Aborted.')
492
+ process.exit(0)
493
+ }
494
+
495
+ const tokens: Record<string, string> = {}
496
+ if (choice === 'bot' || choice === 'both') tokens.botToken = await promptSlackBotToken()
497
+ if (choice === 'app' || choice === 'both') tokens.appToken = await promptSlackAppToken()
498
+
499
+ const result = await setChannelSecrets(cwd, 'slack-bot', tokens)
500
+ if (!result.ok) {
501
+ console.error(errorLine(result.reason))
502
+ process.exit(1)
503
+ }
504
+ await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS['slack-bot'], 'credentials updated')
505
+ }
506
+
507
+ type GithubSetChoice = 'auth' | 'webhook' | 'both'
508
+
509
+ async function runSetGithub(cwd: string): Promise<void> {
510
+ const authType = readGithubAuthType(cwd)
511
+ if (authType === null) {
512
+ console.error(
513
+ errorLine(
514
+ 'GitHub auth block is missing or malformed in secrets.json. Run `typeclaw channel add github` first, or fix the file by hand.',
515
+ ),
516
+ )
517
+ process.exit(1)
518
+ }
519
+ const authLabel =
520
+ authType === 'pat'
521
+ ? 'Personal access token (PAT) — the authentication credential (recommended)'
522
+ : 'GitHub App private key — the authentication credential (recommended)'
523
+ const choice = await select<GithubSetChoice>({
524
+ message: 'Which GitHub secret do you want to rotate?',
525
+ options: [
526
+ { value: 'auth', label: authLabel },
527
+ { 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' },
529
+ ],
530
+ initialValue: 'auth',
531
+ })
532
+ if (isCancel(choice)) {
533
+ cancel('Aborted.')
534
+ process.exit(0)
535
+ }
536
+
537
+ const patch: GithubCredentialPatch = {}
538
+
539
+ 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
+ }
570
+ }
571
+
572
+ if (choice === 'webhook' || choice === 'both') {
573
+ const secret = await password({
574
+ message: 'New webhook secret (leave blank to auto-generate)',
575
+ })
576
+ if (isCancel(secret)) {
577
+ cancel('Aborted.')
578
+ process.exit(0)
579
+ }
580
+ const enteredSecret = typeof secret === 'string' ? secret : ''
581
+ patch.webhookSecret = enteredSecret.length > 0 ? enteredSecret : randomBytes(32).toString('hex')
582
+ }
583
+
584
+ const result = await setGithubSecrets(cwd, patch)
585
+ if (!result.ok) {
586
+ console.error(errorLine(result.reason))
587
+ process.exit(1)
588
+ }
589
+ await maybePromptCredentialRefresh(cwd, CHANNEL_LABELS.github, 'credentials updated')
590
+ }
591
+
312
592
  type CollectedCredentials =
313
593
  | { channel: 'discord-bot'; discordBotToken: string }
314
594
  | { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
315
595
  | { channel: 'telegram-bot'; telegramBotToken: string }
316
596
  | { channel: 'kakaotalk'; runKakaotalkAuth: (options: { cwd: string }) => Promise<KakaotalkAuthResult> }
597
+ | {
598
+ channel: 'github'
599
+ webhookSecret: string
600
+ tunnelProvider: GithubTunnelProvider
601
+ webhookUrl?: string
602
+ webhookPort?: number
603
+ repos: string[]
604
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
605
+ }
317
606
 
318
607
  async function collectCredentials(channel: ChannelKind): Promise<CollectedCredentials> {
319
608
  switch (channel) {
@@ -338,9 +627,192 @@ async function collectCredentials(channel: ChannelKind): Promise<CollectedCreden
338
627
  }),
339
628
  }
340
629
  }
630
+ case 'github': {
631
+ const creds = await promptGithubCredentials()
632
+ return { channel, ...creds }
633
+ }
341
634
  }
342
635
  }
343
636
 
637
+ async function promptGithubCredentials(): Promise<{
638
+ webhookSecret: string
639
+ tunnelProvider: GithubTunnelProvider
640
+ webhookUrl?: string
641
+ webhookPort?: number
642
+ repos: string[]
643
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
644
+ }> {
645
+ note(
646
+ [
647
+ 'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
648
+ 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
649
+ 'Metadata read, and Webhooks read/write (TypeClaw will create and manage the repository webhooks for you).',
650
+ ].join('\n'),
651
+ 'Get GitHub credentials',
652
+ )
653
+ const authType = await select({
654
+ message: 'GitHub authentication type',
655
+ options: [
656
+ { value: 'pat', label: 'Fine-grained personal access token' },
657
+ { value: 'app', label: 'GitHub App installation token' },
658
+ ],
659
+ })
660
+ if (isCancel(authType)) {
661
+ cancel('Aborted.')
662
+ process.exit(0)
663
+ }
664
+ const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
665
+ note('GitHub webhooks need a public URL. TypeClaw can manage a tunnel for you.', 'GitHub webhook tunnel')
666
+ const tunnelProvider = await select<GithubTunnelProvider>({
667
+ message: 'Tunnel provider',
668
+ options: [
669
+ {
670
+ value: 'cloudflare-quick',
671
+ label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
672
+ },
673
+ { value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
674
+ { value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
675
+ ],
676
+ initialValue: 'cloudflare-quick',
677
+ })
678
+ if (isCancel(tunnelProvider)) {
679
+ cancel('Aborted.')
680
+ process.exit(0)
681
+ }
682
+ const webhookUrl =
683
+ tunnelProvider === 'external'
684
+ ? await text({
685
+ message: 'Public webhook URL (GitHub will POST events here)',
686
+ validate: (value) => validateUrl(value ?? '', 'Webhook URL is required'),
687
+ })
688
+ : undefined
689
+ if (isCancel(webhookUrl)) {
690
+ cancel('Aborted.')
691
+ process.exit(0)
692
+ }
693
+ const port = await text({
694
+ message: 'Local webhook port inside the agent container',
695
+ initialValue: '8975',
696
+ validate: (value) => {
697
+ const parsed = Number(value)
698
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Port must be a positive integer'
699
+ },
700
+ })
701
+ if (isCancel(port)) {
702
+ cancel('Aborted.')
703
+ process.exit(0)
704
+ }
705
+ const secret = await password({
706
+ message: 'Webhook secret (leave blank to auto-generate)',
707
+ })
708
+ if (isCancel(secret)) {
709
+ cancel('Aborted.')
710
+ process.exit(0)
711
+ }
712
+ // clack's password() returns `undefined` on an empty submission (it has no
713
+ // validate guard and never coerces to ''), so we normalize before the
714
+ // length checks below to avoid a TypeError on the "leave blank" path.
715
+ const enteredSecret = typeof secret === 'string' ? secret : ''
716
+ const reposRaw = await text({
717
+ message: 'Repositories to allow (comma-separated owner/repo)',
718
+ validate: (value) => (parseRepos(value ?? '').length > 0 ? undefined : 'At least one owner/repo is required'),
719
+ })
720
+ if (isCancel(reposRaw)) {
721
+ cancel('Aborted.')
722
+ process.exit(0)
723
+ }
724
+ const resolvedSecret = enteredSecret.length > 0 ? enteredSecret : randomBytes(32).toString('hex')
725
+ return {
726
+ webhookSecret: resolvedSecret,
727
+ tunnelProvider,
728
+ ...(webhookUrl !== undefined ? { webhookUrl } : {}),
729
+ webhookPort: Number(port),
730
+ repos: parseRepos(reposRaw),
731
+ auth,
732
+ }
733
+ }
734
+
735
+ async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string }> {
736
+ const pat = await password({
737
+ message: 'GitHub fine-grained PAT',
738
+ validate: (value) => (value && value.length > 0 ? undefined : 'PAT is required'),
739
+ })
740
+ if (isCancel(pat)) {
741
+ cancel('Aborted.')
742
+ process.exit(0)
743
+ }
744
+ return { type: 'pat', pat }
745
+ }
746
+
747
+ async function promptGithubAppAuth(): Promise<{
748
+ type: 'app'
749
+ appId: number
750
+ privateKey: string
751
+ installationId?: number
752
+ }> {
753
+ const appId = await text({
754
+ message: 'GitHub App ID',
755
+ validate: (value) => validatePositiveInteger(value ?? '', 'App ID is required'),
756
+ })
757
+ if (isCancel(appId)) {
758
+ cancel('Aborted.')
759
+ process.exit(0)
760
+ }
761
+ const privateKeyInput = await text({
762
+ message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
763
+ validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
764
+ })
765
+ if (isCancel(privateKeyInput)) {
766
+ cancel('Aborted.')
767
+ process.exit(0)
768
+ }
769
+ const installationId = await text({
770
+ message: 'Installation ID (optional; leave blank to auto-discover)',
771
+ validate: (value) =>
772
+ value === undefined || value === '' ? undefined : validatePositiveInteger(value, 'Installation ID is required'),
773
+ })
774
+ if (isCancel(installationId)) {
775
+ cancel('Aborted.')
776
+ process.exit(0)
777
+ }
778
+ const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
779
+ return {
780
+ type: 'app',
781
+ appId: Number(appId),
782
+ privateKey: await resolvePrivateKeyInput(privateKeyInput),
783
+ ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
784
+ }
785
+ }
786
+
787
+ async function resolvePrivateKeyInput(input: string): Promise<string> {
788
+ const normalized = input.replace(/\\n/g, '\n')
789
+ if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
790
+ return await readFile(input, 'utf8')
791
+ }
792
+
793
+ function parseRepos(input: string): string[] {
794
+ return input
795
+ .split(',')
796
+ .map((v) => v.trim())
797
+ .filter((v) => /^[^\s/]+\/[^\s/]+$/.test(v))
798
+ }
799
+
800
+ function validateUrl(value: string, requiredMessage: string): string | undefined {
801
+ if (!value || value.length === 0) return requiredMessage
802
+ try {
803
+ new URL(value)
804
+ return undefined
805
+ } catch {
806
+ return 'Must be a valid URL'
807
+ }
808
+ }
809
+
810
+ function validatePositiveInteger(value: string, requiredMessage: string): string | undefined {
811
+ if (!value || value.length === 0) return requiredMessage
812
+ const parsed = Number(value)
813
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Must be a positive integer'
814
+ }
815
+
344
816
  async function promptDiscordToken(): Promise<string> {
345
817
  note(
346
818
  [
@@ -406,19 +878,7 @@ async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
406
878
  ].join('\n'),
407
879
  'Get a Slack bot',
408
880
  )
409
- const botToken = await password({
410
- message: 'Slack bot token (xoxb-...)',
411
- validate: (value) =>
412
- value && value.length > 0
413
- ? value.startsWith('xoxb-')
414
- ? undefined
415
- : 'Bot token must start with "xoxb-"'
416
- : 'Token is required',
417
- })
418
- if (isCancel(botToken)) {
419
- cancel('Aborted.')
420
- process.exit(0)
421
- }
881
+ const bot = await promptSlackBotToken()
422
882
  note(
423
883
  [
424
884
  'Slack does not accept connections:write inside the manifest, so',
@@ -432,6 +892,28 @@ async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
432
892
  ].join('\n'),
433
893
  'Generate the Slack app-level token',
434
894
  )
895
+ const app = await promptSlackAppToken()
896
+ return { bot, app }
897
+ }
898
+
899
+ async function promptSlackBotToken(): Promise<string> {
900
+ const botToken = await password({
901
+ message: 'Slack bot token (xoxb-...)',
902
+ validate: (value) =>
903
+ value && value.length > 0
904
+ ? value.startsWith('xoxb-')
905
+ ? undefined
906
+ : 'Bot token must start with "xoxb-"'
907
+ : 'Token is required',
908
+ })
909
+ if (isCancel(botToken)) {
910
+ cancel('Aborted.')
911
+ process.exit(0)
912
+ }
913
+ return botToken
914
+ }
915
+
916
+ async function promptSlackAppToken(): Promise<string> {
435
917
  const appToken = await password({
436
918
  message: 'Slack app-level token (xapp-...) — Socket Mode requires this',
437
919
  validate: (value) =>
@@ -445,7 +927,7 @@ async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
445
927
  cancel('Aborted.')
446
928
  process.exit(0)
447
929
  }
448
- return { bot: botToken, app: appToken }
930
+ return appToken
449
931
  }
450
932
 
451
933
  async function promptTelegramToken(): Promise<string> {
@@ -534,6 +1016,9 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
534
1016
  case 'secrets':
535
1017
  s.stop('Saved credentials to secrets.json.')
536
1018
  break
1019
+ case 'github-webhooks':
1020
+ s.stop(formatEagerGithubWebhookInstallResult(event.result))
1021
+ break
537
1022
  }
538
1023
  }
539
1024
  }
@@ -542,6 +1027,7 @@ const START_MESSAGES: Record<AddChannelStepEvent['step'], string> = {
542
1027
  'kakaotalk-auth': 'Logging in to KakaoTalk...',
543
1028
  config: 'Updating typeclaw.json...',
544
1029
  secrets: 'Saving credentials to secrets.json...',
1030
+ 'github-webhooks': 'Installing GitHub repository webhooks...',
545
1031
  }
546
1032
 
547
1033
  function reportKakaotalkAuth(result: KakaotalkAuthResult): string {