typeclaw 0.9.2 → 0.11.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/package.json +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +120 -7
package/src/cli/channel.ts
CHANGED
|
@@ -8,8 +8,10 @@ import { config } from '@/config'
|
|
|
8
8
|
import { start, status, stop } from '@/container'
|
|
9
9
|
import {
|
|
10
10
|
CHANNEL_KINDS,
|
|
11
|
+
appendOrReplaceEnvKey,
|
|
11
12
|
findAgentDir,
|
|
12
13
|
formatEagerGithubWebhookInstallResult,
|
|
14
|
+
hasEnvKey,
|
|
13
15
|
isInitialized,
|
|
14
16
|
readConfiguredChannels,
|
|
15
17
|
readGithubAuthType,
|
|
@@ -61,7 +63,7 @@ const addSub = defineCommand({
|
|
|
61
63
|
|
|
62
64
|
intro(`Adding channel: ${CHANNEL_LABELS[channel]}`)
|
|
63
65
|
|
|
64
|
-
const credentials = await collectCredentials(channel)
|
|
66
|
+
const credentials = await collectCredentials(channel, cwd)
|
|
65
67
|
|
|
66
68
|
const events: AddChannelStepEvent[] = []
|
|
67
69
|
try {
|
|
@@ -518,14 +520,14 @@ async function runSetGithub(cwd: string): Promise<void> {
|
|
|
518
520
|
}
|
|
519
521
|
const authLabel =
|
|
520
522
|
authType === 'pat'
|
|
521
|
-
? '
|
|
522
|
-
: '
|
|
523
|
+
? 'Auth credential — rotate the PAT, or switch to GitHub App auth (recommended)'
|
|
524
|
+
: 'Auth credential — rotate the App private key, or switch to PAT auth (recommended)'
|
|
523
525
|
const choice = await select<GithubSetChoice>({
|
|
524
|
-
message: 'Which GitHub secret do you want to
|
|
526
|
+
message: 'Which GitHub secret do you want to update?',
|
|
525
527
|
options: [
|
|
526
528
|
{ value: 'auth', label: authLabel },
|
|
527
529
|
{ value: 'webhook', label: 'Webhook secret — shared secret for verifying GitHub payloads' },
|
|
528
|
-
{ value: 'both', label: 'Both secrets —
|
|
530
|
+
{ value: 'both', label: 'Both secrets — update the auth credential and the webhook secret' },
|
|
529
531
|
],
|
|
530
532
|
initialValue: 'auth',
|
|
531
533
|
})
|
|
@@ -537,36 +539,7 @@ async function runSetGithub(cwd: string): Promise<void> {
|
|
|
537
539
|
const patch: GithubCredentialPatch = {}
|
|
538
540
|
|
|
539
541
|
if (choice === 'auth' || choice === 'both') {
|
|
540
|
-
|
|
541
|
-
note(
|
|
542
|
-
[
|
|
543
|
-
'Rotate at https://github.com/settings/personal-access-tokens.',
|
|
544
|
-
'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
|
|
545
|
-
'Metadata read, and Webhooks read/write.',
|
|
546
|
-
].join('\n'),
|
|
547
|
-
'Rotate the GitHub PAT',
|
|
548
|
-
)
|
|
549
|
-
const { pat } = await promptGithubPatAuth()
|
|
550
|
-
patch.auth = { type: 'pat', pat }
|
|
551
|
-
} else {
|
|
552
|
-
note(
|
|
553
|
-
[
|
|
554
|
-
'Rotate at https://github.com/settings/apps/<your-app> → Private keys → Generate a private key.',
|
|
555
|
-
'GitHub immediately downloads the new .pem. The previous key keeps working until you delete it,',
|
|
556
|
-
'so it is safe to rotate without downtime.',
|
|
557
|
-
].join('\n'),
|
|
558
|
-
'Rotate the GitHub App private key',
|
|
559
|
-
)
|
|
560
|
-
const privateKeyInput = await text({
|
|
561
|
-
message: 'New GitHub App private key PEM, escaped PEM, or path to .pem file',
|
|
562
|
-
validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
|
|
563
|
-
})
|
|
564
|
-
if (isCancel(privateKeyInput)) {
|
|
565
|
-
cancel('Aborted.')
|
|
566
|
-
process.exit(0)
|
|
567
|
-
}
|
|
568
|
-
patch.auth = { type: 'app', privateKey: await resolvePrivateKeyInput(privateKeyInput) }
|
|
569
|
-
}
|
|
542
|
+
patch.auth = await promptGithubAuthUpdate(authType)
|
|
570
543
|
}
|
|
571
544
|
|
|
572
545
|
if (choice === 'webhook' || choice === 'both') {
|
|
@@ -604,7 +577,7 @@ type CollectedCredentials =
|
|
|
604
577
|
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
605
578
|
}
|
|
606
579
|
|
|
607
|
-
async function collectCredentials(channel: ChannelKind): Promise<CollectedCredentials> {
|
|
580
|
+
async function collectCredentials(channel: ChannelKind, cwd: string): Promise<CollectedCredentials> {
|
|
608
581
|
switch (channel) {
|
|
609
582
|
case 'discord-bot':
|
|
610
583
|
return { channel, discordBotToken: await promptDiscordToken() }
|
|
@@ -628,17 +601,19 @@ async function collectCredentials(channel: ChannelKind): Promise<CollectedCreden
|
|
|
628
601
|
}
|
|
629
602
|
}
|
|
630
603
|
case 'github': {
|
|
631
|
-
const creds = await promptGithubCredentials()
|
|
604
|
+
const creds = await promptGithubCredentials(cwd)
|
|
632
605
|
return { channel, ...creds }
|
|
633
606
|
}
|
|
634
607
|
}
|
|
635
608
|
}
|
|
636
609
|
|
|
637
|
-
async function promptGithubCredentials(): Promise<{
|
|
610
|
+
async function promptGithubCredentials(cwd: string): Promise<{
|
|
638
611
|
webhookSecret: string
|
|
639
612
|
tunnelProvider: GithubTunnelProvider
|
|
640
613
|
webhookUrl?: string
|
|
641
614
|
webhookPort?: number
|
|
615
|
+
hostname?: string
|
|
616
|
+
tokenEnv?: string
|
|
642
617
|
repos: string[]
|
|
643
618
|
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
644
619
|
}> {
|
|
@@ -670,6 +645,10 @@ async function promptGithubCredentials(): Promise<{
|
|
|
670
645
|
value: 'cloudflare-quick',
|
|
671
646
|
label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
|
|
672
647
|
},
|
|
648
|
+
{
|
|
649
|
+
value: 'cloudflare-named',
|
|
650
|
+
label: 'Cloudflare Named Tunnel — stable URL, needs Cloudflare account + domain',
|
|
651
|
+
},
|
|
673
652
|
{ value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
|
|
674
653
|
{ value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
|
|
675
654
|
],
|
|
@@ -690,6 +669,7 @@ async function promptGithubCredentials(): Promise<{
|
|
|
690
669
|
cancel('Aborted.')
|
|
691
670
|
process.exit(0)
|
|
692
671
|
}
|
|
672
|
+
const namedCreds = tunnelProvider === 'cloudflare-named' ? await promptCloudflareNamedTunnel(cwd) : undefined
|
|
693
673
|
const port = await text({
|
|
694
674
|
message: 'Local webhook port inside the agent container',
|
|
695
675
|
initialValue: '8975',
|
|
@@ -727,11 +707,128 @@ async function promptGithubCredentials(): Promise<{
|
|
|
727
707
|
tunnelProvider,
|
|
728
708
|
...(webhookUrl !== undefined ? { webhookUrl } : {}),
|
|
729
709
|
webhookPort: Number(port),
|
|
710
|
+
...(namedCreds !== undefined ? namedCreds : {}),
|
|
730
711
|
repos: parseRepos(reposRaw),
|
|
731
712
|
auth,
|
|
732
713
|
}
|
|
733
714
|
}
|
|
734
715
|
|
|
716
|
+
async function promptCloudflareNamedTunnel(cwd: string): Promise<{ hostname: string; tokenEnv: string }> {
|
|
717
|
+
const tokenEnv = 'CLOUDFLARE_TUNNEL_TOKEN'
|
|
718
|
+
note(
|
|
719
|
+
[
|
|
720
|
+
'Cloudflare Named Tunnel needs a tunnel you created in the Zero Trust dashboard:',
|
|
721
|
+
' 1. Networks → Tunnels → Create a tunnel → Cloudflared. Copy the token shown on the install screen.',
|
|
722
|
+
' 2. Public Hostname tab → Add: subdomain + your-domain, service type HTTP, URL localhost:<webhook port>.',
|
|
723
|
+
` 3. Paste the token below when prompted — TypeClaw will write it to .env as ${tokenEnv}.`,
|
|
724
|
+
'A tunnel without a Public Hostname registers but routes nothing.',
|
|
725
|
+
].join('\n'),
|
|
726
|
+
'Cloudflare named tunnel',
|
|
727
|
+
)
|
|
728
|
+
const hostname = await text({
|
|
729
|
+
message: 'Public hostname configured in the dashboard (https://...)',
|
|
730
|
+
validate: (value) => validateUrl(value ?? '', 'Hostname is required'),
|
|
731
|
+
})
|
|
732
|
+
if (isCancel(hostname)) {
|
|
733
|
+
cancel('Aborted.')
|
|
734
|
+
process.exit(0)
|
|
735
|
+
}
|
|
736
|
+
if (!hasEnvKey(cwd, tokenEnv)) {
|
|
737
|
+
const token = await password({
|
|
738
|
+
message: `Cloudflare tunnel token (will be written to .env as ${tokenEnv})`,
|
|
739
|
+
validate: (value) => (value && value.length > 0 ? undefined : 'Token is required'),
|
|
740
|
+
})
|
|
741
|
+
if (isCancel(token)) {
|
|
742
|
+
cancel('Aborted.')
|
|
743
|
+
process.exit(0)
|
|
744
|
+
}
|
|
745
|
+
appendOrReplaceEnvKey(cwd, tokenEnv, token)
|
|
746
|
+
}
|
|
747
|
+
return { hostname, tokenEnv }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
type GithubAuthUpdateAction = 'rotate' | 'switch'
|
|
751
|
+
|
|
752
|
+
async function promptGithubAuthUpdate(currentType: 'pat' | 'app'): Promise<GithubCredentialPatch['auth']> {
|
|
753
|
+
const rotateLabel =
|
|
754
|
+
currentType === 'pat'
|
|
755
|
+
? 'Rotate the PAT (replace the current personal access token)'
|
|
756
|
+
: 'Rotate the App private key (replace the current GitHub App private key)'
|
|
757
|
+
const switchLabel =
|
|
758
|
+
currentType === 'pat'
|
|
759
|
+
? 'Switch to GitHub App auth (replace the PAT with App credentials)'
|
|
760
|
+
: 'Switch to PAT auth (replace the App credentials with a personal access token)'
|
|
761
|
+
const action = await select<GithubAuthUpdateAction>({
|
|
762
|
+
message: 'Update the GitHub auth credential',
|
|
763
|
+
options: [
|
|
764
|
+
{ value: 'rotate', label: rotateLabel },
|
|
765
|
+
{ value: 'switch', label: switchLabel },
|
|
766
|
+
],
|
|
767
|
+
initialValue: 'rotate',
|
|
768
|
+
})
|
|
769
|
+
if (isCancel(action)) {
|
|
770
|
+
cancel('Aborted.')
|
|
771
|
+
process.exit(0)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const nextType: 'pat' | 'app' = action === 'rotate' ? currentType : currentType === 'pat' ? 'app' : 'pat'
|
|
775
|
+
|
|
776
|
+
if (nextType === 'pat') {
|
|
777
|
+
if (action === 'rotate') {
|
|
778
|
+
note(
|
|
779
|
+
[
|
|
780
|
+
'Rotate at https://github.com/settings/personal-access-tokens.',
|
|
781
|
+
'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
|
|
782
|
+
'Metadata read, and Webhooks read/write.',
|
|
783
|
+
].join('\n'),
|
|
784
|
+
'Rotate the GitHub PAT',
|
|
785
|
+
)
|
|
786
|
+
} else {
|
|
787
|
+
note(
|
|
788
|
+
[
|
|
789
|
+
'Create a fine-grained PAT at https://github.com/settings/personal-access-tokens.',
|
|
790
|
+
'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
|
|
791
|
+
'Metadata read, and Webhooks read/write.',
|
|
792
|
+
].join('\n'),
|
|
793
|
+
'Switch to GitHub PAT auth',
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
return await promptGithubPatAuth()
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (action === 'rotate') {
|
|
800
|
+
note(
|
|
801
|
+
[
|
|
802
|
+
'Rotate at https://github.com/settings/apps/<your-app> → Private keys → Generate a private key.',
|
|
803
|
+
'GitHub immediately downloads the new .pem. The previous key keeps working until you delete it,',
|
|
804
|
+
'so it is safe to rotate without downtime.',
|
|
805
|
+
].join('\n'),
|
|
806
|
+
'Rotate the GitHub App private key',
|
|
807
|
+
)
|
|
808
|
+
const privateKeyInput = await text({
|
|
809
|
+
message: 'New GitHub App private key PEM, escaped PEM, or path to .pem file',
|
|
810
|
+
validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
|
|
811
|
+
})
|
|
812
|
+
if (isCancel(privateKeyInput)) {
|
|
813
|
+
cancel('Aborted.')
|
|
814
|
+
process.exit(0)
|
|
815
|
+
}
|
|
816
|
+
return { type: 'app', privateKey: await resolvePrivateKeyInput(privateKeyInput) }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
note(
|
|
820
|
+
[
|
|
821
|
+
'Create a GitHub App at https://github.com/settings/apps/new and install it on your repositories.',
|
|
822
|
+
'Required permissions: Issues read/write, Pull requests read/write, Discussions read/write (if used),',
|
|
823
|
+
'Metadata read, and Webhooks read/write.',
|
|
824
|
+
'Then collect the App ID, generate a private key (.pem), and grab the Installation ID from the URL',
|
|
825
|
+
'of the installation page (https://github.com/settings/installations/<installation-id>).',
|
|
826
|
+
].join('\n'),
|
|
827
|
+
'Switch to GitHub App auth',
|
|
828
|
+
)
|
|
829
|
+
return await promptGithubAppAuth()
|
|
830
|
+
}
|
|
831
|
+
|
|
735
832
|
async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string }> {
|
|
736
833
|
const pat = await password({
|
|
737
834
|
message: 'GitHub fine-grained PAT',
|
package/src/cli/cron.ts
CHANGED
|
@@ -38,7 +38,7 @@ const listSub = defineCommand({
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
let url: string | undefined = args.url
|
|
41
|
-
if (url === undefined) {
|
|
41
|
+
if (url === undefined && process.env.TYPECLAW_CONTAINER_NAME === undefined) {
|
|
42
42
|
const precheck = await requireContainerRunning({ cwd })
|
|
43
43
|
if (!precheck.ok) {
|
|
44
44
|
console.error(errorLine(precheck.reason))
|
package/src/cli/init.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { defineCommand } from 'citty'
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
KNOWN_PROVIDERS,
|
|
9
|
-
providerForModelRef,
|
|
10
9
|
supportsApiKey as providerSupportsApiKey,
|
|
11
10
|
supportsOAuth as providerSupportsOAuth,
|
|
12
11
|
type KnownModelRef,
|
|
@@ -14,9 +13,12 @@ import {
|
|
|
14
13
|
} from '@/config/providers'
|
|
15
14
|
import { checkDockerAvailable, type DockerAvailability } from '@/container'
|
|
16
15
|
import {
|
|
16
|
+
appendOrReplaceEnvKey,
|
|
17
17
|
findAgentDir,
|
|
18
18
|
formatEagerGithubWebhookInstallResult,
|
|
19
|
+
hasEnvKey,
|
|
19
20
|
hasExistingChannelSecrets,
|
|
21
|
+
hasExistingOAuthCredentials,
|
|
20
22
|
isDirectoryNonEmpty,
|
|
21
23
|
isHatched,
|
|
22
24
|
readExistingProviderApiKey,
|
|
@@ -79,8 +81,17 @@ export const init = defineCommand({
|
|
|
79
81
|
name: 'init',
|
|
80
82
|
description: 'initialize a new typeclaw agent in the current directory',
|
|
81
83
|
},
|
|
82
|
-
|
|
84
|
+
args: {
|
|
85
|
+
reset: {
|
|
86
|
+
type: 'boolean',
|
|
87
|
+
description:
|
|
88
|
+
'ignore any partial secrets.json state from an earlier aborted run and re-prompt for every credential',
|
|
89
|
+
default: false,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
async run({ args }) {
|
|
83
93
|
const cwd = process.cwd()
|
|
94
|
+
const reset = args.reset === true
|
|
84
95
|
|
|
85
96
|
const existingAgent = findAgentDir(cwd)
|
|
86
97
|
if (existingAgent !== null && existingAgent !== cwd) {
|
|
@@ -129,7 +140,7 @@ export const init = defineCommand({
|
|
|
129
140
|
|
|
130
141
|
let collected: CollectedInputs
|
|
131
142
|
try {
|
|
132
|
-
collected = await collectWizardInputs(cwd, defaultWizardPrompts)
|
|
143
|
+
collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset })
|
|
133
144
|
} catch (error) {
|
|
134
145
|
if (error instanceof WizardAbortedError) {
|
|
135
146
|
if (error.oauthCredentialsSaved) {
|
|
@@ -330,17 +341,13 @@ type StepId =
|
|
|
330
341
|
export interface WizardPrompts {
|
|
331
342
|
loadCatalog: () => Promise<NonNullable<WizardState['catalog']>>
|
|
332
343
|
readExistingApiKey: (cwd: string, providerId: KnownProviderId) => Promise<string | null>
|
|
344
|
+
hasExistingOAuthCredentials: (cwd: string, providerId: KnownProviderId) => Promise<boolean>
|
|
333
345
|
pickProvider: (options: ModelOption[], initial: KnownProviderId | undefined) => Promise<StepResult<KnownProviderId>>
|
|
334
346
|
pickModel: (
|
|
335
347
|
options: ModelOption[],
|
|
336
348
|
providerId: KnownProviderId,
|
|
337
349
|
initial: KnownModelRef | undefined,
|
|
338
350
|
) => Promise<StepResult<ModelOption>>
|
|
339
|
-
askReuseExistingKey: (
|
|
340
|
-
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
341
|
-
existingApiKey: string | null,
|
|
342
|
-
initial: boolean | undefined,
|
|
343
|
-
) => Promise<StepResult<'reuse' | 'prompt'>>
|
|
344
351
|
pickAuthMethod: (
|
|
345
352
|
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
346
353
|
initial: 'api-key' | 'oauth' | undefined,
|
|
@@ -358,17 +365,12 @@ export interface WizardPrompts {
|
|
|
358
365
|
) => Promise<StepResult<ModelOption>>
|
|
359
366
|
pickChannel: (initial: ChannelChoice | undefined) => Promise<StepResult<ChannelChoice>>
|
|
360
367
|
hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
|
|
361
|
-
|
|
362
|
-
runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
|
|
368
|
+
runChannelFlow: (choice: ChannelChoice, cwd: string) => Promise<StepResult<CollectedInputs['channelSecrets']>>
|
|
363
369
|
runOAuthLogin: (
|
|
364
370
|
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
365
371
|
cwd: string,
|
|
366
372
|
model: KnownModelRef,
|
|
367
373
|
) => Promise<OAuthLoginResult>
|
|
368
|
-
// Asked after a failed OAuth login. `apiKeyAvailable` is true when the
|
|
369
|
-
// provider also supports api-key auth (so the wizard can offer a fallback
|
|
370
|
-
// path); false for OAuth-only providers like openai-codex, where the only
|
|
371
|
-
// options are retry or abort.
|
|
372
374
|
askOAuthFailureRecovery: (
|
|
373
375
|
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
374
376
|
reason: string,
|
|
@@ -376,14 +378,18 @@ export interface WizardPrompts {
|
|
|
376
378
|
) => Promise<OAuthFailureRecovery>
|
|
377
379
|
}
|
|
378
380
|
|
|
381
|
+
export type CollectWizardInputsOptions = {
|
|
382
|
+
reset?: boolean
|
|
383
|
+
}
|
|
384
|
+
|
|
379
385
|
export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
|
|
380
386
|
|
|
381
387
|
export const defaultWizardPrompts: WizardPrompts = {
|
|
382
388
|
loadCatalog,
|
|
383
389
|
readExistingApiKey: readExistingProviderApiKey,
|
|
390
|
+
hasExistingOAuthCredentials,
|
|
384
391
|
pickProvider,
|
|
385
392
|
pickModel: pickModelForProvider,
|
|
386
|
-
askReuseExistingKey,
|
|
387
393
|
pickAuthMethod,
|
|
388
394
|
askApiKey,
|
|
389
395
|
validateApiKey,
|
|
@@ -391,7 +397,6 @@ export const defaultWizardPrompts: WizardPrompts = {
|
|
|
391
397
|
pickVisionModel,
|
|
392
398
|
pickChannel,
|
|
393
399
|
hasExistingChannelSecrets,
|
|
394
|
-
askReuseExistingChannel,
|
|
395
400
|
runChannelFlow,
|
|
396
401
|
runOAuthLogin: async (provider, cwd, model) => {
|
|
397
402
|
const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
|
|
@@ -404,7 +409,12 @@ export const defaultWizardPrompts: WizardPrompts = {
|
|
|
404
409
|
askOAuthFailureRecovery,
|
|
405
410
|
}
|
|
406
411
|
|
|
407
|
-
export async function collectWizardInputs(
|
|
412
|
+
export async function collectWizardInputs(
|
|
413
|
+
cwd: string,
|
|
414
|
+
prompts: WizardPrompts,
|
|
415
|
+
options: CollectWizardInputsOptions = {},
|
|
416
|
+
): Promise<CollectedInputs> {
|
|
417
|
+
const reset = options.reset === true
|
|
408
418
|
const catalog = await prompts.loadCatalog()
|
|
409
419
|
const state: WizardState = { catalog }
|
|
410
420
|
let step: StepId = 'pick-provider'
|
|
@@ -425,6 +435,21 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
425
435
|
return result
|
|
426
436
|
}
|
|
427
437
|
|
|
438
|
+
const readExistingApiKey = async (providerId: KnownProviderId): Promise<string | null> => {
|
|
439
|
+
if (reset) return null
|
|
440
|
+
return await prompts.readExistingApiKey(cwd, providerId)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const hasExistingOAuth = async (providerId: KnownProviderId): Promise<boolean> => {
|
|
444
|
+
if (reset) return false
|
|
445
|
+
return await prompts.hasExistingOAuthCredentials(cwd, providerId)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const hasExistingChannel = async (channel: Exclude<ChannelChoice, 'none'>): Promise<boolean> => {
|
|
449
|
+
if (reset) return false
|
|
450
|
+
return await prompts.hasExistingChannelSecrets(cwd, channel)
|
|
451
|
+
}
|
|
452
|
+
|
|
428
453
|
while (true) {
|
|
429
454
|
switch (step) {
|
|
430
455
|
case 'pick-provider': {
|
|
@@ -456,17 +481,15 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
456
481
|
|
|
457
482
|
case 'reuse-existing-key': {
|
|
458
483
|
const provider = KNOWN_PROVIDERS[state.providerId!]
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (decision.value === 'reuse' && existingApiKey !== null) {
|
|
469
|
-
log.info(`Using existing ${provider.name} API key from secrets.json.`)
|
|
484
|
+
// Auto-resume: if `secrets.json` already has a usable api-key for
|
|
485
|
+
// this provider, reuse it silently. Issue #330: re-running init
|
|
486
|
+
// after a partial abort should pick up where the user left off
|
|
487
|
+
// without re-prompting for credentials they already supplied.
|
|
488
|
+
// `--reset` bypasses this by making `readExistingApiKey` return
|
|
489
|
+
// null, falling through to the normal auth-method flow.
|
|
490
|
+
const existingApiKey = await readExistingApiKey(state.providerId!)
|
|
491
|
+
if (existingApiKey !== null) {
|
|
492
|
+
log.info(`Reusing existing ${provider.name} API key from secrets.json.`)
|
|
470
493
|
state.llmAuth = { kind: 'api-key', apiKey: existingApiKey }
|
|
471
494
|
state.reuseExisting = true
|
|
472
495
|
step = stepAfterDefaultAuth(state)
|
|
@@ -482,11 +505,29 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
482
505
|
const provider = KNOWN_PROVIDERS[state.providerId!]
|
|
483
506
|
const result = onResult(step, await prompts.pickAuthMethod(provider, state.authMethod))
|
|
484
507
|
if (result.kind === 'back') {
|
|
485
|
-
|
|
508
|
+
// Skip past `reuse-existing-key` — it is a silent auto-resume
|
|
509
|
+
// step with no user prompt, so unwind directly to pick-model
|
|
510
|
+
// (the prior user-visible step). This only fires when
|
|
511
|
+
// pickAuthMethod was an interactive choice (dual-auth providers);
|
|
512
|
+
// single-method providers return autoValue and never reach the
|
|
513
|
+
// back branch.
|
|
514
|
+
step = 'pick-model'
|
|
486
515
|
break
|
|
487
516
|
}
|
|
488
517
|
state.authMethod = result.value
|
|
489
518
|
if (result.value === 'oauth') {
|
|
519
|
+
// Auto-resume: skip the browser flow when OAuth credentials are
|
|
520
|
+
// already on disk from a prior partial run. The fresh-tokens path
|
|
521
|
+
// and the resume path both leave `state.llmAuth = oauth-completed`,
|
|
522
|
+
// so downstream steps (vision, channel, scaffold) can't tell the
|
|
523
|
+
// difference. `--reset` short-circuits this by making
|
|
524
|
+
// `hasExistingOAuth` return false.
|
|
525
|
+
if (await hasExistingOAuth(state.providerId!)) {
|
|
526
|
+
log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
|
|
527
|
+
state.llmAuth = { kind: 'oauth-completed' }
|
|
528
|
+
step = stepAfterDefaultAuth(state)
|
|
529
|
+
break
|
|
530
|
+
}
|
|
490
531
|
// Run the browser login eagerly so the user sees the OAuth URL the
|
|
491
532
|
// moment they pick "OAuth (browser login)" — not at the end of the
|
|
492
533
|
// wizard. On failure we ask the user how to recover (retry / fall
|
|
@@ -583,9 +624,9 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
583
624
|
step = 'pick-channel'
|
|
584
625
|
break
|
|
585
626
|
}
|
|
586
|
-
const existingVisionKey = await
|
|
627
|
+
const existingVisionKey = await readExistingApiKey(state.visionProviderId!)
|
|
587
628
|
if (existingVisionKey !== null) {
|
|
588
|
-
log.info(`
|
|
629
|
+
log.info(`Reusing existing ${KNOWN_PROVIDERS[state.visionProviderId!].name} API key from secrets.json.`)
|
|
589
630
|
state.visionLlmAuth = { kind: 'api-key', apiKey: existingVisionKey }
|
|
590
631
|
state.visionReuseExisting = true
|
|
591
632
|
step = 'pick-channel'
|
|
@@ -606,6 +647,16 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
606
647
|
}
|
|
607
648
|
state.visionAuthMethod = result.value
|
|
608
649
|
if (result.value === 'oauth') {
|
|
650
|
+
// Auto-resume mirror of the default-provider branch above: skip
|
|
651
|
+
// the browser flow when vision OAuth credentials are already on
|
|
652
|
+
// disk. The same `--reset` short-circuit applies via
|
|
653
|
+
// `hasExistingOAuth`.
|
|
654
|
+
if (await hasExistingOAuth(state.visionProviderId!)) {
|
|
655
|
+
log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
|
|
656
|
+
state.visionLlmAuth = { kind: 'oauth-completed' }
|
|
657
|
+
step = 'pick-channel'
|
|
658
|
+
break
|
|
659
|
+
}
|
|
609
660
|
// Same eager-login + recovery-prompt rationale as the default-provider branch above.
|
|
610
661
|
const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
|
|
611
662
|
if (!login.ok) {
|
|
@@ -667,31 +718,26 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
667
718
|
|
|
668
719
|
case 'reuse-existing-channel': {
|
|
669
720
|
const choice = state.channelChoice as Exclude<ChannelChoice, 'none'>
|
|
670
|
-
|
|
721
|
+
// Auto-resume: when usable channel credentials already exist on
|
|
722
|
+
// disk, reuse them silently — mirrors the api-key and OAuth
|
|
723
|
+
// resume paths above. `--reset` short-circuits via
|
|
724
|
+
// `hasExistingChannel` returning false, falling through to the
|
|
725
|
+
// normal channel-flow prompts.
|
|
726
|
+
const present = await hasExistingChannel(choice)
|
|
671
727
|
if (!present) {
|
|
672
728
|
state.channelReuseOffered = false
|
|
673
729
|
state.channelReuseExisting = false
|
|
674
730
|
step = 'channel-flow'
|
|
675
731
|
break
|
|
676
732
|
}
|
|
733
|
+
log.info(`Reusing existing ${channelDisplayName(choice)} credentials from secrets.json.`)
|
|
677
734
|
state.channelReuseOffered = true
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
step = 'pick-channel'
|
|
681
|
-
break
|
|
682
|
-
}
|
|
683
|
-
if (decision.value === 'reuse') {
|
|
684
|
-
log.info(`Using existing ${channelDisplayName(choice)} credentials from secrets.json.`)
|
|
685
|
-
state.channelReuseExisting = true
|
|
686
|
-
return finalize(state, {})
|
|
687
|
-
}
|
|
688
|
-
state.channelReuseExisting = false
|
|
689
|
-
step = 'channel-flow'
|
|
690
|
-
break
|
|
735
|
+
state.channelReuseExisting = true
|
|
736
|
+
return finalize(state, {})
|
|
691
737
|
}
|
|
692
738
|
|
|
693
739
|
case 'channel-flow': {
|
|
694
|
-
const result = onResult(step, await prompts.runChannelFlow(state.channelChoice
|
|
740
|
+
const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!, cwd))
|
|
695
741
|
if (result.kind === 'back') {
|
|
696
742
|
step = state.channelReuseOffered === true ? 'reuse-existing-channel' : 'pick-channel'
|
|
697
743
|
break
|
|
@@ -818,31 +864,6 @@ async function pickModelForProvider(
|
|
|
818
864
|
return value(picked)
|
|
819
865
|
}
|
|
820
866
|
|
|
821
|
-
async function askReuseExistingKey(
|
|
822
|
-
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
823
|
-
existingApiKey: string | null,
|
|
824
|
-
initial: boolean | undefined,
|
|
825
|
-
): Promise<StepResult<'reuse' | 'prompt'>> {
|
|
826
|
-
if (!providerSupportsApiKey(provider) || existingApiKey === null) return value('prompt')
|
|
827
|
-
const reuse = await confirm({
|
|
828
|
-
message: `Reuse existing ${provider.name} API key from secrets.json?`,
|
|
829
|
-
initialValue: initial ?? true,
|
|
830
|
-
})
|
|
831
|
-
if (isCancel(reuse)) return back()
|
|
832
|
-
return value(reuse === true ? 'reuse' : 'prompt')
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
async function askReuseExistingChannel(
|
|
836
|
-
channel: Exclude<ChannelChoice, 'none'>,
|
|
837
|
-
): Promise<StepResult<'reuse' | 'prompt'>> {
|
|
838
|
-
const reuse = await confirm({
|
|
839
|
-
message: `Reuse existing ${channelDisplayName(channel)} credentials from secrets.json?`,
|
|
840
|
-
initialValue: true,
|
|
841
|
-
})
|
|
842
|
-
if (isCancel(reuse)) return back()
|
|
843
|
-
return value(reuse === true ? 'reuse' : 'prompt')
|
|
844
|
-
}
|
|
845
|
-
|
|
846
867
|
async function pickAuthMethod(
|
|
847
868
|
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
848
869
|
initial: 'api-key' | 'oauth' | undefined,
|
|
@@ -1011,7 +1032,10 @@ async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResu
|
|
|
1011
1032
|
return value(choice)
|
|
1012
1033
|
}
|
|
1013
1034
|
|
|
1014
|
-
async function runChannelFlow(
|
|
1035
|
+
async function runChannelFlow(
|
|
1036
|
+
choice: ChannelChoice,
|
|
1037
|
+
cwd: string,
|
|
1038
|
+
): Promise<StepResult<CollectedInputs['channelSecrets']>> {
|
|
1015
1039
|
switch (choice) {
|
|
1016
1040
|
case 'none':
|
|
1017
1041
|
return value({})
|
|
@@ -1024,7 +1048,7 @@ async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<Collect
|
|
|
1024
1048
|
case 'telegram':
|
|
1025
1049
|
return runTelegramFlow()
|
|
1026
1050
|
case 'github':
|
|
1027
|
-
return runGithubFlow()
|
|
1051
|
+
return runGithubFlow(cwd)
|
|
1028
1052
|
}
|
|
1029
1053
|
}
|
|
1030
1054
|
|
|
@@ -1144,7 +1168,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
|
|
|
1144
1168
|
}
|
|
1145
1169
|
}
|
|
1146
1170
|
|
|
1147
|
-
async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
|
|
1171
|
+
async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['channelSecrets']>> {
|
|
1148
1172
|
note(
|
|
1149
1173
|
[
|
|
1150
1174
|
'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
|
|
@@ -1171,6 +1195,10 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
|
|
|
1171
1195
|
value: 'cloudflare-quick',
|
|
1172
1196
|
label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
|
|
1173
1197
|
},
|
|
1198
|
+
{
|
|
1199
|
+
value: 'cloudflare-named',
|
|
1200
|
+
label: 'Cloudflare Named Tunnel — stable URL, needs Cloudflare account + domain',
|
|
1201
|
+
},
|
|
1174
1202
|
{ value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
|
|
1175
1203
|
{ value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
|
|
1176
1204
|
],
|
|
@@ -1185,6 +1213,8 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
|
|
|
1185
1213
|
})
|
|
1186
1214
|
: undefined
|
|
1187
1215
|
if (isCancel(webhookUrl)) return back()
|
|
1216
|
+
const namedCreds = tunnelProvider === 'cloudflare-named' ? await promptGithubCloudflareNamedTunnel(cwd) : undefined
|
|
1217
|
+
if (namedCreds === null) return back()
|
|
1188
1218
|
const port = await text({
|
|
1189
1219
|
message: 'Local webhook port inside the agent container',
|
|
1190
1220
|
initialValue: '8975',
|
|
@@ -1214,12 +1244,41 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
|
|
|
1214
1244
|
tunnelProvider,
|
|
1215
1245
|
...(webhookUrl !== undefined ? { webhookUrl } : {}),
|
|
1216
1246
|
webhookPort: Number(port),
|
|
1247
|
+
...(namedCreds !== undefined ? namedCreds : {}),
|
|
1217
1248
|
repos: parseGithubRepos(reposRaw),
|
|
1218
1249
|
auth,
|
|
1219
1250
|
},
|
|
1220
1251
|
})
|
|
1221
1252
|
}
|
|
1222
1253
|
|
|
1254
|
+
async function promptGithubCloudflareNamedTunnel(cwd: string): Promise<{ hostname: string; tokenEnv: string } | null> {
|
|
1255
|
+
const tokenEnv = 'CLOUDFLARE_TUNNEL_TOKEN'
|
|
1256
|
+
note(
|
|
1257
|
+
[
|
|
1258
|
+
'Cloudflare Named Tunnel needs a tunnel you created in the Zero Trust dashboard:',
|
|
1259
|
+
' 1. Networks → Tunnels → Create a tunnel → Cloudflared. Copy the token shown on the install screen.',
|
|
1260
|
+
' 2. Public Hostname tab → Add: subdomain + your-domain, service type HTTP, URL localhost:<webhook port>.',
|
|
1261
|
+
` 3. Paste the token below when prompted — TypeClaw will write it to .env as ${tokenEnv}.`,
|
|
1262
|
+
'A tunnel without a Public Hostname registers but routes nothing.',
|
|
1263
|
+
].join('\n'),
|
|
1264
|
+
'Cloudflare named tunnel',
|
|
1265
|
+
)
|
|
1266
|
+
const hostname = await text({
|
|
1267
|
+
message: 'Public hostname configured in the dashboard (https://...)',
|
|
1268
|
+
validate: (v) => validateGithubUrl(v ?? '', 'Hostname is required'),
|
|
1269
|
+
})
|
|
1270
|
+
if (isCancel(hostname)) return null
|
|
1271
|
+
if (!hasEnvKey(cwd, tokenEnv)) {
|
|
1272
|
+
const token = await password({
|
|
1273
|
+
message: `Cloudflare tunnel token (will be written to .env as ${tokenEnv})`,
|
|
1274
|
+
validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
|
|
1275
|
+
})
|
|
1276
|
+
if (isCancel(token)) return null
|
|
1277
|
+
appendOrReplaceEnvKey(cwd, tokenEnv, token)
|
|
1278
|
+
}
|
|
1279
|
+
return { hostname, tokenEnv }
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1223
1282
|
async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string } | null> {
|
|
1224
1283
|
const pat = await password({
|
|
1225
1284
|
message: 'GitHub fine-grained PAT',
|
|
@@ -1432,18 +1491,6 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
|
|
|
1432
1491
|
}
|
|
1433
1492
|
}
|
|
1434
1493
|
|
|
1435
|
-
export async function decideExistingApiKeyReuse(
|
|
1436
|
-
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
1437
|
-
existingApiKey: string | null,
|
|
1438
|
-
askReuse: (message: string) => Promise<unknown>,
|
|
1439
|
-
): Promise<'reuse' | 'prompt' | 'cancel'> {
|
|
1440
|
-
if (!providerSupportsApiKey(provider) || existingApiKey === null) return 'prompt'
|
|
1441
|
-
|
|
1442
|
-
const reuse = await askReuse(`Reuse existing ${provider.name} API key from secrets.json?`)
|
|
1443
|
-
if (isCancel(reuse)) return 'cancel'
|
|
1444
|
-
return reuse === true ? 'reuse' : 'prompt'
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
1494
|
function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
|
|
1448
1495
|
const seen = new Set<KnownProviderId>()
|
|
1449
1496
|
const out: KnownProviderId[] = []
|