typeclaw 0.3.1 → 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 (89) 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/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
package/src/cli/init.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { readFile } from 'node:fs/promises'
3
+
1
4
  import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
2
5
  import { defineCommand } from 'citty'
3
6
 
@@ -11,11 +14,14 @@ import {
11
14
  import type { DockerAvailability } from '@/container'
12
15
  import {
13
16
  findAgentDir,
17
+ formatEagerGithubWebhookInstallResult,
14
18
  hasExistingChannelSecrets,
15
19
  isDirectoryNonEmpty,
16
20
  isHatched,
17
21
  readExistingProviderApiKey,
18
22
  runInit,
23
+ type GithubInitCredentials,
24
+ type GithubTunnelProvider,
19
25
  type InitStep,
20
26
  type InitStepEvent,
21
27
  type KakaotalkAuthResult,
@@ -31,15 +37,33 @@ import { c, done, errorLine } from './ui'
31
37
  // aliases both to the same "cancel" action — there's no way to tell them
32
38
  // apart through @clack/prompts). The wizard treats every cancel as "go
33
39
  // back to the previous step": each step that runs an interactive prompt
34
- // either advances with a value or rewinds. There is no "go back" target
35
- // on the very first step (pick-provider), so a `back` there is a no-op
36
- // that re-displays the same prompt rather than aborting. Users who want
37
- // to bail out of the wizard kill the process from outside (close the
38
- // terminal, send SIGTERM); inside an active clack prompt Ctrl+C is also
39
- // aliased to cancel, so there is no in-wizard abort hotkey.
40
- export type StepResult<T> = { kind: 'value'; value: T } | { kind: 'back' }
40
+ // either advances with a value or rewinds.
41
+ //
42
+ // Two cancel patterns must not trap the user:
43
+ // 1. Single-prompt cancel-loop. On the first step (pick-provider) there
44
+ // is no previous step, so `back` re-displays the same prompt. Two
45
+ // consecutive cancels on that same prompt = the user wants out.
46
+ // 2. Auto-advance round-trip. `back` from `enter-api-key` routes to
47
+ // `pick-auth-method`, which for single-method providers (e.g.
48
+ // Fireworks, api-key only) returns its value without prompting and
49
+ // sends the wizard straight back to `enter-api-key`. The user only
50
+ // ever sees the same api-key prompt and has no way to escape.
51
+ //
52
+ // Both patterns are detected in `collectWizardInputs` and surfaced as
53
+ // `WizardAbortedError`, which the `init` command catches and turns into
54
+ // a clean exit. Inside an active clack prompt Ctrl+C is still aliased to
55
+ // cancel, so the abort hotkey is "cancel twice in a row".
56
+ export class WizardAbortedError extends Error {
57
+ constructor() {
58
+ super('Wizard aborted by user')
59
+ this.name = 'WizardAbortedError'
60
+ }
61
+ }
62
+
63
+ export type StepResult<T> = { kind: 'value'; value: T; auto?: boolean } | { kind: 'back' }
41
64
  const back = <T>(): StepResult<T> => ({ kind: 'back' })
42
65
  const value = <T>(v: T): StepResult<T> => ({ kind: 'value', value: v })
66
+ const autoValue = <T>(v: T): StepResult<T> => ({ kind: 'value', value: v, auto: true })
43
67
 
44
68
  export const init = defineCommand({
45
69
  meta: {
@@ -76,12 +100,28 @@ export const init = defineCommand({
76
100
  }
77
101
 
78
102
  intro('Initializing TypeClaw...')
79
- log.info('Press ESC at any prompt to go back to the previous step.')
103
+ log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
80
104
 
81
- const collected = await collectWizardInputs(cwd, defaultWizardPrompts)
105
+ let collected: CollectedInputs
106
+ try {
107
+ collected = await collectWizardInputs(cwd, defaultWizardPrompts)
108
+ } catch (error) {
109
+ if (error instanceof WizardAbortedError) {
110
+ cancel('Aborted.')
111
+ process.exit(0)
112
+ }
113
+ throw error
114
+ }
82
115
  const { model, llmAuth, vision, channelChoice, reuseExistingChannel, channelSecrets } = collected
83
- const { discordBotToken, slackBotToken, slackAppToken, telegramBotToken, kakaotalkEmail, kakaotalkPassword } =
84
- channelSecrets
116
+ const {
117
+ discordBotToken,
118
+ slackBotToken,
119
+ slackAppToken,
120
+ telegramBotToken,
121
+ kakaotalkEmail,
122
+ kakaotalkPassword,
123
+ github: githubCredentials,
124
+ } = channelSecrets
85
125
 
86
126
  // TODO: add remaining wizard steps from TypeClaw.md once their runtime lands:
87
127
  // - git backup (url + PAT) — Phase 10
@@ -96,7 +136,9 @@ export const init = defineCommand({
96
136
  const reuseSlack = reuseExistingChannel && channelChoice === 'slack'
97
137
  const reuseTelegram = reuseExistingChannel && channelChoice === 'telegram'
98
138
  const reuseKakaotalk = reuseExistingChannel && channelChoice === 'kakaotalk'
139
+ const reuseGithub = reuseExistingChannel && channelChoice === 'github'
99
140
  const wantsKakaotalk = (kakaotalkEmail !== undefined && kakaotalkPassword !== undefined) || reuseKakaotalk
141
+ const wantsGithub = githubCredentials !== undefined || reuseGithub
100
142
  let hatchingOk = false
101
143
  let preflightFailure: Extract<DockerAvailability, { ok: false }> | null = null
102
144
  try {
@@ -130,6 +172,12 @@ export const init = defineCommand({
130
172
  }),
131
173
  }
132
174
  : {}),
175
+ ...(wantsGithub
176
+ ? {
177
+ withGithub: true,
178
+ ...(reuseGithub || githubCredentials === undefined ? {} : { githubCredentials }),
179
+ }
180
+ : {}),
133
181
  onProgress: reportProgress(
134
182
  (ok) => {
135
183
  hatchingOk = ok
@@ -149,6 +197,12 @@ export const init = defineCommand({
149
197
  process.exit(1)
150
198
  }
151
199
 
200
+ if (githubCredentials?.tunnelProvider === 'none') {
201
+ log.warn(
202
+ 'Webhook delivery is disabled until you add a `tunnels[]` entry or set `channels.github.webhookUrl` manually.',
203
+ )
204
+ }
205
+
152
206
  if (hatchingOk) {
153
207
  done({
154
208
  title: c.green('Hatched. Your agent is ready.'),
@@ -179,7 +233,7 @@ interface WizardState {
179
233
  channelReuseExisting?: boolean
180
234
  }
181
235
 
182
- type ChannelChoice = 'slack' | 'discord' | 'telegram' | 'kakaotalk' | 'none'
236
+ type ChannelChoice = 'slack' | 'discord' | 'telegram' | 'kakaotalk' | 'github' | 'none'
183
237
 
184
238
  interface CollectedInputs {
185
239
  model: ModelOption
@@ -201,6 +255,11 @@ interface CollectedInputs {
201
255
  telegramBotToken?: string
202
256
  kakaotalkEmail?: string
203
257
  kakaotalkPassword?: string
258
+ // Structured (auth union + webhook + repo allowlist) rather than flat
259
+ // tokens, so it rides as one sub-object instead of sibling fields.
260
+ // `runInit` delegates to `runAddChannel` for GitHub to keep the github
261
+ // config-writing in one place.
262
+ github?: GithubInitCredentials
204
263
  }
205
264
  }
206
265
 
@@ -277,11 +336,22 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
277
336
  const catalog = await prompts.loadCatalog()
278
337
  const state: WizardState = { catalog }
279
338
  let step: StepId = 'pick-provider'
339
+ let pendingBackOrigin: StepId | null = null
340
+
341
+ const onResult = <T>(currentStep: StepId, result: StepResult<T>): StepResult<T> => {
342
+ if (result.kind === 'back') {
343
+ if (pendingBackOrigin === currentStep) throw new WizardAbortedError()
344
+ pendingBackOrigin = currentStep
345
+ } else if (!result.auto) {
346
+ pendingBackOrigin = null
347
+ }
348
+ return result
349
+ }
280
350
 
281
351
  while (true) {
282
352
  switch (step) {
283
353
  case 'pick-provider': {
284
- const result = await prompts.pickProvider(catalog.options, state.providerId)
354
+ const result = onResult(step, await prompts.pickProvider(catalog.options, state.providerId))
285
355
  if (result.kind === 'back') {
286
356
  break
287
357
  }
@@ -297,7 +367,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
297
367
  }
298
368
 
299
369
  case 'pick-model': {
300
- const result = await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref)
370
+ const result = onResult(step, await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref))
301
371
  if (result.kind === 'back') {
302
372
  step = 'pick-provider'
303
373
  break
@@ -310,7 +380,10 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
310
380
  case 'reuse-existing-key': {
311
381
  const provider = KNOWN_PROVIDERS[state.providerId!]
312
382
  const existingApiKey = await prompts.readExistingApiKey(cwd, state.providerId!)
313
- const decision = await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting)
383
+ const decision = onResult(
384
+ step,
385
+ await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting),
386
+ )
314
387
  if (decision.kind === 'back') {
315
388
  step = 'pick-model'
316
389
  break
@@ -330,7 +403,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
330
403
 
331
404
  case 'pick-auth-method': {
332
405
  const provider = KNOWN_PROVIDERS[state.providerId!]
333
- const result = await prompts.pickAuthMethod(provider, state.authMethod)
406
+ const result = onResult(step, await prompts.pickAuthMethod(provider, state.authMethod))
334
407
  if (result.kind === 'back') {
335
408
  step = 'reuse-existing-key'
336
409
  break
@@ -347,7 +420,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
347
420
 
348
421
  case 'enter-api-key': {
349
422
  const provider = KNOWN_PROVIDERS[state.providerId!]
350
- const result = await prompts.askApiKey(provider)
423
+ const result = onResult(step, await prompts.askApiKey(provider))
351
424
  if (result.kind === 'back') {
352
425
  step = 'pick-auth-method'
353
426
  break
@@ -359,7 +432,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
359
432
 
360
433
  case 'pick-vision-provider': {
361
434
  const visionOptions = catalog.options.filter((o) => o.supportsVision)
362
- const result = await prompts.pickVisionProvider(visionOptions, state.visionProviderId)
435
+ const result = onResult(step, await prompts.pickVisionProvider(visionOptions, state.visionProviderId))
363
436
  if (result.kind === 'back') {
364
437
  step = stepBeforeVision(state)
365
438
  break
@@ -386,7 +459,10 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
386
459
 
387
460
  case 'pick-vision-model': {
388
461
  const visionOptions = catalog.options.filter((o) => o.supportsVision)
389
- const result = await prompts.pickVisionModel(visionOptions, state.visionProviderId!, state.visionModel?.ref)
462
+ const result = onResult(
463
+ step,
464
+ await prompts.pickVisionModel(visionOptions, state.visionProviderId!, state.visionModel?.ref),
465
+ )
390
466
  if (result.kind === 'back') {
391
467
  step = 'pick-vision-provider'
392
468
  break
@@ -415,7 +491,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
415
491
 
416
492
  case 'pick-vision-auth-method': {
417
493
  const provider = KNOWN_PROVIDERS[state.visionProviderId!]
418
- const result = await prompts.pickAuthMethod(provider, state.visionAuthMethod)
494
+ const result = onResult(step, await prompts.pickAuthMethod(provider, state.visionAuthMethod))
419
495
  if (result.kind === 'back') {
420
496
  step = 'pick-vision-model'
421
497
  break
@@ -432,7 +508,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
432
508
 
433
509
  case 'enter-vision-api-key': {
434
510
  const provider = KNOWN_PROVIDERS[state.visionProviderId!]
435
- const result = await prompts.askApiKey(provider)
511
+ const result = onResult(step, await prompts.askApiKey(provider))
436
512
  if (result.kind === 'back') {
437
513
  step = 'pick-vision-auth-method'
438
514
  break
@@ -443,7 +519,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
443
519
  }
444
520
 
445
521
  case 'pick-channel': {
446
- const result = await prompts.pickChannel(state.channelChoice)
522
+ const result: StepResult<ChannelChoice> = onResult(step, await prompts.pickChannel(state.channelChoice))
447
523
  if (result.kind === 'back') {
448
524
  step = stepBeforePickChannel(state)
449
525
  break
@@ -467,7 +543,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
467
543
  break
468
544
  }
469
545
  state.channelReuseOffered = true
470
- const decision = await prompts.askReuseExistingChannel(choice)
546
+ const decision = onResult(step, await prompts.askReuseExistingChannel(choice))
471
547
  if (decision.kind === 'back') {
472
548
  step = 'pick-channel'
473
549
  break
@@ -483,7 +559,7 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
483
559
  }
484
560
 
485
561
  case 'channel-flow': {
486
- const result = await prompts.runChannelFlow(state.channelChoice!)
562
+ const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!))
487
563
  if (result.kind === 'back') {
488
564
  step = state.channelReuseOffered === true ? 'reuse-existing-channel' : 'pick-channel'
489
565
  break
@@ -517,6 +593,8 @@ function channelDisplayName(choice: Exclude<ChannelChoice, 'none'>): string {
517
593
  return 'Telegram'
518
594
  case 'kakaotalk':
519
595
  return 'KakaoTalk'
596
+ case 'github':
597
+ return 'GitHub'
520
598
  }
521
599
  }
522
600
 
@@ -632,8 +710,7 @@ async function pickAuthMethod(
632
710
  if (isCancel(choice)) return back()
633
711
  return value(choice)
634
712
  }
635
- // Single-method providers: no prompt to back out of, so always advance.
636
- return value(supportsOAuth ? 'oauth' : 'api-key')
713
+ return autoValue(supportsOAuth ? 'oauth' : 'api-key')
637
714
  }
638
715
 
639
716
  async function pickVisionProvider(
@@ -643,7 +720,7 @@ async function pickVisionProvider(
643
720
  const providers = uniqueProviders(options)
644
721
  if (providers.length === 0) {
645
722
  log.warn('No vision-capable models available; skipping vision profile.')
646
- return value('skip')
723
+ return autoValue('skip')
647
724
  }
648
725
  const choice = await select<KnownProviderId | 'skip'>({
649
726
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
@@ -699,6 +776,7 @@ async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResu
699
776
  { value: 'discord', label: 'Discord' },
700
777
  { value: 'telegram', label: 'Telegram' },
701
778
  { value: 'kakaotalk', label: 'KakaoTalk' },
779
+ { value: 'github', label: 'GitHub' },
702
780
  { value: 'none', label: 'Skip — no channel right now' },
703
781
  ],
704
782
  initialValue: initial ?? 'slack',
@@ -719,6 +797,8 @@ async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<Collect
719
797
  return runSlackFlow()
720
798
  case 'telegram':
721
799
  return runTelegramFlow()
800
+ case 'github':
801
+ return runGithubFlow()
722
802
  }
723
803
  }
724
804
 
@@ -881,6 +961,151 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
881
961
  }
882
962
  }
883
963
 
964
+ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
965
+ note(
966
+ [
967
+ 'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
968
+ 'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
969
+ 'Metadata read, and Webhooks read/write (TypeClaw will create and manage the repository webhooks for you).',
970
+ ].join('\n'),
971
+ 'Get GitHub credentials',
972
+ )
973
+ const authType = await select<'pat' | 'app'>({
974
+ message: 'GitHub authentication type',
975
+ options: [
976
+ { value: 'pat', label: 'Fine-grained personal access token' },
977
+ { value: 'app', label: 'GitHub App installation token' },
978
+ ],
979
+ })
980
+ if (isCancel(authType)) return back()
981
+ const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
982
+ if (auth === null) return back()
983
+ note('GitHub webhooks need a public URL. TypeClaw can manage a tunnel for you.', 'GitHub webhook tunnel')
984
+ const tunnelProvider = await select<GithubTunnelProvider>({
985
+ message: 'Tunnel provider',
986
+ options: [
987
+ {
988
+ value: 'cloudflare-quick',
989
+ label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
990
+ },
991
+ { value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
992
+ { value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
993
+ ],
994
+ initialValue: 'cloudflare-quick',
995
+ })
996
+ if (isCancel(tunnelProvider)) return back()
997
+ const webhookUrl =
998
+ tunnelProvider === 'external'
999
+ ? await text({
1000
+ message: 'Public webhook URL (GitHub will POST events here)',
1001
+ validate: (v) => validateGithubUrl(v ?? '', 'Webhook URL is required'),
1002
+ })
1003
+ : undefined
1004
+ if (isCancel(webhookUrl)) return back()
1005
+ const port = await text({
1006
+ message: 'Local webhook port inside the agent container',
1007
+ initialValue: '8975',
1008
+ validate: (v) => {
1009
+ const parsed = Number(v)
1010
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Port must be a positive integer'
1011
+ },
1012
+ })
1013
+ if (isCancel(port)) return back()
1014
+ const secret = await password({
1015
+ message: 'Webhook secret (leave blank to auto-generate)',
1016
+ })
1017
+ if (isCancel(secret)) return back()
1018
+ // clack's password() returns `undefined` on an empty submission (it has no
1019
+ // validate guard and never coerces to ''), so we normalize before the
1020
+ // length checks below to avoid a TypeError on the "leave blank" path.
1021
+ const enteredSecret = typeof secret === 'string' ? secret : ''
1022
+ const reposRaw = await text({
1023
+ message: 'Repositories to allow (comma-separated owner/repo)',
1024
+ validate: (v) => (parseGithubRepos(v ?? '').length > 0 ? undefined : 'At least one owner/repo is required'),
1025
+ })
1026
+ if (isCancel(reposRaw)) return back()
1027
+ const resolvedSecret = enteredSecret.length > 0 ? enteredSecret : randomBytes(32).toString('hex')
1028
+ return value({
1029
+ github: {
1030
+ webhookSecret: resolvedSecret,
1031
+ tunnelProvider,
1032
+ ...(webhookUrl !== undefined ? { webhookUrl } : {}),
1033
+ webhookPort: Number(port),
1034
+ repos: parseGithubRepos(reposRaw),
1035
+ auth,
1036
+ },
1037
+ })
1038
+ }
1039
+
1040
+ async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string } | null> {
1041
+ const pat = await password({
1042
+ message: 'GitHub fine-grained PAT',
1043
+ validate: (v) => (v && v.length > 0 ? undefined : 'PAT is required'),
1044
+ })
1045
+ if (isCancel(pat)) return null
1046
+ return { type: 'pat', pat }
1047
+ }
1048
+
1049
+ async function promptGithubAppAuth(): Promise<{
1050
+ type: 'app'
1051
+ appId: number
1052
+ privateKey: string
1053
+ installationId?: number
1054
+ } | null> {
1055
+ const appId = await text({
1056
+ message: 'GitHub App ID',
1057
+ validate: (v) => validatePositiveInteger(v ?? '', 'App ID is required'),
1058
+ })
1059
+ if (isCancel(appId)) return null
1060
+ const privateKeyInput = await text({
1061
+ message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
1062
+ validate: (v) => (v && v.length > 0 ? undefined : 'Private key is required'),
1063
+ })
1064
+ if (isCancel(privateKeyInput)) return null
1065
+ const installationId = await text({
1066
+ message: 'Installation ID (optional; leave blank to auto-discover)',
1067
+ validate: (v) =>
1068
+ v === undefined || v === '' ? undefined : validatePositiveInteger(v, 'Installation ID is required'),
1069
+ })
1070
+ if (isCancel(installationId)) return null
1071
+ const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
1072
+ return {
1073
+ type: 'app',
1074
+ appId: Number(appId),
1075
+ privateKey: await resolveGithubPrivateKey(privateKeyInput),
1076
+ ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
1077
+ }
1078
+ }
1079
+
1080
+ async function resolveGithubPrivateKey(input: string): Promise<string> {
1081
+ const normalized = input.replace(/\\n/g, '\n')
1082
+ if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
1083
+ return await readFile(input, 'utf8')
1084
+ }
1085
+
1086
+ function parseGithubRepos(input: string): string[] {
1087
+ return input
1088
+ .split(',')
1089
+ .map((v) => v.trim())
1090
+ .filter((v) => /^[^\s/]+\/[^\s/]+$/.test(v))
1091
+ }
1092
+
1093
+ function validateGithubUrl(v: string, requiredMessage: string): string | undefined {
1094
+ if (!v || v.length === 0) return requiredMessage
1095
+ try {
1096
+ new URL(v)
1097
+ return undefined
1098
+ } catch {
1099
+ return 'Must be a valid URL'
1100
+ }
1101
+ }
1102
+
1103
+ function validatePositiveInteger(v: string, requiredMessage: string): string | undefined {
1104
+ if (!v || v.length === 0) return requiredMessage
1105
+ const parsed = Number(v)
1106
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Must be a positive integer'
1107
+ }
1108
+
884
1109
  async function runTelegramFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
885
1110
  note(
886
1111
  [
@@ -950,6 +1175,9 @@ function reportProgress(
950
1175
  case 'kakaotalk-auth':
951
1176
  s.stop(reportKakaotalkAuth(event.result))
952
1177
  break
1178
+ case 'github-webhooks':
1179
+ s.stop(formatEagerGithubWebhookInstallResult(event.result))
1180
+ break
953
1181
  case 'oauth-login':
954
1182
  s.stop(event.result.ok ? 'Logged in.' : `OAuth login failed: ${event.result.reason}`)
955
1183
  break
@@ -1096,6 +1324,7 @@ const START_MESSAGES: Record<Exclude<InitStep, 'hatching'>, string> = {
1096
1324
  'oauth-login': 'Waiting for browser login...',
1097
1325
  scaffold: 'Laying the egg...',
1098
1326
  'kakaotalk-auth': 'Logging in to KakaoTalk...',
1327
+ 'github-webhooks': 'Installing GitHub repository webhooks...',
1099
1328
  install: 'Installing dependencies with bun...',
1100
1329
  dockerfile: 'Writing Dockerfile...',
1101
1330
  git: 'Initializing git repository...',
package/src/cli/model.ts CHANGED
@@ -57,7 +57,8 @@ const setSub = defineCommand({
57
57
  process.exit(1)
58
58
  }
59
59
  done({
60
- title: c.green(`Profile "${profile}" set to ${ref}.`),
60
+ title: c.green(`Profile "${profile}" set.`),
61
+ details: `${profile} → ${ref}`,
61
62
  hints: [{ label: 'If the agent is running:', command: 'typeclaw reload' }],
62
63
  })
63
64
  },
@@ -97,7 +98,8 @@ const addSub = defineCommand({
97
98
  process.exit(1)
98
99
  }
99
100
  done({
100
- title: c.green(`Profile "${args.profile}" → ${ref}.`),
101
+ title: c.green(`Profile "${args.profile}" added.`),
102
+ details: `${args.profile} → ${ref}`,
101
103
  hints: [{ label: 'If the agent is running:', command: 'typeclaw reload' }],
102
104
  })
103
105
  },
@@ -0,0 +1,49 @@
1
+ import type { z } from 'zod'
2
+
3
+ import { describeLeaf } from '@/plugin/zod-introspect'
4
+
5
+ import type { DiscoveredCommand } from './plugin-commands'
6
+
7
+ export function renderPluginCommandsSection(commands: readonly DiscoveredCommand[]): string | null {
8
+ if (commands.length === 0) return null
9
+ const lines: string[] = ['Plugin commands:']
10
+ const namePad = Math.max(...commands.map((c) => c.commandName.length))
11
+ for (const c of commands) {
12
+ lines.push(` ${c.commandName.padEnd(namePad)} ${c.command.description}`)
13
+ }
14
+ return lines.join('\n')
15
+ }
16
+
17
+ export function renderCommandHelp(c: DiscoveredCommand): string {
18
+ const lines: string[] = []
19
+ lines.push(`typeclaw ${c.commandName} — ${c.command.description}`)
20
+ lines.push('')
21
+ lines.push(` Plugin: ${c.pluginName}${c.pluginVersion !== undefined ? ` v${c.pluginVersion}` : ''}`)
22
+ lines.push(` Surface: ${c.command.surface}`)
23
+ lines.push('')
24
+
25
+ if (c.command.args === undefined) {
26
+ lines.push(' Options:')
27
+ lines.push(' (no options)')
28
+ return lines.join('\n')
29
+ }
30
+
31
+ lines.push(' Options:')
32
+ for (const line of renderFlags(c.command.args)) {
33
+ lines.push(` ${line}`)
34
+ }
35
+ return lines.join('\n')
36
+ }
37
+
38
+ export function renderFlags(schema: z.ZodObject<z.ZodRawShape>): string[] {
39
+ const out: string[] = []
40
+ const shape = schema.shape as Record<string, unknown>
41
+ for (const [field, leaf] of Object.entries(shape)) {
42
+ const info = describeLeaf(leaf)
43
+ const required = info.required ? ' (required)' : ''
44
+ const defaultPart = info.defaultValue !== undefined ? ` (default: ${info.defaultValue})` : ''
45
+ const descPart = info.description !== undefined ? ` ${info.description}` : ''
46
+ out.push(`--${field}=<${info.kind}>${descPart}${required}${defaultPart}`)
47
+ }
48
+ return out
49
+ }
@@ -0,0 +1,112 @@
1
+ import { proxyContainerCommand } from './container-command-client'
2
+ import { parseArgs, runHostCommand } from './host-command-runner'
3
+ import { renderCommandHelp } from './plugin-command-help'
4
+ import { discoverCommands } from './plugin-commands'
5
+
6
+ export type PluginCommandDispatchOutcome =
7
+ | { kind: 'not-found' }
8
+ | { kind: 'dispatched'; exitCode: number }
9
+ | { kind: 'error'; exitCode: number; message: string }
10
+
11
+ export type DispatchOptions = {
12
+ name: string
13
+ rawArgs: readonly string[]
14
+ cwd: string
15
+ stdin?: ReadableStream<Uint8Array>
16
+ stdout?: WritableStream<Uint8Array>
17
+ stderr?: WritableStream<Uint8Array>
18
+ signal?: AbortSignal
19
+ }
20
+
21
+ export async function dispatchPluginCommand(opts: DispatchOptions): Promise<PluginCommandDispatchOutcome> {
22
+ const discovery = await discoverCommands({ cwd: opts.cwd })
23
+ const match = discovery.commands.find((c) => c.commandName === opts.name)
24
+ if (match === undefined) {
25
+ // Surface plugin load failures so a user typing `typeclaw <cmd>` sees why
26
+ // their plugin's command isn't listed, instead of a generic "not found".
27
+ if (discovery.loadErrors.length > 0) {
28
+ const stderr = opts.stderr ?? defaultStderr()
29
+ const writer = stderr.getWriter()
30
+ const encoder = new TextEncoder()
31
+ for (const e of discovery.loadErrors) {
32
+ await writer.write(encoder.encode(`[plugin-commands] ${e.entry}: ${e.error}\n`))
33
+ }
34
+ writer.releaseLock()
35
+ }
36
+ return { kind: 'not-found' }
37
+ }
38
+
39
+ const stdin = opts.stdin ?? defaultStdin()
40
+ const stdout = opts.stdout ?? defaultStdout()
41
+ const stderr = opts.stderr ?? defaultStderr()
42
+ const signal = opts.signal ?? new AbortController().signal
43
+
44
+ if (opts.rawArgs.includes('--help') || opts.rawArgs.includes('-h')) {
45
+ const help = renderCommandHelp(match)
46
+ const writer = stdout.getWriter()
47
+ await writer.write(new TextEncoder().encode(`${help}\n`))
48
+ writer.releaseLock()
49
+ return { kind: 'dispatched', exitCode: 0 }
50
+ }
51
+
52
+ if (match.command.surface === 'container') {
53
+ const parsed = parseArgs(match.command, opts.rawArgs)
54
+ if (!parsed.ok) {
55
+ return { kind: 'error', exitCode: 2, message: parsed.message }
56
+ }
57
+ const containerResult = await proxyContainerCommand({
58
+ agentDir: discovery.agentDir,
59
+ commandName: match.commandName,
60
+ args: parsed.value,
61
+ stdin,
62
+ stdout,
63
+ stderr,
64
+ abortSignal: signal,
65
+ })
66
+ if (!containerResult.ok) {
67
+ return { kind: 'error', exitCode: containerResult.exitCode, message: containerResult.message }
68
+ }
69
+ return { kind: 'dispatched', exitCode: containerResult.exitCode }
70
+ }
71
+
72
+ const result = await runHostCommand({
73
+ agentDir: discovery.agentDir,
74
+ pluginName: match.pluginName,
75
+ pluginVersion: match.pluginVersion,
76
+ command: match.command,
77
+ rawArgs: opts.rawArgs,
78
+ signal,
79
+ stdin,
80
+ stdout,
81
+ stderr,
82
+ })
83
+
84
+ if (!result.ok) {
85
+ return { kind: 'error', exitCode: result.exitCode, message: result.message }
86
+ }
87
+ return { kind: 'dispatched', exitCode: result.exitCode }
88
+ }
89
+
90
+ function defaultStdin(): ReadableStream<Uint8Array> {
91
+ return new ReadableStream<Uint8Array>({
92
+ start(controller) {
93
+ controller.close()
94
+ },
95
+ })
96
+ }
97
+
98
+ function defaultStdout(): WritableStream<Uint8Array> {
99
+ return new WritableStream<Uint8Array>({
100
+ write(chunk) {
101
+ process.stdout.write(chunk)
102
+ },
103
+ })
104
+ }
105
+
106
+ function defaultStderr(): WritableStream<Uint8Array> {
107
+ return new WritableStream<Uint8Array>({
108
+ write(chunk) {
109
+ process.stderr.write(chunk)
110
+ },
111
+ })
112
+ }