typeclaw 0.3.1 → 0.5.0

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