@andypai/orb 0.1.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/LICENSE +21 -0
  2. package/README.md +349 -0
  3. package/assets/orb-logo.svg +75 -0
  4. package/assets/orb-terminal-session.svg +72 -0
  5. package/assets/orb-wordmark.svg +77 -0
  6. package/package.json +76 -0
  7. package/prompts/anthropic.md +2 -0
  8. package/prompts/base.md +1 -0
  9. package/prompts/openai.md +7 -0
  10. package/prompts/voice.md +12 -0
  11. package/src/cli.ts +9 -0
  12. package/src/config.ts +270 -0
  13. package/src/index.ts +82 -0
  14. package/src/pipeline/adapters/anthropic.ts +111 -0
  15. package/src/pipeline/adapters/openai.ts +202 -0
  16. package/src/pipeline/adapters/types.ts +16 -0
  17. package/src/pipeline/adapters/utils.ts +131 -0
  18. package/src/pipeline/frames.ts +113 -0
  19. package/src/pipeline/observer.ts +36 -0
  20. package/src/pipeline/observers/metrics.ts +95 -0
  21. package/src/pipeline/pipeline.ts +43 -0
  22. package/src/pipeline/processor.ts +57 -0
  23. package/src/pipeline/processors/agent.ts +38 -0
  24. package/src/pipeline/processors/tts.ts +120 -0
  25. package/src/pipeline/task.ts +239 -0
  26. package/src/pipeline/transports/terminal-text.ts +24 -0
  27. package/src/pipeline/transports/types.ts +33 -0
  28. package/src/services/auth-utils.ts +149 -0
  29. package/src/services/global-config.ts +363 -0
  30. package/src/services/openai-auth.ts +18 -0
  31. package/src/services/prompts.ts +76 -0
  32. package/src/services/provider-defaults.ts +97 -0
  33. package/src/services/session.ts +204 -0
  34. package/src/services/streaming-tts.ts +483 -0
  35. package/src/services/tts.ts +309 -0
  36. package/src/setup.ts +234 -0
  37. package/src/types/index.ts +108 -0
  38. package/src/ui/App.tsx +142 -0
  39. package/src/ui/components/ActivityTimeline.tsx +60 -0
  40. package/src/ui/components/AsciiOrb.tsx +92 -0
  41. package/src/ui/components/ConversationRail.tsx +44 -0
  42. package/src/ui/components/Footer.tsx +61 -0
  43. package/src/ui/components/InputPrompt.tsx +88 -0
  44. package/src/ui/components/MicroOrb.tsx +25 -0
  45. package/src/ui/components/TTSErrorBanner.tsx +36 -0
  46. package/src/ui/components/TurnRow.tsx +71 -0
  47. package/src/ui/components/WelcomeSplash.tsx +78 -0
  48. package/src/ui/hooks/useAnimationFrame.ts +33 -0
  49. package/src/ui/hooks/useConversation.ts +195 -0
  50. package/src/ui/hooks/useKeyboardShortcuts.ts +57 -0
  51. package/src/ui/hooks/usePipeline.ts +83 -0
  52. package/src/ui/hooks/useTerminalSize.ts +37 -0
  53. package/src/ui/utils/markdown.ts +89 -0
  54. package/src/ui/utils/model-label.ts +20 -0
  55. package/src/ui/utils/text.ts +18 -0
  56. package/src/ui/utils/tool-format.ts +40 -0
