typeclaw 0.10.0 → 0.11.1
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 +5 -1
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/multimodal/look-at.ts +8 -0
- 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 +3 -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/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- 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/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +269 -34
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +138 -52
- package/src/cli/init.ts +139 -100
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -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 +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +14 -2
- package/src/server/command-runner.ts +31 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
- 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 +25 -7
package/src/cli/init.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
2
|
|
|
4
3
|
import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
|
|
5
4
|
import { defineCommand } from 'citty'
|
|
6
5
|
|
|
7
6
|
import {
|
|
8
7
|
KNOWN_PROVIDERS,
|
|
9
|
-
providerForModelRef,
|
|
10
8
|
supportsApiKey as providerSupportsApiKey,
|
|
11
9
|
supportsOAuth as providerSupportsOAuth,
|
|
12
10
|
type KnownModelRef,
|
|
@@ -14,9 +12,12 @@ import {
|
|
|
14
12
|
} from '@/config/providers'
|
|
15
13
|
import { checkDockerAvailable, type DockerAvailability } from '@/container'
|
|
16
14
|
import {
|
|
15
|
+
appendOrReplaceEnvKey,
|
|
17
16
|
findAgentDir,
|
|
18
17
|
formatEagerGithubWebhookInstallResult,
|
|
18
|
+
hasEnvKey,
|
|
19
19
|
hasExistingChannelSecrets,
|
|
20
|
+
hasExistingOAuthCredentials,
|
|
20
21
|
isDirectoryNonEmpty,
|
|
21
22
|
isHatched,
|
|
22
23
|
readExistingProviderApiKey,
|
|
@@ -34,7 +35,8 @@ import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
|
|
|
34
35
|
import { API_KEY_DASHBOARD_URL, validateApiKey, type KeyValidationResult } from '@/init/validate-api-key'
|
|
35
36
|
|
|
36
37
|
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
37
|
-
import {
|
|
38
|
+
import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
|
|
39
|
+
import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
|
|
38
40
|
|
|
39
41
|
// ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
|
|
40
42
|
// aliases both to the same "cancel" action — there's no way to tell them
|
|
@@ -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
|
|
|
@@ -1042,6 +1066,7 @@ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecr
|
|
|
1042
1066
|
validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
|
|
1043
1067
|
})
|
|
1044
1068
|
if (isCancel(token)) return back()
|
|
1069
|
+
printDiscordInviteHint(token)
|
|
1045
1070
|
return value({ discordBotToken: token })
|
|
1046
1071
|
}
|
|
1047
1072
|
|
|
@@ -1144,7 +1169,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
|
|
|
1144
1169
|
}
|
|
1145
1170
|
}
|
|
1146
1171
|
|
|
1147
|
-
async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
|
|
1172
|
+
async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['channelSecrets']>> {
|
|
1148
1173
|
note(
|
|
1149
1174
|
[
|
|
1150
1175
|
'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
|
|
@@ -1171,6 +1196,10 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
|
|
|
1171
1196
|
value: 'cloudflare-quick',
|
|
1172
1197
|
label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
|
|
1173
1198
|
},
|
|
1199
|
+
{
|
|
1200
|
+
value: 'cloudflare-named',
|
|
1201
|
+
label: 'Cloudflare Named Tunnel — stable URL, needs Cloudflare account + domain',
|
|
1202
|
+
},
|
|
1174
1203
|
{ value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
|
|
1175
1204
|
{ value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
|
|
1176
1205
|
],
|
|
@@ -1185,6 +1214,8 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
|
|
|
1185
1214
|
})
|
|
1186
1215
|
: undefined
|
|
1187
1216
|
if (isCancel(webhookUrl)) return back()
|
|
1217
|
+
const namedCreds = tunnelProvider === 'cloudflare-named' ? await promptGithubCloudflareNamedTunnel(cwd) : undefined
|
|
1218
|
+
if (namedCreds === null) return back()
|
|
1188
1219
|
const port = await text({
|
|
1189
1220
|
message: 'Local webhook port inside the agent container',
|
|
1190
1221
|
initialValue: '8975',
|
|
@@ -1214,12 +1245,41 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
|
|
|
1214
1245
|
tunnelProvider,
|
|
1215
1246
|
...(webhookUrl !== undefined ? { webhookUrl } : {}),
|
|
1216
1247
|
webhookPort: Number(port),
|
|
1248
|
+
...(namedCreds !== undefined ? namedCreds : {}),
|
|
1217
1249
|
repos: parseGithubRepos(reposRaw),
|
|
1218
1250
|
auth,
|
|
1219
1251
|
},
|
|
1220
1252
|
})
|
|
1221
1253
|
}
|
|
1222
1254
|
|
|
1255
|
+
async function promptGithubCloudflareNamedTunnel(cwd: string): Promise<{ hostname: string; tokenEnv: string } | null> {
|
|
1256
|
+
const tokenEnv = 'CLOUDFLARE_TUNNEL_TOKEN'
|
|
1257
|
+
note(
|
|
1258
|
+
[
|
|
1259
|
+
'Cloudflare Named Tunnel needs a tunnel you created in the Zero Trust dashboard:',
|
|
1260
|
+
' 1. Networks → Tunnels → Create a tunnel → Cloudflared. Copy the token shown on the install screen.',
|
|
1261
|
+
' 2. Public Hostname tab → Add: subdomain + your-domain, service type HTTP, URL localhost:<webhook port>.',
|
|
1262
|
+
` 3. Paste the token below when prompted — TypeClaw will write it to .env as ${tokenEnv}.`,
|
|
1263
|
+
'A tunnel without a Public Hostname registers but routes nothing.',
|
|
1264
|
+
].join('\n'),
|
|
1265
|
+
'Cloudflare named tunnel',
|
|
1266
|
+
)
|
|
1267
|
+
const hostname = await text({
|
|
1268
|
+
message: 'Public hostname configured in the dashboard (https://...)',
|
|
1269
|
+
validate: (v) => validateGithubUrl(v ?? '', 'Hostname is required'),
|
|
1270
|
+
})
|
|
1271
|
+
if (isCancel(hostname)) return null
|
|
1272
|
+
if (!hasEnvKey(cwd, tokenEnv)) {
|
|
1273
|
+
const token = await password({
|
|
1274
|
+
message: `Cloudflare tunnel token (will be written to .env as ${tokenEnv})`,
|
|
1275
|
+
validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
|
|
1276
|
+
})
|
|
1277
|
+
if (isCancel(token)) return null
|
|
1278
|
+
appendOrReplaceEnvKey(cwd, tokenEnv, token)
|
|
1279
|
+
}
|
|
1280
|
+
return { hostname, tokenEnv }
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1223
1283
|
async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string } | null> {
|
|
1224
1284
|
const pat = await password({
|
|
1225
1285
|
message: 'GitHub fine-grained PAT',
|
|
@@ -1240,11 +1300,8 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
1240
1300
|
validate: (v) => validatePositiveInteger(v ?? '', 'App ID is required'),
|
|
1241
1301
|
})
|
|
1242
1302
|
if (isCancel(appId)) return null
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
validate: (v) => (v && v.length > 0 ? undefined : 'Private key is required'),
|
|
1246
|
-
})
|
|
1247
|
-
if (isCancel(privateKeyInput)) return null
|
|
1303
|
+
const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
|
|
1304
|
+
if (privateKey === CANCEL_SYMBOL) return null
|
|
1248
1305
|
const installationId = await text({
|
|
1249
1306
|
message: 'Installation ID (optional; leave blank to auto-discover)',
|
|
1250
1307
|
validate: (v) =>
|
|
@@ -1255,17 +1312,11 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
1255
1312
|
return {
|
|
1256
1313
|
type: 'app',
|
|
1257
1314
|
appId: Number(appId),
|
|
1258
|
-
privateKey
|
|
1315
|
+
privateKey,
|
|
1259
1316
|
...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
|
|
1260
1317
|
}
|
|
1261
1318
|
}
|
|
1262
1319
|
|
|
1263
|
-
async function resolveGithubPrivateKey(input: string): Promise<string> {
|
|
1264
|
-
const normalized = input.replace(/\\n/g, '\n')
|
|
1265
|
-
if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
|
|
1266
|
-
return await readFile(input, 'utf8')
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
1320
|
function parseGithubRepos(input: string): string[] {
|
|
1270
1321
|
return input
|
|
1271
1322
|
.split(',')
|
|
@@ -1432,18 +1483,6 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
|
|
|
1432
1483
|
}
|
|
1433
1484
|
}
|
|
1434
1485
|
|
|
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
1486
|
function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
|
|
1448
1487
|
const seen = new Set<KnownProviderId>()
|
|
1449
1488
|
const out: KnownProviderId[] = []
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
|
|
2
|
+
// Owns the AbortController lifecycle and the bare-ESC debounce timer,
|
|
3
|
+
// independent of process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
|
|
4
|
+
// Extracted for testability: the lifecycle bug we want to pin is "armForStream's
|
|
5
|
+
// signal must remain valid across pause()/resume() cycles" — verifying that without
|
|
6
|
+
// a real TTY requires this seam.
|
|
7
|
+
|
|
8
|
+
export type EscChunkResult = { sigint: boolean }
|
|
9
|
+
|
|
10
|
+
export type EscController = {
|
|
11
|
+
armForStream: () => AbortSignal
|
|
12
|
+
onChunk: (chunk: Buffer) => EscChunkResult
|
|
13
|
+
clearPending: () => void
|
|
14
|
+
dispose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
|
|
18
|
+
let currentCtrl: AbortController | null = null
|
|
19
|
+
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
20
|
+
|
|
21
|
+
const clearPending = (): void => {
|
|
22
|
+
if (pendingEsc !== null) {
|
|
23
|
+
clearTimeout(pendingEsc)
|
|
24
|
+
pendingEsc = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
armForStream: () => {
|
|
30
|
+
clearPending()
|
|
31
|
+
currentCtrl = new AbortController()
|
|
32
|
+
return currentCtrl.signal
|
|
33
|
+
},
|
|
34
|
+
onChunk: (chunk) => {
|
|
35
|
+
if (chunk.length === 0) return { sigint: false }
|
|
36
|
+
if (chunk[0] === 0x03) {
|
|
37
|
+
// Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
|
|
38
|
+
// SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
|
|
39
|
+
// we deliberately keep the AbortController lifecycle separate from SIGINT.
|
|
40
|
+
return { sigint: true }
|
|
41
|
+
}
|
|
42
|
+
if (chunk.length === 1 && chunk[0] === 0x1b) {
|
|
43
|
+
// Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
|
|
44
|
+
// sequences from arrow keys, mouse, paste) cancels the pending fire.
|
|
45
|
+
// Snapshot currentCtrl so a late-firing timer can't abort a controller
|
|
46
|
+
// created by a subsequent armForStream() call.
|
|
47
|
+
clearPending()
|
|
48
|
+
const ctrl = currentCtrl
|
|
49
|
+
pendingEsc = setTimeout(() => {
|
|
50
|
+
pendingEsc = null
|
|
51
|
+
ctrl?.abort()
|
|
52
|
+
}, debounceMs)
|
|
53
|
+
return { sigint: false }
|
|
54
|
+
}
|
|
55
|
+
// Any other byte arriving within the ESC window is the second byte of a CSI
|
|
56
|
+
// sequence; cancel the pending abort.
|
|
57
|
+
clearPending()
|
|
58
|
+
return { sigint: false }
|
|
59
|
+
},
|
|
60
|
+
clearPending,
|
|
61
|
+
dispose: () => {
|
|
62
|
+
clearPending()
|
|
63
|
+
currentCtrl = null
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/cli/inspect.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { findAgentDir } from '@/init'
|
|
|
5
5
|
import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
|
|
6
6
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
7
|
|
|
8
|
+
import { createEscController } from './inspect-controller'
|
|
8
9
|
import { cancel, c, errorLine, isCancel } from './ui'
|
|
9
10
|
|
|
10
11
|
const ESC_LISTEN_DELAY_MS = 50
|
|
@@ -55,9 +56,9 @@ export const inspectCommand = defineCommand({
|
|
|
55
56
|
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
56
57
|
json: isJson,
|
|
57
58
|
color,
|
|
58
|
-
selectSession: (sessions) => {
|
|
59
|
+
selectSession: (sessions, selectOpts) => {
|
|
59
60
|
escListener?.pause()
|
|
60
|
-
return clackSelect(sessions).finally(() => {
|
|
61
|
+
return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
|
|
61
62
|
escListener?.resume()
|
|
62
63
|
})
|
|
63
64
|
},
|
|
@@ -124,29 +125,12 @@ function createEscListener(): EscListener | null {
|
|
|
124
125
|
const stdin = process.stdin
|
|
125
126
|
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
128
|
+
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
129
129
|
let active = false
|
|
130
130
|
|
|
131
131
|
const onData = (chunk: Buffer): void => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (first === 0x03) {
|
|
135
|
-
process.kill(process.pid, 'SIGINT')
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
if (chunk.length === 1 && first === 0x1b) {
|
|
139
|
-
if (pendingEsc !== null) clearTimeout(pendingEsc)
|
|
140
|
-
pendingEsc = setTimeout(() => {
|
|
141
|
-
pendingEsc = null
|
|
142
|
-
currentCtrl?.abort()
|
|
143
|
-
}, ESC_LISTEN_DELAY_MS)
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
if (pendingEsc !== null) {
|
|
147
|
-
clearTimeout(pendingEsc)
|
|
148
|
-
pendingEsc = null
|
|
149
|
-
}
|
|
132
|
+
const { sigint } = ctrl.onChunk(chunk)
|
|
133
|
+
if (sigint) process.kill(process.pid, 'SIGINT')
|
|
150
134
|
}
|
|
151
135
|
|
|
152
136
|
const start = (): void => {
|
|
@@ -166,27 +150,28 @@ function createEscListener(): EscListener | null {
|
|
|
166
150
|
/* terminal already torn down */
|
|
167
151
|
}
|
|
168
152
|
stdin.pause()
|
|
169
|
-
|
|
170
|
-
clearTimeout(pendingEsc)
|
|
171
|
-
pendingEsc = null
|
|
172
|
-
}
|
|
153
|
+
ctrl.clearPending()
|
|
173
154
|
}
|
|
174
155
|
|
|
175
156
|
return {
|
|
176
157
|
armForStream: () => {
|
|
177
|
-
|
|
158
|
+
const signal = ctrl.armForStream()
|
|
178
159
|
start()
|
|
179
|
-
return
|
|
160
|
+
return signal
|
|
180
161
|
},
|
|
181
162
|
pause: () => {
|
|
182
163
|
stop()
|
|
183
164
|
},
|
|
184
165
|
resume: () => {
|
|
185
|
-
|
|
166
|
+
// Resume the listener WITHOUT replacing the AbortController.
|
|
167
|
+
// The signal returned by armForStream() is held by the live source
|
|
168
|
+
// through streamSession's combinedSignal; replacing the controller
|
|
169
|
+
// here would orphan that signal so a subsequent ESC press could
|
|
170
|
+
// not abort the live tail.
|
|
186
171
|
start()
|
|
187
172
|
},
|
|
188
173
|
stop: () => {
|
|
189
|
-
|
|
174
|
+
ctrl.dispose()
|
|
190
175
|
stop()
|
|
191
176
|
},
|
|
192
177
|
}
|
|
@@ -204,8 +189,15 @@ function useColor(): boolean {
|
|
|
204
189
|
return Boolean(process.stdout.isTTY)
|
|
205
190
|
}
|
|
206
191
|
|
|
207
|
-
async function clackSelect(
|
|
192
|
+
async function clackSelect(
|
|
193
|
+
sessions: SessionSummary[],
|
|
194
|
+
initialSessionId: string | undefined,
|
|
195
|
+
): Promise<SessionSummary | null> {
|
|
208
196
|
const { select } = await import('@clack/prompts')
|
|
197
|
+
const preferred =
|
|
198
|
+
initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
|
|
199
|
+
? initialSessionId
|
|
200
|
+
: sessions[0]?.sessionId
|
|
209
201
|
const picked = await select<string>({
|
|
210
202
|
message: `Pick a session to inspect (showing ${sessions.length})`,
|
|
211
203
|
options: sessions.map((s) => ({
|
|
@@ -213,7 +205,7 @@ async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary |
|
|
|
213
205
|
label: formatRowLabel(s),
|
|
214
206
|
...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
|
|
215
207
|
})),
|
|
216
|
-
initialValue:
|
|
208
|
+
initialValue: preferred,
|
|
217
209
|
})
|
|
218
210
|
if (isCancel(picked)) {
|
|
219
211
|
cancel('Cancelled.')
|