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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/channel.ts +2 -45
  32. package/src/cli/init.ts +148 -87
  33. package/src/cli/model.ts +12 -3
  34. package/src/cli/oauth-callbacks.ts +49 -0
  35. package/src/cli/provider.ts +3 -20
  36. package/src/cli/ui.ts +95 -0
  37. package/src/config/config.ts +59 -24
  38. package/src/config/models-mutation.ts +42 -8
  39. package/src/config/providers-mutation.ts +12 -8
  40. package/src/container/start.ts +18 -1
  41. package/src/cron/consumer.ts +129 -43
  42. package/src/init/dockerfile.ts +221 -3
  43. package/src/init/hatching.ts +2 -2
  44. package/src/init/index.ts +47 -3
  45. package/src/init/oauth-login.ts +17 -3
  46. package/src/permissions/builtins.ts +29 -7
  47. package/src/permissions/permissions.ts +24 -7
  48. package/src/plugin/define.ts +2 -0
  49. package/src/plugin/manager.ts +14 -0
  50. package/src/plugin/types.ts +6 -0
  51. package/src/run/index.ts +2 -1
  52. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  53. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  54. package/src/tui/index.ts +35 -3
  55. package/src/usage/report.ts +15 -12
  56. package/typeclaw.schema.json +57 -25
@@ -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
- note(
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 { DockerAvailability } from '@/container'
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 { c, done, errorLine } from './ui'
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
- constructor() {
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
- buildOAuthAuth: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => LLMAuth
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
- buildOAuthAuth: (provider) => ({
330
- kind: 'oauth',
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) throw new WizardAbortedError()
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
- state.llmAuth = prompts.buildOAuthAuth(provider)
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
- state.visionLlmAuth = prompts.buildOAuthAuth(provider)
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
- note(
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 refWidth = Math.max(3, ...entries.map((e) => e.ref.length))
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.ref.padEnd(refWidth)} ${e.providerId.padEnd(12)} ${status}`
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
+ }
@@ -1,4 +1,4 @@
1
- import { cancel, intro, isCancel, log, note, password, select, text } from '@clack/prompts'
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 callbacks = {
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
+ }