@@ -0,0 +1,363 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { parse, stringify } from '@iarna/toml'
5
+ import { DEFAULT_MODEL_BY_PROVIDER, type ExplicitFlags } from '../config'
6
+ import { VOICES, type AppConfig, type LlmModelId, type LlmProvider, type Voice } from '../types'
7
+
8
+ const CONFIG_DIR = '.orb'
9
+ const CONFIG_FILE = 'config.toml'
10
+
11
+ export interface OrbGlobalTtsConfig {
12
+ enabled?: boolean
13
+ streaming?: boolean
14
+ mode?: AppConfig['ttsMode']
15
+ serverUrl?: string
16
+ voice?: Voice
17
+ speed?: number
18
+ bufferSentences?: number
19
+ clauseBoundaries?: boolean
20
+ minChunkLength?: number
21
+ maxWaitMs?: number
22
+ graceWindowMs?: number
23
+ }
24
+
25
+ export interface OrbGlobalConfig {
26
+ provider?: LlmProvider
27
+ model?: LlmModelId
28
+ skipIntro?: boolean
29
+ tts?: OrbGlobalTtsConfig
30
+ }
31
+
32
+ export interface LoadGlobalConfigResult {
33
+ config: OrbGlobalConfig
34
+ explicit: Partial<ExplicitFlags>
35
+ warnings: string[]
36
+ path: string
37
+ exists: boolean
38
+ }
39
+
40
+ type RawObject = Record<string, unknown>
41
+
42
+ function asObject(value: unknown): RawObject | null {
43
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as RawObject) : null
44
+ }
45
+
46
+ function validateString(value: unknown, label: string, warnings: string[]): string | undefined {
47
+ if (typeof value !== 'string') {
48
+ warnings.push(`${label} must be a string.`)
49
+ return undefined
50
+ }
51
+
52
+ const trimmed = value.trim()
53
+ if (trimmed.length === 0) {
54
+ warnings.push(`${label} must not be empty.`)
55
+ return undefined
56
+ }
57
+
58
+ return trimmed
59
+ }
60
+
61
+ function validateBoolean(value: unknown, label: string, warnings: string[]): boolean | undefined {
62
+ if (typeof value !== 'boolean') {
63
+ warnings.push(`${label} must be true or false.`)
64
+ return undefined
65
+ }
66
+
67
+ return value
68
+ }
69
+
70
+ function validatePositiveNumber(
71
+ value: unknown,
72
+ label: string,
73
+ warnings: string[],
74
+ ): number | undefined {
75
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
76
+ warnings.push(`${label} must be a positive number.`)
77
+ return undefined
78
+ }
79
+
80
+ return value
81
+ }
82
+
83
+ function validatePositiveInt(
84
+ value: unknown,
85
+ label: string,
86
+ warnings: string[],
87
+ ): number | undefined {
88
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
89
+ warnings.push(`${label} must be a positive integer.`)
90
+ return undefined
91
+ }
92
+
93
+ return value
94
+ }
95
+
96
+ function validateNonNegativeInt(
97
+ value: unknown,
98
+ label: string,
99
+ warnings: string[],
100
+ ): number | undefined {
101
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
102
+ warnings.push(`${label} must be a non-negative integer.`)
103
+ return undefined
104
+ }
105
+
106
+ return value
107
+ }
108
+
109
+ function validateProvider(
110
+ value: unknown,
111
+ label: string,
112
+ warnings: string[],
113
+ ): LlmProvider | undefined {
114
+ if (value === 'anthropic' || value === 'openai') return value
115
+ warnings.push(`${label} must be "anthropic" or "openai".`)
116
+ return undefined
117
+ }
118
+
119
+ function validateTtsMode(
120
+ value: unknown,
121
+ label: string,
122
+ warnings: string[],
123
+ ): AppConfig['ttsMode'] | undefined {
124
+ if (value === 'generate' || value === 'serve') return value
125
+ warnings.push(`${label} must be "generate" or "serve".`)
126
+ return undefined
127
+ }
128
+
129
+ function validateVoice(value: unknown, label: string, warnings: string[]): Voice | undefined {
130
+ if (typeof value !== 'string' || !VOICES.includes(value as Voice)) {
131
+ warnings.push(`${label} must be one of: ${VOICES.join(', ')}.`)
132
+ return undefined
133
+ }
134
+
135
+ return value as Voice
136
+ }
137
+
138
+ export function getGlobalConfigPath(homeDir = os.homedir()): string {
139
+ return path.join(homeDir, CONFIG_DIR, CONFIG_FILE)
140
+ }
141
+
142
+ export function parseGlobalConfigToml(
143
+ contents: string,
144
+ configPath = getGlobalConfigPath(),
145
+ ): Omit<LoadGlobalConfigResult, 'path' | 'exists'> {
146
+ const warnings: string[] = []
147
+ const config: OrbGlobalConfig = {}
148
+ const explicit: Partial<ExplicitFlags> = {}
149
+
150
+ let parsed: unknown
151
+ try {
152
+ parsed = parse(contents)
153
+ } catch (error) {
154
+ warnings.push(
155
+ `Failed to parse Orb config "${configPath}": ${error instanceof Error ? error.message : String(error)}`,
156
+ )
157
+ return { config, explicit, warnings }
158
+ }
159
+
160
+ const root = asObject(parsed)
161
+ if (!root) {
162
+ warnings.push(`Orb config "${configPath}" must contain a TOML table.`)
163
+ return { config, explicit, warnings }
164
+ }
165
+
166
+ if ('provider' in root) {
167
+ const provider = validateProvider(root.provider, 'provider', warnings)
168
+ if (provider) {
169
+ config.provider = provider
170
+ explicit.provider = true
171
+ }
172
+ }
173
+
174
+ if ('model' in root) {
175
+ const model = validateString(root.model, 'model', warnings)
176
+ if (model) {
177
+ config.model = model
178
+ explicit.model = true
179
+ }
180
+ }
181
+
182
+ if ('skip_intro' in root) {
183
+ const skipIntro = validateBoolean(root.skip_intro, 'skip_intro', warnings)
184
+ if (skipIntro !== undefined) {
185
+ config.skipIntro = skipIntro
186
+ }
187
+ }
188
+
189
+ if ('tts' in root) {
190
+ const rawTts = asObject(root.tts)
191
+ if (!rawTts) {
192
+ warnings.push('tts must be a table.')
193
+ } else {
194
+ const tts: OrbGlobalTtsConfig = {}
195
+
196
+ if ('enabled' in rawTts) {
197
+ const value = validateBoolean(rawTts.enabled, 'tts.enabled', warnings)
198
+ if (value !== undefined) tts.enabled = value
199
+ }
200
+ if ('streaming' in rawTts) {
201
+ const value = validateBoolean(rawTts.streaming, 'tts.streaming', warnings)
202
+ if (value !== undefined) tts.streaming = value
203
+ }
204
+ if ('mode' in rawTts) {
205
+ const value = validateTtsMode(rawTts.mode, 'tts.mode', warnings)
206
+ if (value) tts.mode = value
207
+ }
208
+ if ('server_url' in rawTts) {
209
+ const value = validateString(rawTts.server_url, 'tts.server_url', warnings)
210
+ if (value) tts.serverUrl = value
211
+ }
212
+ if ('voice' in rawTts) {
213
+ const value = validateVoice(rawTts.voice, 'tts.voice', warnings)
214
+ if (value) tts.voice = value
215
+ }
216
+ if ('speed' in rawTts) {
217
+ const value = validatePositiveNumber(rawTts.speed, 'tts.speed', warnings)
218
+ if (value !== undefined) tts.speed = value
219
+ }
220
+ if ('buffer_sentences' in rawTts) {
221
+ const value = validatePositiveInt(rawTts.buffer_sentences, 'tts.buffer_sentences', warnings)
222
+ if (value !== undefined) {
223
+ tts.bufferSentences = value
224
+ explicit.ttsBufferSentences = true
225
+ }
226
+ }
227
+ if ('clause_boundaries' in rawTts) {
228
+ const value = validateBoolean(rawTts.clause_boundaries, 'tts.clause_boundaries', warnings)
229
+ if (value !== undefined) {
230
+ tts.clauseBoundaries = value
231
+ explicit.ttsClauseBoundaries = true
232
+ }
233
+ }
234
+ if ('min_chunk_length' in rawTts) {
235
+ const value = validateNonNegativeInt(
236
+ rawTts.min_chunk_length,
237
+ 'tts.min_chunk_length',
238
+ warnings,
239
+ )
240
+ if (value !== undefined) {
241
+ tts.minChunkLength = value
242
+ explicit.ttsMinChunkLength = true
243
+ }
244
+ }
245
+ if ('max_wait_ms' in rawTts) {
246
+ const value = validateNonNegativeInt(rawTts.max_wait_ms, 'tts.max_wait_ms', warnings)
247
+ if (value !== undefined) {
248
+ tts.maxWaitMs = value
249
+ explicit.ttsMaxWaitMs = true
250
+ }
251
+ }
252
+ if ('grace_window_ms' in rawTts) {
253
+ const value = validateNonNegativeInt(
254
+ rawTts.grace_window_ms,
255
+ 'tts.grace_window_ms',
256
+ warnings,
257
+ )
258
+ if (value !== undefined) {
259
+ tts.graceWindowMs = value
260
+ explicit.ttsGraceWindowMs = true
261
+ }
262
+ }
263
+
264
+ if (Object.keys(tts).length > 0) {
265
+ config.tts = tts
266
+ }
267
+ }
268
+ }
269
+
270
+ return { config, explicit, warnings }
271
+ }
272
+
273
+ export async function loadGlobalConfig(
274
+ configPath = getGlobalConfigPath(),
275
+ ): Promise<LoadGlobalConfigResult> {
276
+ try {
277
+ const contents = await fs.readFile(configPath, 'utf8')
278
+ const parsed = parseGlobalConfigToml(contents, configPath)
279
+ return { ...parsed, path: configPath, exists: true }
280
+ } catch (error) {
281
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
282
+ return { config: {}, explicit: {}, warnings: [], path: configPath, exists: false }
283
+ }
284
+
285
+ return {
286
+ config: {},
287
+ explicit: {},
288
+ warnings: [
289
+ `Failed to read Orb config "${configPath}": ${error instanceof Error ? error.message : String(error)}`,
290
+ ],
291
+ path: configPath,
292
+ exists: false,
293
+ }
294
+ }
295
+ }
296
+
297
+ export function applyGlobalConfig(baseConfig: AppConfig, globalConfig: OrbGlobalConfig): AppConfig {
298
+ const nextConfig: AppConfig = { ...baseConfig }
299
+
300
+ if (globalConfig.provider) {
301
+ nextConfig.llmProvider = globalConfig.provider
302
+ if (!globalConfig.model) {
303
+ nextConfig.llmModel = DEFAULT_MODEL_BY_PROVIDER[globalConfig.provider]
304
+ }
305
+ }
306
+
307
+ if (globalConfig.model) nextConfig.llmModel = globalConfig.model
308
+ if (globalConfig.skipIntro !== undefined) nextConfig.skipIntro = globalConfig.skipIntro
309
+
310
+ if (globalConfig.tts) {
311
+ const tts = globalConfig.tts
312
+ if (tts.enabled !== undefined) nextConfig.ttsEnabled = tts.enabled
313
+ if (tts.streaming !== undefined) nextConfig.ttsStreamingEnabled = tts.streaming
314
+ if (tts.mode) nextConfig.ttsMode = tts.mode
315
+ if (tts.serverUrl) nextConfig.ttsServerUrl = tts.serverUrl
316
+ if (tts.voice) nextConfig.ttsVoice = tts.voice
317
+ if (tts.speed !== undefined) nextConfig.ttsSpeed = tts.speed
318
+ if (tts.bufferSentences !== undefined) nextConfig.ttsBufferSentences = tts.bufferSentences
319
+ if (tts.clauseBoundaries !== undefined) nextConfig.ttsClauseBoundaries = tts.clauseBoundaries
320
+ if (tts.minChunkLength !== undefined) nextConfig.ttsMinChunkLength = tts.minChunkLength
321
+ if (tts.maxWaitMs !== undefined) nextConfig.ttsMaxWaitMs = tts.maxWaitMs
322
+ if (tts.graceWindowMs !== undefined) nextConfig.ttsGraceWindowMs = tts.graceWindowMs
323
+ }
324
+
325
+ return nextConfig
326
+ }
327
+
328
+ export function serializeGlobalConfig(config: OrbGlobalConfig): string {
329
+ const document: RawObject = {}
330
+
331
+ if (config.provider) document.provider = config.provider
332
+ if (config.model) document.model = config.model
333
+ if (config.skipIntro !== undefined) document.skip_intro = config.skipIntro
334
+
335
+ if (config.tts) {
336
+ const tts: RawObject = {}
337
+ if (config.tts.enabled !== undefined) tts.enabled = config.tts.enabled
338
+ if (config.tts.streaming !== undefined) tts.streaming = config.tts.streaming
339
+ if (config.tts.mode) tts.mode = config.tts.mode
340
+ if (config.tts.serverUrl) tts.server_url = config.tts.serverUrl
341
+ if (config.tts.voice) tts.voice = config.tts.voice
342
+ if (config.tts.speed !== undefined) tts.speed = config.tts.speed
343
+ if (config.tts.bufferSentences !== undefined) tts.buffer_sentences = config.tts.bufferSentences
344
+ if (config.tts.clauseBoundaries !== undefined)
345
+ tts.clause_boundaries = config.tts.clauseBoundaries
346
+ if (config.tts.minChunkLength !== undefined) tts.min_chunk_length = config.tts.minChunkLength
347
+ if (config.tts.maxWaitMs !== undefined) tts.max_wait_ms = config.tts.maxWaitMs
348
+ if (config.tts.graceWindowMs !== undefined) tts.grace_window_ms = config.tts.graceWindowMs
349
+ if (Object.keys(tts).length > 0) document.tts = tts
350
+ }
351
+
352
+ // @iarna/toml expects JsonMap; our RawObject is structurally compatible at runtime.
353
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
354
+ return stringify(document as any)
355
+ }
356
+
357
+ export async function writeGlobalConfig(
358
+ config: OrbGlobalConfig,
359
+ configPath = getGlobalConfigPath(),
360
+ ): Promise<void> {
361
+ await fs.mkdir(path.dirname(configPath), { recursive: true })
362
+ await Bun.write(configPath, serializeGlobalConfig(config))
363
+ }
@@ -0,0 +1,18 @@
1
+ import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai'
2
+ import type { AppConfig } from '../types'
3
+ export type AuthSource = 'api-key'
4
+
5
+ export function getOpenAiApiKey(config: AppConfig): string | null {
6
+ return config.openaiApiKey || Bun.env.OPENAI_API_KEY || null
7
+ }
8
+
9
+ export async function resolveOpenAiProvider(
10
+ config: AppConfig,
11
+ ): Promise<{ provider: OpenAIProvider; source: AuthSource }> {
12
+ const apiKey = getOpenAiApiKey(config)
13
+ if (apiKey) {
14
+ return { provider: createOpenAI({ apiKey }), source: 'api-key' }
15
+ }
16
+
17
+ throw new Error('OpenAI requires OPENAI_API_KEY for Orb.')
18
+ }
@@ -0,0 +1,76 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+ import type { LlmProvider } from '../types'
4
+
5
+ const PROMPTS_DIR = join(import.meta.dir, '..', '..', 'prompts')
6
+
7
+ const PROVIDER_PROMPT_FILES: Record<LlmProvider, string> = {
8
+ anthropic: 'anthropic.md',
9
+ openai: 'openai.md',
10
+ }
11
+
12
+ const VOICE_PROMPT_FILE = 'voice.md'
13
+
14
+ interface PromptTemplateValues {
15
+ projectName: string
16
+ projectPath: string
17
+ }
18
+
19
+ export interface PromptBuildOptions {
20
+ provider: LlmProvider
21
+ projectPath: string
22
+ ttsEnabled: boolean
23
+ promptsDir?: string
24
+ }
25
+
26
+ function getPromptTemplateValues(projectPath: string): PromptTemplateValues {
27
+ return {
28
+ projectName: basename(projectPath) || projectPath,
29
+ projectPath,
30
+ }
31
+ }
32
+
33
+ function interpolatePrompt(template: string, values: PromptTemplateValues): string {
34
+ return template
35
+ .replaceAll('{{projectName}}', values.projectName)
36
+ .replaceAll('{{projectPath}}', values.projectPath)
37
+ }
38
+
39
+ async function readPromptFile(
40
+ promptsDir: string,
41
+ fileName: string,
42
+ values: PromptTemplateValues,
43
+ ): Promise<string> {
44
+ const filePath = join(promptsDir, fileName)
45
+
46
+ try {
47
+ const contents = await readFile(filePath, 'utf8')
48
+ return interpolatePrompt(contents.trim(), values)
49
+ } catch (error) {
50
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
51
+ throw new Error(`Missing prompt file: ${filePath}`)
52
+ }
53
+ throw new Error(
54
+ `Failed to read prompt file "${filePath}": ${error instanceof Error ? error.message : String(error)}`,
55
+ )
56
+ }
57
+ }
58
+
59
+ export async function buildProviderPrompt({
60
+ provider,
61
+ projectPath,
62
+ ttsEnabled,
63
+ promptsDir = PROMPTS_DIR,
64
+ }: PromptBuildOptions): Promise<string> {
65
+ const values = getPromptTemplateValues(projectPath)
66
+ const fileNames = [
67
+ 'base.md',
68
+ PROVIDER_PROMPT_FILES[provider],
69
+ ...(ttsEnabled ? [VOICE_PROMPT_FILE] : []),
70
+ ]
71
+ const sections = await Promise.all(
72
+ fileNames.map((fileName) => readPromptFile(promptsDir, fileName, values)),
73
+ )
74
+
75
+ return sections.filter(Boolean).join('\n\n').trim()
76
+ }
@@ -0,0 +1,97 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk'
2
+ import { DEFAULT_MODEL_BY_PROVIDER } from '../config'
3
+ import type { AppConfig, LlmProvider } from '../types'
4
+ import { getOpenAiApiKey } from './openai-auth'
5
+
6
+ type SmartProviderSource = 'claude-oauth' | 'openai-api-key' | 'anthropic-api-key'
7
+
8
+ type ClaudeAuthState = {
9
+ hasOAuth: boolean
10
+ hasApiKey: boolean
11
+ }
12
+
13
+ const CLAUDE_AUTH_TIMEOUT_MS = 10_000
14
+
15
+ function getAnthropicApiKey(): string | null {
16
+ return Bun.env.ANTHROPIC_API_KEY || Bun.env.CLAUDE_API_KEY || null
17
+ }
18
+
19
+ // Empty async iterable that immediately completes
20
+ const EMPTY_PROMPT: AsyncIterable<never> = {
21
+ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined as never }) }),
22
+ }
23
+
24
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
25
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
26
+ const timeout = new Promise<T>((_, reject) => {
27
+ timeoutId = setTimeout(() => {
28
+ reject(new Error('timeout'))
29
+ }, timeoutMs)
30
+ })
31
+ try {
32
+ return await Promise.race([promise, timeout])
33
+ } finally {
34
+ if (timeoutId) clearTimeout(timeoutId)
35
+ }
36
+ }
37
+
38
+ async function detectClaudeAuth(config: AppConfig): Promise<ClaudeAuthState> {
39
+ if (!Bun.which('claude')) {
40
+ return { hasOAuth: false, hasApiKey: false }
41
+ }
42
+
43
+ const abortController = new AbortController()
44
+ const prompt = EMPTY_PROMPT
45
+ const queryInstance = query({
46
+ prompt,
47
+ options: {
48
+ cwd: config.projectPath,
49
+ model: DEFAULT_MODEL_BY_PROVIDER.anthropic,
50
+ maxTurns: 1,
51
+ persistSession: false,
52
+ permissionMode: 'default',
53
+ abortController,
54
+ stderr: () => {},
55
+ },
56
+ })
57
+
58
+ try {
59
+ const info = await withTimeout(queryInstance.accountInfo(), CLAUDE_AUTH_TIMEOUT_MS)
60
+ return {
61
+ hasOAuth: Boolean(info?.tokenSource || info?.subscriptionType),
62
+ hasApiKey: Boolean(info?.apiKeySource),
63
+ }
64
+ } catch (error) {
65
+ const reason = error instanceof Error ? error.message : String(error)
66
+ console.warn(`[orb] Claude credential detection failed: ${reason}`)
67
+ return { hasOAuth: false, hasApiKey: false }
68
+ } finally {
69
+ abortController.abort()
70
+ await queryInstance.interrupt().catch(() => {})
71
+ if (typeof queryInstance.return === 'function') {
72
+ await queryInstance.return()
73
+ }
74
+ }
75
+ }
76
+
77
+ export async function resolveSmartProvider(
78
+ config: AppConfig,
79
+ ): Promise<{ provider: LlmProvider; source: SmartProviderSource } | null> {
80
+ const claudeAuth = await detectClaudeAuth(config)
81
+
82
+ if (claudeAuth.hasOAuth) {
83
+ return { provider: 'anthropic', source: 'claude-oauth' }
84
+ }
85
+
86
+ const openAiApiKey = getOpenAiApiKey(config)
87
+ if (openAiApiKey) {
88
+ return { provider: 'openai', source: 'openai-api-key' }
89
+ }
90
+
91
+ const anthropicApiKey = getAnthropicApiKey()
92
+ if (claudeAuth.hasApiKey || anthropicApiKey) {
93
+ return { provider: 'anthropic', source: 'anthropic-api-key' }
94
+ }
95
+
96
+ return null
97
+ }