typeclaw 0.4.0 → 0.5.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/package.json +1 -1
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +87 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/index.ts +87 -3
- package/src/channels/router.ts +194 -28
- package/src/channels/types.ts +3 -1
- package/src/cli/channel.ts +2 -45
- package/src/cli/init.ts +148 -87
- package/src/cli/model.ts +12 -3
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/ui.ts +95 -0
- package/src/config/config.ts +59 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +18 -1
- package/src/cron/consumer.ts +129 -43
- package/src/init/dockerfile.ts +221 -3
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +47 -3
- package/src/init/oauth-login.ts +17 -3
- package/src/permissions/builtins.ts +29 -7
- package/src/permissions/permissions.ts +24 -7
- package/src/plugin/define.ts +2 -0
- package/src/plugin/manager.ts +14 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/index.ts +2 -1
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-permissions/SKILL.md +35 -17
- package/src/tui/index.ts +35 -3
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +57 -25
package/src/cli/channel.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
26
26
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
27
27
|
|
|
28
|
-
import { c, done, errorLine } from './ui'
|
|
28
|
+
import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
|
|
29
29
|
|
|
30
30
|
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
31
31
|
'slack-bot': 'Slack',
|
|
@@ -834,50 +834,7 @@ async function promptDiscordToken(): Promise<string> {
|
|
|
834
834
|
}
|
|
835
835
|
|
|
836
836
|
async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
|
|
837
|
-
|
|
838
|
-
[
|
|
839
|
-
'1. https://api.slack.com/apps → Create New App → From a manifest.',
|
|
840
|
-
' Pick your workspace, then paste this JSON manifest:',
|
|
841
|
-
'',
|
|
842
|
-
' {',
|
|
843
|
-
' "display_information": { "name": "TypeClaw" },',
|
|
844
|
-
' "features": {',
|
|
845
|
-
' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
|
|
846
|
-
' },',
|
|
847
|
-
' "oauth_config": {',
|
|
848
|
-
' "scopes": {',
|
|
849
|
-
' "bot": [',
|
|
850
|
-
' "app_mentions:read", "chat:write", "users:read", "files:read",',
|
|
851
|
-
' "channels:history", "channels:read",',
|
|
852
|
-
' "groups:history", "groups:read",',
|
|
853
|
-
' "im:history", "im:read",',
|
|
854
|
-
' "mpim:history", "mpim:read"',
|
|
855
|
-
' ]',
|
|
856
|
-
' }',
|
|
857
|
-
' },',
|
|
858
|
-
' "settings": {',
|
|
859
|
-
' "event_subscriptions": {',
|
|
860
|
-
' "bot_events": [',
|
|
861
|
-
' "app_mention",',
|
|
862
|
-
' "message.channels", "message.groups",',
|
|
863
|
-
' "message.im", "message.mpim"',
|
|
864
|
-
' ]',
|
|
865
|
-
' },',
|
|
866
|
-
' "socket_mode_enabled": true',
|
|
867
|
-
' }',
|
|
868
|
-
' }',
|
|
869
|
-
'',
|
|
870
|
-
'2. Install to Workspace, then OAuth & Permissions →',
|
|
871
|
-
' copy the Bot User OAuth Token (xoxb-...).',
|
|
872
|
-
'3. Basic Information → App-Level Tokens → Generate Token and',
|
|
873
|
-
' Scopes, add the connections:write scope, and copy the',
|
|
874
|
-
' token (xapp-...). Socket Mode needs this; the manifest',
|
|
875
|
-
' cannot grant it.',
|
|
876
|
-
'4. Invite the bot to any private channel or DM you want it in:',
|
|
877
|
-
' /invite @TypeClaw',
|
|
878
|
-
].join('\n'),
|
|
879
|
-
'Get a Slack bot',
|
|
880
|
-
)
|
|
837
|
+
printSlackAppManifestSetup()
|
|
881
838
|
const bot = await promptSlackBotToken()
|
|
882
839
|
note(
|
|
883
840
|
[
|
package/src/cli/init.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type KnownModelRef,
|
|
12
12
|
type KnownProviderId,
|
|
13
13
|
} from '@/config/providers'
|
|
14
|
-
import type
|
|
14
|
+
import { checkDockerAvailable, type DockerAvailability } from '@/container'
|
|
15
15
|
import {
|
|
16
16
|
findAgentDir,
|
|
17
17
|
formatEagerGithubWebhookInstallResult,
|
|
@@ -29,9 +29,10 @@ import {
|
|
|
29
29
|
} from '@/init'
|
|
30
30
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
31
31
|
import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
|
|
32
|
-
import { makeOAuthLoginRunner } from '@/init/oauth-login'
|
|
32
|
+
import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
|
|
33
33
|
|
|
34
|
-
import {
|
|
34
|
+
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
35
|
+
import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
|
|
35
36
|
|
|
36
37
|
// ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
|
|
37
38
|
// aliases both to the same "cancel" action — there's no way to tell them
|
|
@@ -54,9 +55,15 @@ import { c, done, errorLine } from './ui'
|
|
|
54
55
|
// a clean exit. Inside an active clack prompt Ctrl+C is still aliased to
|
|
55
56
|
// cancel, so the abort hotkey is "cancel twice in a row".
|
|
56
57
|
export class WizardAbortedError extends Error {
|
|
57
|
-
|
|
58
|
+
// When the wizard ran a successful eager OAuth login before aborting, the
|
|
59
|
+
// resulting credentials are already on disk at `<cwd>/secrets.json`. The
|
|
60
|
+
// CLI surfaces this on abort so the user knows to either re-run init in
|
|
61
|
+
// the same directory (the credentials will be reused) or delete the file.
|
|
62
|
+
readonly oauthCredentialsSaved: boolean
|
|
63
|
+
constructor(options: { oauthCredentialsSaved?: boolean } = {}) {
|
|
58
64
|
super('Wizard aborted by user')
|
|
59
65
|
this.name = 'WizardAbortedError'
|
|
66
|
+
this.oauthCredentialsSaved = options.oauthCredentialsSaved === true
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
|
|
@@ -102,11 +109,37 @@ export const init = defineCommand({
|
|
|
102
109
|
intro('Initializing TypeClaw...')
|
|
103
110
|
log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
|
|
104
111
|
|
|
112
|
+
// Docker preflight runs BEFORE the wizard so an OAuth login (which the
|
|
113
|
+
// wizard fires the moment the user picks "OAuth (browser login)") doesn't
|
|
114
|
+
// burn a real browser flow on an agent folder we can't actually start.
|
|
115
|
+
// `runInit` re-runs the preflight as a defense-in-depth gate, but
|
|
116
|
+
// surfacing the failure here lets the user fix Docker without re-doing
|
|
117
|
+
// every wizard step.
|
|
118
|
+
const preflightSpinner = spinner()
|
|
119
|
+
preflightSpinner.start('Checking Docker...')
|
|
120
|
+
const preflight = await checkDockerAvailable()
|
|
121
|
+
if (!preflight.ok) {
|
|
122
|
+
preflightSpinner.error(preflightFailureSummary(preflight))
|
|
123
|
+
note(preflightFailureGuidance(preflight).join('\n'), 'Docker check failed')
|
|
124
|
+
process.exit(1)
|
|
125
|
+
}
|
|
126
|
+
preflightSpinner.stop('Docker is reachable.')
|
|
127
|
+
|
|
105
128
|
let collected: CollectedInputs
|
|
106
129
|
try {
|
|
107
130
|
collected = await collectWizardInputs(cwd, defaultWizardPrompts)
|
|
108
131
|
} catch (error) {
|
|
109
132
|
if (error instanceof WizardAbortedError) {
|
|
133
|
+
if (error.oauthCredentialsSaved) {
|
|
134
|
+
note(
|
|
135
|
+
[
|
|
136
|
+
'OAuth credentials were saved to `secrets.json` before you aborted.',
|
|
137
|
+
'Re-run `typeclaw init` here to pick up where you left off (the credentials',
|
|
138
|
+
'will be reused), or delete `secrets.json` if you want a clean restart.',
|
|
139
|
+
].join('\n'),
|
|
140
|
+
'Saved OAuth credentials',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
110
143
|
cancel('Aborted.')
|
|
111
144
|
process.exit(0)
|
|
112
145
|
}
|
|
@@ -309,9 +342,24 @@ export interface WizardPrompts {
|
|
|
309
342
|
hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
|
|
310
343
|
askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
|
|
311
344
|
runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
|
|
312
|
-
|
|
345
|
+
runOAuthLogin: (
|
|
346
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
347
|
+
cwd: string,
|
|
348
|
+
model: KnownModelRef,
|
|
349
|
+
) => Promise<OAuthLoginResult>
|
|
350
|
+
// Asked after a failed OAuth login. `apiKeyAvailable` is true when the
|
|
351
|
+
// provider also supports api-key auth (so the wizard can offer a fallback
|
|
352
|
+
// path); false for OAuth-only providers like openai-codex, where the only
|
|
353
|
+
// options are retry or abort.
|
|
354
|
+
askOAuthFailureRecovery: (
|
|
355
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
356
|
+
reason: string,
|
|
357
|
+
apiKeyAvailable: boolean,
|
|
358
|
+
) => Promise<OAuthFailureRecovery>
|
|
313
359
|
}
|
|
314
360
|
|
|
361
|
+
export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
|
|
362
|
+
|
|
315
363
|
export const defaultWizardPrompts: WizardPrompts = {
|
|
316
364
|
loadCatalog,
|
|
317
365
|
readExistingApiKey: readExistingProviderApiKey,
|
|
@@ -326,10 +374,8 @@ export const defaultWizardPrompts: WizardPrompts = {
|
|
|
326
374
|
hasExistingChannelSecrets,
|
|
327
375
|
askReuseExistingChannel,
|
|
328
376
|
runChannelFlow,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)),
|
|
332
|
-
}),
|
|
377
|
+
runOAuthLogin: (provider, cwd, model) => makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))({ cwd, model }),
|
|
378
|
+
askOAuthFailureRecovery,
|
|
333
379
|
}
|
|
334
380
|
|
|
335
381
|
export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
|
|
@@ -337,10 +383,15 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
337
383
|
const state: WizardState = { catalog }
|
|
338
384
|
let step: StepId = 'pick-provider'
|
|
339
385
|
let pendingBackOrigin: StepId | null = null
|
|
386
|
+
let oauthCredentialsSaved = false
|
|
387
|
+
|
|
388
|
+
const abort = (): never => {
|
|
389
|
+
throw new WizardAbortedError({ oauthCredentialsSaved })
|
|
390
|
+
}
|
|
340
391
|
|
|
341
392
|
const onResult = <T>(currentStep: StepId, result: StepResult<T>): StepResult<T> => {
|
|
342
393
|
if (result.kind === 'back') {
|
|
343
|
-
if (pendingBackOrigin === currentStep)
|
|
394
|
+
if (pendingBackOrigin === currentStep) abort()
|
|
344
395
|
pendingBackOrigin = currentStep
|
|
345
396
|
} else if (!result.auto) {
|
|
346
397
|
pendingBackOrigin = null
|
|
@@ -410,7 +461,32 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
410
461
|
}
|
|
411
462
|
state.authMethod = result.value
|
|
412
463
|
if (result.value === 'oauth') {
|
|
413
|
-
|
|
464
|
+
// Run the browser login eagerly so the user sees the OAuth URL the
|
|
465
|
+
// moment they pick "OAuth (browser login)" — not at the end of the
|
|
466
|
+
// wizard. On failure we ask the user how to recover (retry / fall
|
|
467
|
+
// back to API key / abort) instead of dumping them back into the
|
|
468
|
+
// auth method picker with no guidance.
|
|
469
|
+
const login = await runOAuthLoginSafely(prompts, provider, cwd, state.model!.ref)
|
|
470
|
+
if (!login.ok) {
|
|
471
|
+
const recovery = await prompts.askOAuthFailureRecovery(
|
|
472
|
+
provider,
|
|
473
|
+
login.reason,
|
|
474
|
+
providerSupportsApiKey(provider),
|
|
475
|
+
)
|
|
476
|
+
// The recovery prompt is a fresh user decision, so it must clear
|
|
477
|
+
// any back-token left over from an earlier step. Without this, a
|
|
478
|
+
// sequence like `enter-api-key → back → autoValue('oauth') →
|
|
479
|
+
// OAuth fails → recovery=api-key → enter-api-key` would treat the
|
|
480
|
+
// user's NEXT back press as a double-back and abort the wizard.
|
|
481
|
+
pendingBackOrigin = null
|
|
482
|
+
if (recovery === 'abort') abort()
|
|
483
|
+
state.authMethod = recovery === 'api-key' ? 'api-key' : undefined
|
|
484
|
+
state.llmAuth = undefined
|
|
485
|
+
step = recovery === 'api-key' ? 'enter-api-key' : 'pick-auth-method'
|
|
486
|
+
break
|
|
487
|
+
}
|
|
488
|
+
oauthCredentialsSaved = true
|
|
489
|
+
state.llmAuth = { kind: 'oauth-completed' }
|
|
414
490
|
step = stepAfterDefaultAuth(state)
|
|
415
491
|
} else {
|
|
416
492
|
step = 'enter-api-key'
|
|
@@ -498,7 +574,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
498
574
|
}
|
|
499
575
|
state.visionAuthMethod = result.value
|
|
500
576
|
if (result.value === 'oauth') {
|
|
501
|
-
|
|
577
|
+
// Same eager-login + recovery-prompt rationale as the default-provider branch above.
|
|
578
|
+
const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
|
|
579
|
+
if (!login.ok) {
|
|
580
|
+
const recovery = await prompts.askOAuthFailureRecovery(
|
|
581
|
+
provider,
|
|
582
|
+
login.reason,
|
|
583
|
+
providerSupportsApiKey(provider),
|
|
584
|
+
)
|
|
585
|
+
// See the matching pendingBackOrigin reset in the default-provider
|
|
586
|
+
// branch above — same reasoning applies to vision auth recovery.
|
|
587
|
+
pendingBackOrigin = null
|
|
588
|
+
if (recovery === 'abort') abort()
|
|
589
|
+
state.visionAuthMethod = recovery === 'api-key' ? 'api-key' : undefined
|
|
590
|
+
state.visionLlmAuth = undefined
|
|
591
|
+
step = recovery === 'api-key' ? 'enter-vision-api-key' : 'pick-vision-auth-method'
|
|
592
|
+
break
|
|
593
|
+
}
|
|
594
|
+
oauthCredentialsSaved = true
|
|
595
|
+
state.visionLlmAuth = { kind: 'oauth-completed' }
|
|
502
596
|
step = 'pick-channel'
|
|
503
597
|
} else {
|
|
504
598
|
step = 'enter-vision-api-key'
|
|
@@ -570,6 +664,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
570
664
|
}
|
|
571
665
|
}
|
|
572
666
|
|
|
667
|
+
// Belt-and-suspenders wrapper: `makeOAuthLoginRunner` already catches the
|
|
668
|
+
// upstream pi-ai login flow and returns `{ ok: false, reason }`, but the
|
|
669
|
+
// wizard cannot afford ANY uncaught throw from a custom runner (test seam,
|
|
670
|
+
// future plugin-contributed runner) — it would bubble out of
|
|
671
|
+
// `collectWizardInputs` and exit the whole init. Coerce unexpected throws to
|
|
672
|
+
// the normal failure path so the recovery prompt always fires.
|
|
673
|
+
async function runOAuthLoginSafely(
|
|
674
|
+
prompts: WizardPrompts,
|
|
675
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
676
|
+
cwd: string,
|
|
677
|
+
model: KnownModelRef,
|
|
678
|
+
): Promise<OAuthLoginResult> {
|
|
679
|
+
try {
|
|
680
|
+
return await prompts.runOAuthLogin(provider, cwd, model)
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
573
686
|
function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSecrets']): CollectedInputs {
|
|
574
687
|
return {
|
|
575
688
|
model: state.model!,
|
|
@@ -768,6 +881,28 @@ async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): P
|
|
|
768
881
|
return value(apiKey)
|
|
769
882
|
}
|
|
770
883
|
|
|
884
|
+
async function askOAuthFailureRecovery(
|
|
885
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
886
|
+
reason: string,
|
|
887
|
+
apiKeyAvailable: boolean,
|
|
888
|
+
): Promise<OAuthFailureRecovery> {
|
|
889
|
+
note(reason, `${provider.name} OAuth login failed`)
|
|
890
|
+
const options: Array<{ value: OAuthFailureRecovery; label: string; hint?: string }> = [
|
|
891
|
+
{ value: 'retry', label: 'Retry OAuth login' },
|
|
892
|
+
]
|
|
893
|
+
if (apiKeyAvailable) {
|
|
894
|
+
options.push({ value: 'api-key', label: `Use a ${provider.name} API key instead` })
|
|
895
|
+
}
|
|
896
|
+
options.push({ value: 'abort', label: 'Abort init', hint: 'you can re-run `typeclaw init` later' })
|
|
897
|
+
const choice = await select<OAuthFailureRecovery>({
|
|
898
|
+
message: 'What next?',
|
|
899
|
+
options,
|
|
900
|
+
initialValue: 'retry',
|
|
901
|
+
})
|
|
902
|
+
if (isCancel(choice)) return 'abort'
|
|
903
|
+
return choice
|
|
904
|
+
}
|
|
905
|
+
|
|
771
906
|
async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResult<ChannelChoice>> {
|
|
772
907
|
const choice = await select<ChannelChoice>({
|
|
773
908
|
message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
|
|
@@ -870,50 +1005,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
|
|
|
870
1005
|
let sub: SubStep = 'bot'
|
|
871
1006
|
let botToken: string | undefined
|
|
872
1007
|
|
|
873
|
-
|
|
874
|
-
[
|
|
875
|
-
'1. https://api.slack.com/apps → Create New App → From a manifest.',
|
|
876
|
-
' Pick your workspace, then paste this JSON manifest:',
|
|
877
|
-
'',
|
|
878
|
-
' {',
|
|
879
|
-
' "display_information": { "name": "TypeClaw" },',
|
|
880
|
-
' "features": {',
|
|
881
|
-
' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
|
|
882
|
-
' },',
|
|
883
|
-
' "oauth_config": {',
|
|
884
|
-
' "scopes": {',
|
|
885
|
-
' "bot": [',
|
|
886
|
-
' "app_mentions:read", "chat:write", "users:read", "files:read",',
|
|
887
|
-
' "channels:history", "channels:read",',
|
|
888
|
-
' "groups:history", "groups:read",',
|
|
889
|
-
' "im:history", "im:read",',
|
|
890
|
-
' "mpim:history", "mpim:read"',
|
|
891
|
-
' ]',
|
|
892
|
-
' }',
|
|
893
|
-
' },',
|
|
894
|
-
' "settings": {',
|
|
895
|
-
' "event_subscriptions": {',
|
|
896
|
-
' "bot_events": [',
|
|
897
|
-
' "app_mention",',
|
|
898
|
-
' "message.channels", "message.groups",',
|
|
899
|
-
' "message.im", "message.mpim"',
|
|
900
|
-
' ]',
|
|
901
|
-
' },',
|
|
902
|
-
' "socket_mode_enabled": true',
|
|
903
|
-
' }',
|
|
904
|
-
' }',
|
|
905
|
-
'',
|
|
906
|
-
'2. Install to Workspace, then OAuth & Permissions →',
|
|
907
|
-
' copy the Bot User OAuth Token (xoxb-...).',
|
|
908
|
-
'3. Basic Information → App-Level Tokens → Generate Token and',
|
|
909
|
-
' Scopes, add the connections:write scope, and copy the',
|
|
910
|
-
' token (xapp-...). Socket Mode needs this; the manifest',
|
|
911
|
-
' cannot grant it.',
|
|
912
|
-
'4. Invite the bot to any private channel or DM you want it in:',
|
|
913
|
-
' /invite @TypeClaw',
|
|
914
|
-
].join('\n'),
|
|
915
|
-
'Get a Slack bot',
|
|
916
|
-
)
|
|
1008
|
+
printSlackAppManifestSetup()
|
|
917
1009
|
|
|
918
1010
|
while (true) {
|
|
919
1011
|
if (sub === 'bot') {
|
|
@@ -1261,37 +1353,6 @@ export async function decideExistingApiKeyReuse(
|
|
|
1261
1353
|
return reuse === true ? 'reuse' : 'prompt'
|
|
1262
1354
|
}
|
|
1263
1355
|
|
|
1264
|
-
// Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
|
|
1265
|
-
// uses: a spinner over the "waiting for login" period, with onAuth printing
|
|
1266
|
-
// the URL the user needs to open and onPrompt falling back to a `text`
|
|
1267
|
-
// prompt for the manual code path. The spinner is started by onAuth and
|
|
1268
|
-
// stopped by the caller (runInit) — we don't try to manage it here because
|
|
1269
|
-
// the spinner lifecycle has to span emit('start') -> emit('done').
|
|
1270
|
-
function buildOAuthCallbacks(providerName: string) {
|
|
1271
|
-
return {
|
|
1272
|
-
onAuth: (url: string, instructions?: string) => {
|
|
1273
|
-
// Don't put the URL inside note(): clack wraps long lines with the box
|
|
1274
|
-
// border `│` on each wrapped segment, which corrupts the URL when the
|
|
1275
|
-
// user copy-pastes it. Keep instructional text in the box, but print
|
|
1276
|
-
// the URL itself as a bare console.log line that any terminal will
|
|
1277
|
-
// hyperlink intact.
|
|
1278
|
-
const preamble = [`Open this URL in your browser to authorize ${providerName}.`]
|
|
1279
|
-
if (instructions) preamble.push('', instructions)
|
|
1280
|
-
note(preamble.join('\n'), 'Browser login')
|
|
1281
|
-
console.log(url)
|
|
1282
|
-
console.log('')
|
|
1283
|
-
},
|
|
1284
|
-
onProgress: (message: string) => {
|
|
1285
|
-
log.info(message)
|
|
1286
|
-
},
|
|
1287
|
-
onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
|
|
1288
|
-
const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
|
|
1289
|
-
if (isCancel(value)) return null
|
|
1290
|
-
return value
|
|
1291
|
-
},
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
1356
|
function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
|
|
1296
1357
|
const seen = new Set<KnownProviderId>()
|
|
1297
1358
|
const out: KnownProviderId[] = []
|
package/src/cli/model.ts
CHANGED
|
@@ -25,7 +25,7 @@ const ADD_PROVIDER_SENTINEL = '__add-provider__'
|
|
|
25
25
|
const setSub = defineCommand({
|
|
26
26
|
meta: {
|
|
27
27
|
name: 'set',
|
|
28
|
-
description: 'set or update a model profile (default | fast | vision | <custom>)',
|
|
28
|
+
description: 'set or update a model profile (default | fast | deep | vision | <custom>)',
|
|
29
29
|
},
|
|
30
30
|
args: {
|
|
31
31
|
profile: {
|
|
@@ -157,15 +157,23 @@ const listSub = defineCommand({
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
const profileWidth = Math.max(7, ...entries.map((e) => e.profile.length))
|
|
160
|
-
const
|
|
160
|
+
const refDisplay = (e: (typeof entries)[number]): string =>
|
|
161
|
+
e.refs.length > 1 ? `${e.ref} ${c.dim(`(+${e.refs.length - 1} fallback)`)}` : e.ref
|
|
162
|
+
const refWidth = Math.max(3, ...entries.map((e) => e.ref.length + (e.refs.length > 1 ? 14 : 0)))
|
|
161
163
|
|
|
162
164
|
const header = `${'PROFILE'.padEnd(profileWidth)} ${'REF'.padEnd(refWidth)} PROVIDER STATUS`
|
|
163
165
|
console.log(c.dim(header))
|
|
164
166
|
for (const e of entries) {
|
|
165
167
|
const star = e.isDefault ? c.cyan('*') : ' '
|
|
166
168
|
const status = e.credentialStatus === 'available' ? c.green('ok') : c.yellow('missing-credentials')
|
|
167
|
-
const line = `${star}${e.profile.padEnd(profileWidth - 1)} ${e.
|
|
169
|
+
const line = `${star}${e.profile.padEnd(profileWidth - 1)} ${refDisplay(e).padEnd(refWidth)} ${e.providerId.padEnd(12)} ${status}`
|
|
168
170
|
console.log(line)
|
|
171
|
+
if (e.refs.length > 1) {
|
|
172
|
+
for (let i = 1; i < e.refs.length; i++) {
|
|
173
|
+
const fb = e.refs[i]!
|
|
174
|
+
console.log(`${' '.padEnd(profileWidth + 2)}↳ ${c.dim(fb)}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
169
177
|
}
|
|
170
178
|
},
|
|
171
179
|
})
|
|
@@ -198,6 +206,7 @@ async function pickProfileName(): Promise<string> {
|
|
|
198
206
|
options: [
|
|
199
207
|
{ value: 'default', label: 'default', hint: 'active model for new sessions' },
|
|
200
208
|
{ value: 'fast', label: 'fast', hint: 'optional alias used by some subagents' },
|
|
209
|
+
{ value: 'deep', label: 'deep', hint: 'optional alias used by some subagents' },
|
|
201
210
|
{ value: 'vision', label: 'vision', hint: 'optional alias used by some subagents' },
|
|
202
211
|
],
|
|
203
212
|
initialValue: 'default',
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isCancel, log, note, text } from '@clack/prompts'
|
|
2
|
+
|
|
3
|
+
import type { OAuthCallbacks } from '@/init/oauth-login'
|
|
4
|
+
|
|
5
|
+
// Shared between `typeclaw init` (src/cli/init.ts) and `typeclaw provider
|
|
6
|
+
// add/set` (src/cli/provider.ts). Both call into the same OAuth runner, so
|
|
7
|
+
// they need to render the same UX: a note() box with the URL + cross-device
|
|
8
|
+
// guidance, a `text()` prompt for the post-callback manual fallback, and a
|
|
9
|
+
// concurrent `onManualCodeInput` prompt for users whose browser is on a
|
|
10
|
+
// different host than the CLI. See src/init/oauth-login.ts for the contract
|
|
11
|
+
// on each callback and why onManualCodeInput is required for cross-device.
|
|
12
|
+
export function buildOAuthCallbacks(providerName: string): OAuthCallbacks {
|
|
13
|
+
return {
|
|
14
|
+
onAuth: (url, instructions) => {
|
|
15
|
+
// Don't put the URL inside note(): clack wraps long lines with the box
|
|
16
|
+
// border `│` on each wrapped segment, which corrupts the URL when the
|
|
17
|
+
// user copy-pastes it. Keep instructional text in the box, but print
|
|
18
|
+
// the URL itself as a bare console.log line that any terminal will
|
|
19
|
+
// hyperlink intact.
|
|
20
|
+
const preamble = [
|
|
21
|
+
`Open this URL in your browser to sign in to ${providerName}.`,
|
|
22
|
+
'',
|
|
23
|
+
'If your browser shows "this site can\'t be reached" after you sign in,',
|
|
24
|
+
'copy the full address from the top of the browser and paste it below.',
|
|
25
|
+
]
|
|
26
|
+
if (instructions) preamble.push('', instructions)
|
|
27
|
+
note(preamble.join('\n'), 'Browser login')
|
|
28
|
+
console.log(url)
|
|
29
|
+
console.log('')
|
|
30
|
+
},
|
|
31
|
+
onProgress: (message) => {
|
|
32
|
+
log.info(message)
|
|
33
|
+
},
|
|
34
|
+
onPrompt: async (message, placeholder) => {
|
|
35
|
+
const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
|
|
36
|
+
if (isCancel(value)) return null
|
|
37
|
+
return value
|
|
38
|
+
},
|
|
39
|
+
onManualCodeInput: async () => {
|
|
40
|
+
const value = await text({
|
|
41
|
+
message:
|
|
42
|
+
'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
|
|
43
|
+
placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
|
|
44
|
+
})
|
|
45
|
+
if (isCancel(value)) throw new Error('Login cancelled by user')
|
|
46
|
+
return value
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/cli/provider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cancel, intro, isCancel, log,
|
|
1
|
+
import { cancel, intro, isCancel, log, password, select } from '@clack/prompts'
|
|
2
2
|
import { defineCommand } from 'citty'
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { findAgentDir, isInitialized } from '@/init'
|
|
18
18
|
import { makeOAuthLoginRunner } from '@/init/oauth-login'
|
|
19
19
|
|
|
20
|
+
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
20
21
|
import { c, done, errorLine } from './ui'
|
|
21
22
|
|
|
22
23
|
const addSub = defineCommand({
|
|
@@ -366,25 +367,7 @@ async function runOAuthLogin(cwd: string, providerId: KnownProviderId): Promise<
|
|
|
366
367
|
}
|
|
367
368
|
const modelRef = `${providerId}/${ref}` as const
|
|
368
369
|
|
|
369
|
-
const
|
|
370
|
-
onAuth: (url: string, instructions?: string) => {
|
|
371
|
-
const preamble = [`Open this URL in your browser to authorize ${provider.name}.`]
|
|
372
|
-
if (instructions) preamble.push('', instructions)
|
|
373
|
-
note(preamble.join('\n'), 'Browser login')
|
|
374
|
-
console.log(url)
|
|
375
|
-
console.log('')
|
|
376
|
-
},
|
|
377
|
-
onProgress: (message: string) => {
|
|
378
|
-
log.info(message)
|
|
379
|
-
},
|
|
380
|
-
onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
|
|
381
|
-
const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
|
|
382
|
-
if (isCancel(value)) return null
|
|
383
|
-
return value
|
|
384
|
-
},
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const runner = makeOAuthLoginRunner(callbacks)
|
|
370
|
+
const runner = makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))
|
|
388
371
|
const result = await runner({ cwd, model: modelRef as Parameters<typeof runner>[0]['model'] })
|
|
389
372
|
if (!result.ok) return { ok: false, reason: result.reason }
|
|
390
373
|
return { ok: true }
|
package/src/cli/ui.ts
CHANGED
|
@@ -124,3 +124,98 @@ export function errorLine(reason: string): string {
|
|
|
124
124
|
export function successLine(message: string): string {
|
|
125
125
|
return `${c.green('●')} ${message}`
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
// The exact JSON manifest a user pastes into
|
|
129
|
+
// https://api.slack.com/apps → From a manifest. Kept as a typed object so
|
|
130
|
+
// the file stays a single source of truth and `JSON.stringify` guarantees
|
|
131
|
+
// the rendered text is always valid JSON — no risk of a stray comma or
|
|
132
|
+
// quote slipping in through hand-formatting.
|
|
133
|
+
export const SLACK_APP_MANIFEST = {
|
|
134
|
+
display_information: { name: 'TypeClaw' },
|
|
135
|
+
features: {
|
|
136
|
+
bot_user: { display_name: 'TypeClaw', always_online: true },
|
|
137
|
+
// Enable the Messages tab so users can DM the bot from its app profile,
|
|
138
|
+
// and disable the Home tab — TypeClaw does not publish a custom App Home
|
|
139
|
+
// view, and leaving it enabled would surface an empty default tab.
|
|
140
|
+
app_home: {
|
|
141
|
+
home_tab_enabled: false,
|
|
142
|
+
messages_tab_enabled: true,
|
|
143
|
+
messages_tab_read_only_enabled: false,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
oauth_config: {
|
|
147
|
+
scopes: {
|
|
148
|
+
// Ordered alphabetically so the manifest stays a stable diff target.
|
|
149
|
+
// Read scopes cover every conversation type the agent might observe;
|
|
150
|
+
// write scopes (chat, files, im/mpim/groups, pins, reactions) let the
|
|
151
|
+
// agent post replies, upload attachments, open DMs, pin messages, and
|
|
152
|
+
// react to messages. `channels:join` lets the bot self-join public
|
|
153
|
+
// channels it's invited to discuss in.
|
|
154
|
+
bot: [
|
|
155
|
+
'app_mentions:read',
|
|
156
|
+
'channels:history',
|
|
157
|
+
'channels:join',
|
|
158
|
+
'channels:read',
|
|
159
|
+
'chat:write',
|
|
160
|
+
'emoji:read',
|
|
161
|
+
'files:read',
|
|
162
|
+
'files:write',
|
|
163
|
+
'groups:history',
|
|
164
|
+
'groups:read',
|
|
165
|
+
'groups:write',
|
|
166
|
+
'im:history',
|
|
167
|
+
'im:read',
|
|
168
|
+
'im:write',
|
|
169
|
+
'mpim:history',
|
|
170
|
+
'mpim:read',
|
|
171
|
+
'mpim:write',
|
|
172
|
+
'pins:read',
|
|
173
|
+
'pins:write',
|
|
174
|
+
'reactions:read',
|
|
175
|
+
'reactions:write',
|
|
176
|
+
'users:read',
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
settings: {
|
|
181
|
+
event_subscriptions: {
|
|
182
|
+
bot_events: ['app_mention', 'message.channels', 'message.groups', 'message.im', 'message.mpim'],
|
|
183
|
+
},
|
|
184
|
+
socket_mode_enabled: true,
|
|
185
|
+
},
|
|
186
|
+
} as const
|
|
187
|
+
|
|
188
|
+
// Prints the "create a Slack app from a manifest" walkthrough so the JSON
|
|
189
|
+
// payload is **flush-left and copy-pasteable**. Clack's `note()` wraps
|
|
190
|
+
// content inside a box with `│` borders on both sides, and `log.message()`
|
|
191
|
+
// still prefixes every line with a `│ ` guide column — neither survives a
|
|
192
|
+
// click-and-drag copy. This helper splits the walkthrough into three
|
|
193
|
+
// segments: a boxed prose intro, a raw-stdout JSON block, and a boxed
|
|
194
|
+
// follow-up. The JSON block is emitted via `process.stdout.write` so it
|
|
195
|
+
// carries zero terminal decoration.
|
|
196
|
+
export function printSlackAppManifestSetup(output: NodeJS.WritableStream = process.stdout): void {
|
|
197
|
+
note(
|
|
198
|
+
[
|
|
199
|
+
'1. https://api.slack.com/apps → Create New App → From a manifest.',
|
|
200
|
+
' Pick your workspace, then paste the JSON manifest printed below',
|
|
201
|
+
` (it is rendered flush-left so you can ${c.bold('click-drag and copy')} cleanly).`,
|
|
202
|
+
].join('\n'),
|
|
203
|
+
'Get a Slack bot',
|
|
204
|
+
)
|
|
205
|
+
output.write('\n')
|
|
206
|
+
output.write(`${JSON.stringify(SLACK_APP_MANIFEST, null, 2)}\n`)
|
|
207
|
+
output.write('\n')
|
|
208
|
+
note(
|
|
209
|
+
[
|
|
210
|
+
'2. Install to Workspace, then OAuth & Permissions →',
|
|
211
|
+
' copy the Bot User OAuth Token (xoxb-...).',
|
|
212
|
+
'3. Basic Information → App-Level Tokens → Generate Token and',
|
|
213
|
+
' Scopes, add the connections:write scope, and copy the',
|
|
214
|
+
' token (xapp-...). Socket Mode needs this; the manifest',
|
|
215
|
+
' cannot grant it.',
|
|
216
|
+
'4. Invite the bot to any private channel or DM you want it in:',
|
|
217
|
+
' /invite @TypeClaw',
|
|
218
|
+
].join('\n'),
|
|
219
|
+
'Finish Slack setup',
|
|
220
|
+
)
|
|
221
|
+
}
|