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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/secrets.schema.json +113 -0
- package/src/agent/index.ts +149 -30
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +70 -35
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +28 -2
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +90 -4
- package/src/config/reloadable.ts +22 -4
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +62 -6
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +119 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +393 -15
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
- 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.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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
|
|
103
|
+
log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
|
|
80
104
|
|
|
81
|
-
|
|
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 {
|
|
84
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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}"
|
|
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
|
+
}
|