typeclaw 0.3.0 → 0.4.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 (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. package/typeclaw.schema.json +254 -1
package/src/init/index.ts CHANGED
@@ -3,6 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
+ import { DEFAULT_GITHUB_EVENT_ALLOWLIST } from '@/channels/schema'
6
7
  import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
7
8
  import {
8
9
  DEFAULT_MODEL_REF,
@@ -18,6 +19,7 @@ import { createTui } from '@/tui'
18
19
 
19
20
  import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
20
21
  import { buildDockerfile, DOCKERFILE } from './dockerfile'
22
+ import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
21
23
  import { buildGitignore, GITIGNORE_FILE } from './gitignore'
22
24
  import { HATCHING_PROMPT } from './hatching'
23
25
  import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
@@ -26,6 +28,9 @@ import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun
26
28
 
27
29
  export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
28
30
 
31
+ export type { EagerGithubWebhookInstallResult } from './github-webhook-install'
32
+ export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } from './github-webhook-install'
33
+
29
34
  export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
30
35
 
31
36
  const CONFIG_FILE = 'typeclaw.json'
@@ -51,6 +56,7 @@ export type InitStep =
51
56
  | 'oauth-login'
52
57
  | 'scaffold'
53
58
  | 'kakaotalk-auth'
59
+ | 'github-webhooks'
54
60
  | 'install'
55
61
  | 'dockerfile'
56
62
  | 'git'
@@ -58,6 +64,20 @@ export type InitStep =
58
64
 
59
65
  export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
60
66
 
67
+ // Structured credential block for the GitHub channel adapter. Mirrors the
68
+ // shape `runAddChannel({ channel: 'github', ... })` consumes so the wizard
69
+ // can hand off without re-encoding the auth union or webhook fields.
70
+ export type GithubInitCredentials = {
71
+ webhookSecret: string
72
+ tunnelProvider: GithubTunnelProvider
73
+ webhookUrl?: string
74
+ webhookPort?: number
75
+ repos: string[]
76
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
77
+ }
78
+
79
+ export type GithubTunnelProvider = 'cloudflare-quick' | 'external' | 'none'
80
+
61
81
  export type InitStepEvent =
62
82
  | { step: 'preflight'; phase: 'start' }
63
83
  | { step: 'preflight'; phase: 'done'; result: DockerAvailability }
@@ -67,6 +87,8 @@ export type InitStepEvent =
67
87
  | { step: 'scaffold'; phase: 'done' }
68
88
  | { step: 'kakaotalk-auth'; phase: 'start' }
69
89
  | { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
90
+ | { step: 'github-webhooks'; phase: 'start' }
91
+ | { step: 'github-webhooks'; phase: 'done'; result: EagerGithubWebhookInstallResult }
70
92
  | { step: 'install'; phase: 'start' }
71
93
  | { step: 'install'; phase: 'done'; result: InstallResult }
72
94
  | { step: 'dockerfile'; phase: 'start' }
@@ -131,7 +153,16 @@ export type InitOptions = {
131
153
  withSlack?: boolean
132
154
  withTelegram?: boolean
133
155
  withKakaotalk?: boolean
156
+ withGithub?: boolean
134
157
  runKakaotalkAuth?: KakaotalkAuthRunner
158
+ // Structured GitHub credentials collected by the wizard. When omitted and
159
+ // `withGithub` is true, the existing secrets.json#channels.github block is
160
+ // reused as-is (the wizard's "reuse existing credentials" path).
161
+ githubCredentials?: GithubInitCredentials
162
+ // Test seam for the eager-webhook-install path that fires after the github
163
+ // channel block is written. Production callers leave this undefined so the
164
+ // global `fetch` is used.
165
+ githubFetchImpl?: typeof fetch
135
166
  onProgress?: (event: InitStepEvent) => void
136
167
  runHatching?: HatchRunner
137
168
  runBunInstall?: InstallRunner
@@ -159,7 +190,10 @@ export async function runInit({
159
190
  withSlack,
160
191
  withTelegram,
161
192
  withKakaotalk = false,
193
+ withGithub = false,
162
194
  runKakaotalkAuth,
195
+ githubCredentials,
196
+ githubFetchImpl,
163
197
  onProgress,
164
198
  runHatching = defaultRunHatching,
165
199
  runBunInstall: installRunner = runBunInstall,
@@ -263,6 +297,30 @@ export async function runInit({
263
297
  }
264
298
  }
265
299
 
300
+ // Write the structured github channel block alongside scaffold's bot-token
301
+ // blocks. We do NOT delegate to runAddChannel because that's the `channel
302
+ // add` semantics — strict no-overwrite, throws when secrets.json#channels
303
+ // .github already exists. Init is a different contract: re-running it
304
+ // regenerates config from the wizard's current inputs (scaffold() already
305
+ // overwrites typeclaw.json wholesale on every run), so failing on an
306
+ // existing secret block here would brick the re-init recovery path the
307
+ // bot-token adapters all support.
308
+ if (withGithub && githubCredentials !== undefined) {
309
+ await writeGithubChannelForInit(cwd, githubCredentials)
310
+ if (githubCredentials.webhookUrl !== undefined && githubCredentials.repos.length > 0) {
311
+ emit({ step: 'github-webhooks', phase: 'start' })
312
+ const result = await installGithubWebhooksEagerly({
313
+ webhookUrl: githubCredentials.webhookUrl,
314
+ webhookSecret: githubCredentials.webhookSecret,
315
+ repos: githubCredentials.repos,
316
+ auth: githubCredentials.auth,
317
+ agentDir: cwd,
318
+ ...(githubFetchImpl !== undefined ? { fetchImpl: githubFetchImpl } : {}),
319
+ })
320
+ emit({ step: 'github-webhooks', phase: 'done', result })
321
+ }
322
+ }
323
+
266
324
  emit({ step: 'install', phase: 'start' })
267
325
  const install = await installRunner(cwd)
268
326
  emit({ step: 'install', phase: 'done', result: install })
@@ -280,6 +338,7 @@ export async function runInit({
280
338
  if (wantsSlack) configuredChannels.push('slack-bot')
281
339
  if (wantsTelegram) configuredChannels.push('telegram-bot')
282
340
  if (withKakaotalk) configuredChannels.push('kakaotalk')
341
+ if (withGithub) configuredChannels.push('github')
283
342
 
284
343
  emit({ step: 'hatching', phase: 'start' })
285
344
  const hatching = await runHatching({
@@ -721,7 +780,7 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
721
780
  // kakaotalk` anyway — better to re-auth now during init.
722
781
  export async function hasExistingChannelSecrets(
723
782
  root: string,
724
- channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
783
+ channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk' | 'github',
725
784
  ): Promise<boolean> {
726
785
  const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
727
786
  if (channels === null) return false
@@ -732,6 +791,15 @@ export async function hasExistingChannelSecrets(
732
791
  return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
733
792
  case 'telegram':
734
793
  return hasSecretField(channels['telegram-bot'], 'token')
794
+ case 'github':
795
+ // GitHub credentials alone are not enough to scaffold a working
796
+ // channel: typeclaw.json#channels.github also needs webhookUrl and
797
+ // webhookPort, which only the user can supply. Always force a fresh
798
+ // prompt in the wizard so those fields end up in typeclaw.json. The
799
+ // existing `secrets.json#channels.github` (if any) is detected and
800
+ // surfaced as a hard error inside `runAddChannel` to prevent silent
801
+ // overwrites.
802
+ return false
735
803
  case 'kakaotalk': {
736
804
  const block = channels.kakaotalk
737
805
  if (!isObjectRecord(block)) return false
@@ -802,15 +870,21 @@ function ignoreExists(error: NodeJS.ErrnoException): void {
802
870
  // scaffold-test cases above demonstrates how easy it is to lose a single
803
871
  // behavior under a mode flag.
804
872
 
805
- export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk'
873
+ export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk' | 'github'
806
874
 
807
875
  // Public adapter names match the typeclaw.json `channels.*` keys exactly.
808
876
  // The CLI takes these as the optional positional arg, the picker shows
809
877
  // these labels, and they're the keys we use to detect "already configured"
810
878
  // when reading typeclaw.json.
811
- export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = ['slack-bot', 'discord-bot', 'telegram-bot', 'kakaotalk']
879
+ export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
880
+ 'slack-bot',
881
+ 'discord-bot',
882
+ 'telegram-bot',
883
+ 'kakaotalk',
884
+ 'github',
885
+ ]
812
886
 
813
- export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets'
887
+ export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
814
888
 
815
889
  export type AddChannelStepEvent =
816
890
  | { step: 'config'; phase: 'start' }
@@ -819,6 +893,8 @@ export type AddChannelStepEvent =
819
893
  | { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
820
894
  | { step: 'secrets'; phase: 'start' }
821
895
  | { step: 'secrets'; phase: 'done' }
896
+ | { step: 'github-webhooks'; phase: 'start' }
897
+ | { step: 'github-webhooks'; phase: 'done'; result: EagerGithubWebhookInstallResult }
822
898
 
823
899
  // Discriminated union per channel so the type system enforces "you must pass
824
900
  // the right credentials for the channel you're adding". The CLI builds these
@@ -831,6 +907,16 @@ export type AddChannelOptions = {
831
907
  | { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
832
908
  | { channel: 'telegram-bot'; telegramBotToken: string }
833
909
  | { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
910
+ | {
911
+ channel: 'github'
912
+ webhookSecret: string
913
+ tunnelProvider: GithubTunnelProvider
914
+ webhookUrl?: string
915
+ webhookPort?: number
916
+ repos: string[]
917
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
918
+ fetchImpl?: typeof fetch
919
+ }
834
920
  )
835
921
 
836
922
  export async function runAddChannel(options: AddChannelOptions): Promise<void> {
@@ -852,7 +938,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
852
938
  }
853
939
 
854
940
  emit({ step: 'config', phase: 'start' })
855
- await mergeChannelIntoConfig(options.cwd, options.channel)
941
+ await mergeChannelIntoConfig(options.cwd, options)
856
942
  emit({ step: 'config', phase: 'done' })
857
943
 
858
944
  emit({ step: 'secrets', phase: 'start' })
@@ -860,8 +946,16 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
860
946
  if (Object.keys(tokens).length > 0) {
861
947
  await appendChannelSecrets(options.cwd, options.channel, tokens)
862
948
  }
949
+ if (options.channel === 'github') {
950
+ await appendGithubSecrets(options.cwd, options)
951
+ }
863
952
  emit({ step: 'secrets', phase: 'done' })
864
953
 
954
+ if (options.channel === 'github') {
955
+ await appendGithubMatchRules(options.cwd, options.repos)
956
+ await maybeInstallGithubWebhooks(options, emit)
957
+ }
958
+
865
959
  // Commit the typeclaw.json change so the agent folder isn't silently
866
960
  // dirty after `typeclaw channel add`. Same `commitSystemFile` contract as
867
961
  // every other host-side rewrite: no-op outside a git repo, when Bun is
@@ -870,6 +964,30 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
870
964
  await commitSystemFile(options.cwd, CONFIG_FILE, `channel: add ${options.channel}`)
871
965
  }
872
966
 
967
+ // Eager webhook registration is best-effort: a failure here MUST NOT roll
968
+ // back the typeclaw.json / secrets.json writes. The container-side adapter
969
+ // re-runs registration on every start, so a missing PAT scope or a transient
970
+ // 5xx today gets retried automatically on the next `typeclaw start`. We
971
+ // surface the per-repo outcome as a structured event so the CLI can render
972
+ // it, but we never throw.
973
+ async function maybeInstallGithubWebhooks(
974
+ options: Extract<AddChannelOptions, { channel: 'github' }>,
975
+ emit: (event: AddChannelStepEvent) => void,
976
+ ): Promise<void> {
977
+ if (options.webhookUrl === undefined) return
978
+ if (options.repos.length === 0) return
979
+ emit({ step: 'github-webhooks', phase: 'start' })
980
+ const result = await installGithubWebhooksEagerly({
981
+ webhookUrl: options.webhookUrl,
982
+ webhookSecret: options.webhookSecret,
983
+ repos: options.repos,
984
+ auth: options.auth,
985
+ agentDir: options.cwd,
986
+ ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
987
+ })
988
+ emit({ step: 'github-webhooks', phase: 'done', result })
989
+ }
990
+
873
991
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
874
992
  switch (options.channel) {
875
993
  case 'discord-bot':
@@ -882,6 +1000,9 @@ function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
882
1000
  // KakaoTalk auth writes its structured multi-account block directly to
883
1001
  // secrets.json#channels.kakaotalk before config mutation.
884
1002
  return {}
1003
+ case 'github':
1004
+ // GitHub stores a structured PAT + webhook secret block directly.
1005
+ return {}
885
1006
  }
886
1007
  }
887
1008
 
@@ -908,7 +1029,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
908
1029
  return present
909
1030
  }
910
1031
 
911
- async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
1032
+ async function mergeChannelIntoConfig(cwd: string, options: AddChannelOptions): Promise<void> {
912
1033
  const path = join(cwd, CONFIG_FILE)
913
1034
  let parsed: Record<string, unknown>
914
1035
  try {
@@ -928,19 +1049,152 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promis
928
1049
  ? (parsed.channels as Record<string, unknown>)
929
1050
  : {}
930
1051
 
931
- if (channel in existingChannels) {
1052
+ if (options.channel in existingChannels) {
932
1053
  // Defense in depth — the CLI already filters configured channels out of
933
1054
  // the picker and rejects them as the positional arg. Hitting this branch
934
1055
  // means a programmatic caller passed a duplicate; better to fail loudly
935
1056
  // than silently overwrite the user's existing config.
936
- throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
1057
+ throw new Error(`Channel "${options.channel}" is already configured in ${CONFIG_FILE}.`)
937
1058
  }
938
1059
 
1060
+ const nextChannelConfig = options.channel === 'github' ? buildGithubChannelConfig(options) : {}
1061
+
939
1062
  parsed.channels = {
940
1063
  ...existingChannels,
941
- [channel]: {},
1064
+ [options.channel]: nextChannelConfig,
1065
+ }
1066
+
1067
+ if (options.channel === 'github') mergeGithubTunnelConfig(parsed, options)
1068
+
1069
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1070
+ }
1071
+
1072
+ function buildGithubChannelConfig(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
1073
+ return {
1074
+ ...(options.webhookUrl !== undefined ? { webhookUrl: options.webhookUrl } : {}),
1075
+ webhookPort: options.webhookPort ?? 8975,
1076
+ eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
1077
+ repos: options.repos,
1078
+ }
1079
+ }
1080
+
1081
+ function mergeGithubTunnelConfig(
1082
+ parsed: Record<string, unknown>,
1083
+ options: Extract<AddChannelOptions, { channel: 'github' }>,
1084
+ ): void {
1085
+ if (options.tunnelProvider === 'none') return
1086
+ if (options.tunnelProvider === 'external' && options.webhookUrl === undefined) {
1087
+ throw new Error('GitHub external tunnel requires webhookUrl')
1088
+ }
1089
+
1090
+ const existingTunnels = Array.isArray(parsed.tunnels) ? parsed.tunnels : []
1091
+ const tunnel =
1092
+ options.tunnelProvider === 'external'
1093
+ ? {
1094
+ name: 'github-webhook',
1095
+ provider: 'external',
1096
+ externalUrl: options.webhookUrl,
1097
+ for: { kind: 'channel', name: 'github' },
1098
+ }
1099
+ : {
1100
+ name: 'github-webhook',
1101
+ provider: 'cloudflare-quick',
1102
+ for: { kind: 'channel', name: 'github' },
1103
+ }
1104
+ parsed.tunnels = [...existingTunnels, tunnel]
1105
+
1106
+ if (options.tunnelProvider === 'cloudflare-quick') {
1107
+ const docker = isObjectRecord(parsed.docker) ? { ...parsed.docker } : {}
1108
+ const file = isObjectRecord(docker.file) ? { ...docker.file } : {}
1109
+ file.cloudflared = true
1110
+ docker.file = file
1111
+ parsed.docker = docker
942
1112
  }
1113
+ }
1114
+
1115
+ // Init-side counterpart of runAddChannel's github branch. Same three writes
1116
+ // (typeclaw.json#channels.github, secrets.json#channels.github, roles.member
1117
+ // .match[]) but with overwrite semantics on the secrets/config side so a
1118
+ // re-run of `typeclaw init` after a partial failure works the same way it
1119
+ // does for the bot-token adapters. The match-rule writer is reused as-is
1120
+ // because its set-union is already idempotent.
1121
+ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCredentials): Promise<void> {
1122
+ const configPath = join(cwd, CONFIG_FILE)
1123
+ const parsed = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>
1124
+ const existingChannels = isObjectRecord(parsed.channels) ? { ...parsed.channels } : {}
1125
+ existingChannels.github = {
1126
+ ...(credentials.webhookUrl !== undefined ? { webhookUrl: credentials.webhookUrl } : {}),
1127
+ webhookPort: credentials.webhookPort ?? 8975,
1128
+ eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
1129
+ repos: credentials.repos,
1130
+ }
1131
+ parsed.channels = existingChannels
1132
+ mergeGithubTunnelConfig(parsed, { channel: 'github', ...credentials, cwd })
1133
+ await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`)
1134
+
1135
+ const backend = new SecretsBackend(join(cwd, 'secrets.json'))
1136
+ const channels: Record<string, unknown> = backend.readChannelsSync()
1137
+ channels.github = {
1138
+ auth:
1139
+ credentials.auth.type === 'pat'
1140
+ ? { type: 'pat', token: { value: credentials.auth.pat } satisfies Secret }
1141
+ : {
1142
+ type: 'app',
1143
+ appId: credentials.auth.appId,
1144
+ privateKey: { value: credentials.auth.privateKey } satisfies Secret,
1145
+ ...(credentials.auth.installationId !== undefined
1146
+ ? { installationId: credentials.auth.installationId }
1147
+ : {}),
1148
+ },
1149
+ webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
1150
+ }
1151
+ backend.writeChannelsSync(channels as Channels)
943
1152
 
1153
+ await appendGithubMatchRules(cwd, credentials.repos)
1154
+ }
1155
+
1156
+ async function appendGithubSecrets(
1157
+ cwd: string,
1158
+ options: Extract<AddChannelOptions, { channel: 'github' }>,
1159
+ ): Promise<void> {
1160
+ if (!existsSync(join(cwd, CONFIG_FILE))) {
1161
+ throw new Error(
1162
+ `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
1163
+ )
1164
+ }
1165
+ const backend = new SecretsBackend(join(cwd, 'secrets.json'))
1166
+ const channels: Record<string, unknown> = backend.readChannelsSync()
1167
+ if (channels.github !== undefined) {
1168
+ throw new Error(
1169
+ 'github is already set in secrets.json. Remove it before re-adding the channel, or edit it by hand.',
1170
+ )
1171
+ }
1172
+ channels.github = {
1173
+ auth:
1174
+ options.auth.type === 'pat'
1175
+ ? { type: 'pat', token: { value: options.auth.pat } satisfies Secret }
1176
+ : {
1177
+ type: 'app',
1178
+ appId: options.auth.appId,
1179
+ privateKey: { value: options.auth.privateKey } satisfies Secret,
1180
+ ...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
1181
+ },
1182
+ webhookSecret: { value: options.webhookSecret } satisfies Secret,
1183
+ }
1184
+ backend.writeChannelsSync(channels as Channels)
1185
+ }
1186
+
1187
+ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Promise<void> {
1188
+ const path = join(cwd, CONFIG_FILE)
1189
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1190
+ const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1191
+ const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1192
+ const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1193
+ const merged = new Set(existing)
1194
+ for (const repo of repos) merged.add(`github:${repo}`)
1195
+ member.match = Array.from(merged)
1196
+ roles.member = member
1197
+ parsed.roles = roles
944
1198
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
945
1199
  }
946
1200
 
@@ -976,3 +1230,245 @@ async function appendChannelSecrets(cwd: string, channel: ChannelKind, tokens: C
976
1230
  channels[channel] = slot
977
1231
  backend.writeChannelsSync(channels as Channels)
978
1232
  }
1233
+
1234
+ // ----------------------------------------------------------------------------
1235
+ // `typeclaw channel set`
1236
+ //
1237
+ // Rotate credentials of an already-configured channel. Symmetric with
1238
+ // `typeclaw provider set` (see `setProvider` in src/config/providers-mutation.ts):
1239
+ // `add` is "add for the first time, refuse if already present", `set` is
1240
+ // "rotate the value, refuse if NOT yet present". Two separate verbs keep the
1241
+ // "add by mistake" footgun and the "rotate by mistake" footgun on opposite
1242
+ // sides of the CLI namespace.
1243
+ //
1244
+ // Per the env-wins / file-never-auto-mutated rule in AGENTS.md#secrets, these
1245
+ // helpers only touch the fields the user explicitly asked to rotate. Any
1246
+ // untouched field — including a sibling field bound to a custom env var —
1247
+ // keeps its existing Secret envelope verbatim.
1248
+ //
1249
+ // Kakaotalk has its own auth flow (encryption envelope + device_uuid + phone
1250
+ // passcode) and is rotated via `typeclaw channel reauth kakaotalk`, NOT via
1251
+ // these helpers. Trying to set('kakaotalk') would bypass the encryption
1252
+ // bridge and corrupt the per-account block; the CLI layer rejects it before
1253
+ // reaching here.
1254
+
1255
+ export type SetChannelTokensResult = { ok: true } | { ok: false; reason: string }
1256
+
1257
+ type BotTokenAdapter = 'discord-bot' | 'slack-bot' | 'telegram-bot'
1258
+
1259
+ // Required credential fields per adapter. Used post-merge to refuse a
1260
+ // rotation that would leave the adapter half-configured — e.g. rotating
1261
+ // only `botToken` when the on-disk slot was `{}` would silently leave
1262
+ // `appToken` missing and the Slack adapter would fail to start. Listing
1263
+ // the contract explicitly here keeps it close to the writer.
1264
+ const REQUIRED_CHANNEL_FIELDS: Record<BotTokenAdapter, readonly string[]> = {
1265
+ 'discord-bot': ['token'],
1266
+ 'slack-bot': ['botToken', 'appToken'],
1267
+ 'telegram-bot': ['token'],
1268
+ }
1269
+
1270
+ // Preserve a user-authored `{ env: 'CUSTOM_NAME' }` rebinding when rotating
1271
+ // the value behind it. Mirrors `buildSecret` in providers-mutation.ts for
1272
+ // the case where the prior credential had an explicit env-name binding —
1273
+ // the rotated value is written as `{ value, env }` so env-wins still works
1274
+ // at runtime. Without this, every `channel set` would silently strip the
1275
+ // rebinding and `process.env[<custom>]` would no longer override.
1276
+ function rotatedSecret(previous: unknown, value: string): Secret {
1277
+ if (isObjectRecord(previous)) {
1278
+ const env = (previous as { env?: unknown }).env
1279
+ if (typeof env === 'string' && env.length > 0) {
1280
+ return { value, env }
1281
+ }
1282
+ }
1283
+ return { value }
1284
+ }
1285
+
1286
+ // Rotate one or more credential fields on an already-configured bot-token
1287
+ // adapter (discord-bot, slack-bot, telegram-bot). Refuses when the adapter
1288
+ // has no existing entry in secrets.json — callers must use `runAddChannel`
1289
+ // for first-time setup, so a typo in the adapter name can't silently create
1290
+ // a half-configured channel. Also refuses when the rotation would leave any
1291
+ // required field for the adapter unset (e.g. rotating only Slack's
1292
+ // `botToken` when `appToken` is missing from disk).
1293
+ export async function setChannelSecrets(
1294
+ cwd: string,
1295
+ channel: BotTokenAdapter,
1296
+ tokens: ChannelSecrets,
1297
+ ): Promise<SetChannelTokensResult> {
1298
+ if (!existsSync(join(cwd, CONFIG_FILE))) {
1299
+ return {
1300
+ ok: false,
1301
+ reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before rotating channel credentials, or run this command from inside an agent folder.`,
1302
+ }
1303
+ }
1304
+
1305
+ if (Object.keys(tokens).length === 0) return { ok: true }
1306
+
1307
+ return await runChannelMutation(cwd, async (current) => {
1308
+ const existingSlot = current[channel]
1309
+ if (!isObjectRecord(existingSlot)) {
1310
+ return {
1311
+ result: {
1312
+ ok: false,
1313
+ reason: `${channel} is not configured in secrets.json. Run \`typeclaw channel add ${channel}\` first.`,
1314
+ },
1315
+ }
1316
+ }
1317
+ const slot: Record<string, unknown> = { ...existingSlot }
1318
+ for (const [k, v] of Object.entries(tokens)) {
1319
+ slot[k] = rotatedSecret(existingSlot[k], v)
1320
+ }
1321
+ const missing = REQUIRED_CHANNEL_FIELDS[channel].filter((field) => !isSecretFieldSet(slot[field]))
1322
+ if (missing.length > 0) {
1323
+ return {
1324
+ result: {
1325
+ ok: false,
1326
+ reason: `${channel} would be left half-configured after this rotation: missing required field(s) ${missing.join(', ')} in secrets.json. Run \`typeclaw channel add ${channel}\` to re-add the channel, or fix secrets.json by hand.`,
1327
+ },
1328
+ }
1329
+ }
1330
+ const next: Record<string, unknown> = { ...current, [channel]: slot }
1331
+ return { result: { ok: true }, next }
1332
+ })
1333
+ }
1334
+
1335
+ // Discriminated union of what GitHub credentials the user wants to rotate.
1336
+ // The three secrets (PAT/private-key, webhook secret) rotate independently,
1337
+ // so the CLI lets the user pick which one(s) to touch in a single call.
1338
+ // `auth.type` must match the existing on-disk auth type — flipping between
1339
+ // PAT and App auth is a structural change, not a credential rotation, and
1340
+ // belongs in a future `channel migrate-auth` or hand-edit of secrets.json.
1341
+ export type GithubCredentialPatch = {
1342
+ webhookSecret?: string
1343
+ auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
1344
+ }
1345
+
1346
+ // Rotate one or more credential fields on an already-configured GitHub
1347
+ // channel. Like setChannelSecrets, refuses when secrets.json has no
1348
+ // existing github entry. Additionally refuses when the requested auth.type
1349
+ // doesn't match the on-disk type — see `GithubCredentialPatch` above.
1350
+ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
1351
+ if (!existsSync(join(cwd, CONFIG_FILE))) {
1352
+ return {
1353
+ ok: false,
1354
+ reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before rotating channel credentials, or run this command from inside an agent folder.`,
1355
+ }
1356
+ }
1357
+
1358
+ if (patch.webhookSecret === undefined && patch.auth === undefined) return { ok: true }
1359
+
1360
+ return await runChannelMutation(cwd, async (current) => {
1361
+ const existing = current.github
1362
+ if (!isObjectRecord(existing)) {
1363
+ return {
1364
+ result: {
1365
+ ok: false,
1366
+ reason: 'github is not configured in secrets.json. Run `typeclaw channel add github` first.',
1367
+ },
1368
+ }
1369
+ }
1370
+ const block: Record<string, unknown> = { ...existing }
1371
+
1372
+ if (patch.auth !== undefined) {
1373
+ const existingAuth = block.auth
1374
+ const existingAuthType = readGithubAuthTypeFromObject(existingAuth)
1375
+ if (existingAuthType !== patch.auth.type) {
1376
+ return {
1377
+ result: {
1378
+ ok: false,
1379
+ reason: `github auth type mismatch: secrets.json currently uses "${existingAuthType ?? 'unknown'}" auth, but you tried to rotate a "${patch.auth.type}" credential. Edit secrets.json by hand to migrate between PAT and App auth.`,
1380
+ },
1381
+ }
1382
+ }
1383
+ if (patch.auth.type === 'pat') {
1384
+ const previousToken = isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
1385
+ block.auth = { type: 'pat', token: rotatedSecret(previousToken, patch.auth.pat) }
1386
+ } else {
1387
+ const existingApp = isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1388
+ const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
1389
+ const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
1390
+ if (typeof appId !== 'number') {
1391
+ return {
1392
+ result: {
1393
+ ok: false,
1394
+ reason:
1395
+ 'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.',
1396
+ },
1397
+ }
1398
+ }
1399
+ block.auth = {
1400
+ type: 'app',
1401
+ appId,
1402
+ privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
1403
+ ...(installationId !== undefined ? { installationId } : {}),
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ if (patch.webhookSecret !== undefined) {
1409
+ block.webhookSecret = rotatedSecret(block.webhookSecret, patch.webhookSecret)
1410
+ }
1411
+
1412
+ const next: Record<string, unknown> = { ...current, github: block }
1413
+ return { result: { ok: true }, next }
1414
+ })
1415
+ }
1416
+
1417
+ // Wrapper that converts a thrown schema-validation error (from a hand-edited
1418
+ // malformed secrets.json) into a structured `{ ok: false }` result, so CLI
1419
+ // callers can render a clean message instead of a stack trace. The
1420
+ // `updateChannelsAsync` path itself is atomic; this wrapper only catches the
1421
+ // READ stage (envelope parse) failures.
1422
+ async function runChannelMutation(
1423
+ cwd: string,
1424
+ fn: (current: Record<string, unknown>) => Promise<{ result: SetChannelTokensResult; next?: Record<string, unknown> }>,
1425
+ ): Promise<SetChannelTokensResult> {
1426
+ const backend = new SecretsBackend(join(cwd, 'secrets.json'))
1427
+ try {
1428
+ return await backend.updateChannelsAsync<SetChannelTokensResult>(fn)
1429
+ } catch (err) {
1430
+ return {
1431
+ ok: false,
1432
+ reason: `secrets.json is malformed: ${err instanceof Error ? err.message : String(err)}. Fix it by hand, then retry.`,
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ function isSecretFieldSet(slot: unknown): boolean {
1438
+ if (typeof slot === 'string') return slot.length > 0
1439
+ if (isObjectRecord(slot)) {
1440
+ const value = (slot as { value?: unknown }).value
1441
+ if (typeof value === 'string' && value.length > 0) return true
1442
+ const env = (slot as { env?: unknown }).env
1443
+ if (typeof env === 'string' && env.length > 0) return true
1444
+ }
1445
+ return false
1446
+ }
1447
+
1448
+ function readGithubAuthTypeFromObject(auth: unknown): 'pat' | 'app' | undefined {
1449
+ if (!isObjectRecord(auth)) return undefined
1450
+ const type = (auth as { type?: unknown }).type
1451
+ if (type === 'pat' || type === 'app') return type
1452
+ return undefined
1453
+ }
1454
+
1455
+ // Lightweight read-only probe used by the `channel set` CLI to drive its
1456
+ // "which secret do you want to rotate?" menu for GitHub. Returns the
1457
+ // current auth type ('pat' | 'app') so the prompt knows whether to ask for
1458
+ // a PAT or an App private key, without forcing the user to re-select auth
1459
+ // type when they're rotating a credential of the same kind. Returns `null`
1460
+ // when secrets.json is missing, malformed, or has no github entry — the
1461
+ // CLI surfaces that as a single user-facing "fix the file by hand" error.
1462
+ export function readGithubAuthType(cwd: string): 'pat' | 'app' | null {
1463
+ let channels: Channels | null
1464
+ try {
1465
+ channels = new SecretsBackend(join(cwd, 'secrets.json')).tryReadChannelsSync()
1466
+ } catch {
1467
+ return null
1468
+ }
1469
+ if (channels === null) return null
1470
+ const github = channels.github
1471
+ if (!isObjectRecord(github)) return null
1472
+ const auth = (github as { auth?: unknown }).auth
1473
+ return readGithubAuthTypeFromObject(auth) ?? null
1474
+ }