@tenex-chat/backend 0.9.4 → 0.9.6

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 (148) hide show
  1. package/README.md +5 -1
  2. package/dist/daemon-wrapper.cjs +47 -0
  3. package/dist/index.js +59268 -0
  4. package/dist/wrapper.js +171 -0
  5. package/package.json +19 -27
  6. package/src/agents/AgentRegistry.ts +9 -7
  7. package/src/agents/AgentStorage.ts +24 -1
  8. package/src/agents/agent-installer.ts +6 -0
  9. package/src/agents/agent-loader.ts +7 -2
  10. package/src/agents/constants.ts +10 -2
  11. package/src/agents/execution/AgentExecutor.ts +35 -6
  12. package/src/agents/execution/StreamCallbacks.ts +53 -13
  13. package/src/agents/execution/StreamExecutionHandler.ts +110 -16
  14. package/src/agents/execution/StreamSetup.ts +19 -9
  15. package/src/agents/execution/ToolEventHandlers.ts +112 -0
  16. package/src/agents/role-categories.ts +53 -0
  17. package/src/agents/types/runtime.ts +7 -0
  18. package/src/agents/types/storage.ts +7 -0
  19. package/src/commands/agent/import/openclaw-distiller.ts +63 -7
  20. package/src/commands/agent/import/openclaw-reader.ts +54 -0
  21. package/src/commands/agent/import/openclaw.ts +120 -29
  22. package/src/commands/agent/index.ts +83 -2
  23. package/src/commands/setup/display.ts +123 -0
  24. package/src/commands/setup/embed.ts +13 -13
  25. package/src/commands/setup/global-system-prompt.ts +15 -17
  26. package/src/commands/setup/image.ts +17 -20
  27. package/src/commands/setup/interactive.ts +37 -20
  28. package/src/commands/setup/llm.ts +12 -7
  29. package/src/commands/setup/onboarding.ts +1580 -248
  30. package/src/commands/setup/providers.ts +3 -3
  31. package/src/conversations/ConversationStore.ts +23 -2
  32. package/src/conversations/MessageBuilder.ts +51 -73
  33. package/src/conversations/formatters/utils/conversation-transcript-formatter.ts +425 -0
  34. package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +40 -98
  35. package/src/conversations/search/embeddings/ConversationIndexingJob.ts +40 -52
  36. package/src/conversations/services/ConversationSummarizer.ts +1 -2
  37. package/src/conversations/types.ts +11 -0
  38. package/src/daemon/Daemon.ts +78 -57
  39. package/src/daemon/ProjectRuntime.ts +6 -12
  40. package/src/daemon/SubscriptionManager.ts +13 -0
  41. package/src/daemon/index.ts +0 -1
  42. package/src/event-handler/index.ts +1 -0
  43. package/src/index.ts +20 -1
  44. package/src/llm/ChunkHandler.ts +1 -1
  45. package/src/llm/FinishHandler.ts +28 -4
  46. package/src/llm/LLMConfigEditor.ts +218 -106
  47. package/src/llm/index.ts +0 -4
  48. package/src/llm/meta/MetaModelResolver.ts +3 -18
  49. package/src/llm/middleware/message-sanitizer.ts +153 -0
  50. package/src/llm/providers/ollama-models.ts +0 -38
  51. package/src/llm/service.ts +50 -15
  52. package/src/llm/types.ts +0 -12
  53. package/src/llm/utils/ConfigurationManager.ts +88 -465
  54. package/src/llm/utils/ConfigurationTester.ts +42 -185
  55. package/src/llm/utils/ModelSelector.ts +156 -92
  56. package/src/llm/utils/ProviderConfigUI.ts +10 -141
  57. package/src/llm/utils/models-dev-cache.ts +102 -23
  58. package/src/llm/utils/provider-select-prompt.ts +284 -0
  59. package/src/llm/utils/provider-setup.ts +81 -34
  60. package/src/llm/utils/variant-list-prompt.ts +361 -0
  61. package/src/nostr/AgentEventDecoder.ts +1 -0
  62. package/src/nostr/AgentEventEncoder.ts +37 -0
  63. package/src/nostr/AgentProfilePublisher.ts +13 -0
  64. package/src/nostr/AgentPublisher.ts +26 -0
  65. package/src/nostr/kinds.ts +1 -0
  66. package/src/nostr/ndkClient.ts +4 -1
  67. package/src/nostr/types.ts +12 -0
  68. package/src/prompts/fragments/25-rag-instructions.ts +22 -21
  69. package/src/prompts/fragments/31-agents-md-guidance.ts +7 -21
  70. package/src/prompts/fragments/index.ts +2 -0
  71. package/src/prompts/utils/systemPromptBuilder.ts +18 -28
  72. package/src/services/AgentDefinitionMonitor.ts +8 -0
  73. package/src/services/ConfigService.ts +34 -0
  74. package/src/services/PubkeyService.ts +7 -1
  75. package/src/services/compression/CompressionService.ts +133 -74
  76. package/src/services/compression/compression-utils.ts +110 -19
  77. package/src/services/config/types.ts +0 -6
  78. package/src/services/dispatch/AgentDispatchService.ts +79 -0
  79. package/src/services/intervention/InterventionService.ts +78 -5
  80. package/src/services/nip46/Nip46SigningService.ts +30 -1
  81. package/src/services/projects/ProjectContext.ts +8 -6
  82. package/src/services/rag/RAGCollectionRegistry.ts +199 -0
  83. package/src/services/rag/RAGDatabaseService.ts +2 -7
  84. package/src/services/rag/RAGOperations.ts +25 -45
  85. package/src/services/rag/RAGService.ts +0 -31
  86. package/src/services/rag/RagSubscriptionService.ts +71 -122
  87. package/src/services/rag/rag-utils.ts +13 -0
  88. package/src/services/ral/RALRegistry.ts +25 -184
  89. package/src/services/reports/ReportEmbeddingService.ts +63 -113
  90. package/src/services/search/UnifiedSearchService.ts +115 -4
  91. package/src/services/search/index.ts +1 -0
  92. package/src/services/search/projectFilter.ts +20 -4
  93. package/src/services/search/providers/ConversationSearchProvider.ts +1 -0
  94. package/src/services/search/providers/GenericCollectionSearchProvider.ts +81 -0
  95. package/src/services/search/providers/LessonSearchProvider.ts +1 -8
  96. package/src/services/search/providers/ReportSearchProvider.ts +1 -0
  97. package/src/services/search/types.ts +24 -3
  98. package/src/services/trust-pubkeys/SystemPubkeyListService.ts +148 -0
  99. package/src/services/trust-pubkeys/TrustPubkeyService.ts +70 -9
  100. package/src/telemetry/setup.ts +2 -13
  101. package/src/tools/implementations/ask.ts +3 -3
  102. package/src/tools/implementations/conversation_get.ts +28 -268
  103. package/src/tools/implementations/fs_grep.ts +6 -6
  104. package/src/tools/implementations/fs_read.ts +2 -0
  105. package/src/tools/implementations/fs_write.ts +2 -0
  106. package/src/tools/implementations/learn.ts +38 -50
  107. package/src/tools/implementations/rag_add_documents.ts +6 -4
  108. package/src/tools/implementations/rag_create_collection.ts +37 -4
  109. package/src/tools/implementations/rag_delete_collection.ts +9 -0
  110. package/src/tools/implementations/{search.ts → rag_search.ts} +31 -25
  111. package/src/tools/registry.ts +7 -8
  112. package/src/tools/types.ts +11 -2
  113. package/src/tools/utils/transcript-args.ts +13 -0
  114. package/src/utils/cli-theme.ts +13 -0
  115. package/src/utils/logger.ts +55 -0
  116. package/src/utils/metadataKeys.ts +17 -0
  117. package/src/utils/sqlEscaping.ts +39 -0
  118. package/src/wrapper.ts +7 -3
  119. package/dist/src/index.js +0 -46778
  120. package/dist/tenex-backend-wrapper.cjs +0 -3
  121. package/src/agents/execution/constants.ts +0 -16
  122. package/src/agents/execution/index.ts +0 -3
  123. package/src/agents/index.ts +0 -4
  124. package/src/commands/agent.ts +0 -215
  125. package/src/conversations/formatters/DelegationXmlFormatter.ts +0 -64
  126. package/src/conversations/formatters/index.ts +0 -9
  127. package/src/conversations/index.ts +0 -2
  128. package/src/conversations/utils/content-utils.ts +0 -69
  129. package/src/daemon/UnixSocketTransport.ts +0 -318
  130. package/src/event-handler/newConversation.ts +0 -165
  131. package/src/events/NDKProjectStatus.ts +0 -384
  132. package/src/events/index.ts +0 -4
  133. package/src/lib/json-parser.ts +0 -30
  134. package/src/llm/RecordingState.ts +0 -37
  135. package/src/llm/StreamPublisher.ts +0 -40
  136. package/src/llm/middleware/flight-recorder.ts +0 -188
  137. package/src/llm/utils/claudeCodePromptCompiler.ts +0 -141
  138. package/src/nostr/constants.ts +0 -38
  139. package/src/prompts/core/index.ts +0 -3
  140. package/src/prompts/index.ts +0 -21
  141. package/src/services/image/index.ts +0 -12
  142. package/src/services/status/index.ts +0 -11
  143. package/src/telemetry/diagnostics.ts +0 -27
  144. package/src/tools/implementations/rag_query.ts +0 -107
  145. package/src/types/index.ts +0 -46
  146. package/src/utils/agentFetcher.ts +0 -107
  147. package/src/utils/conversation-utils.ts +0 -1
  148. package/src/utils/process.ts +0 -49
@@ -1,8 +1,8 @@
1
1
  import { PROVIDER_IDS } from "@/llm/providers/provider-ids";
2
- import { resolveApiKey, hasApiKey } from "@/llm/providers/key-manager";
2
+ import { hasApiKey } from "@/llm/providers/key-manager";
3
3
  import type { TenexLLMs } from "@/services/config/types";
4
4
  import chalk from "chalk";
5
- import inquirer from "inquirer";
5
+ import * as display from "@/commands/setup/display";
6
6
 
7
7
  /**
8
8
  * Extended type for editor use - includes providers
@@ -13,165 +13,34 @@ type TenexLLMsWithProviders = TenexLLMs & {
13
13
 
14
14
  /**
15
15
  * UI utilities for provider configuration
16
- * Extracted from LLMConfigEditor to separate concerns
17
16
  */
18
17
  export class ProviderConfigUI {
19
- /**
20
- * Get provider display names
21
- */
22
18
  static getProviderDisplayName(provider: string): string {
23
19
  const names: Record<string, string> = {
24
20
  [PROVIDER_IDS.OPENROUTER]: "OpenRouter (300+ models)",
25
21
  [PROVIDER_IDS.ANTHROPIC]: "Anthropic (Claude)",
26
22
  [PROVIDER_IDS.OPENAI]: "OpenAI (GPT)",
27
23
  [PROVIDER_IDS.OLLAMA]: "Ollama (Local models)",
28
- [PROVIDER_IDS.CLAUDE_CODE]: "Claude Code",
29
24
  [PROVIDER_IDS.CODEX_APP_SERVER]: "Codex App Server (GPT-5.1/5.2)",
30
25
  };
31
26
  return names[provider] || provider;
32
27
  }
33
28
 
34
- /**
35
- * Configure a specific provider interactively
36
- */
37
- static async configureProvider(
38
- provider: string,
39
- currentProviders?: Record<string, { apiKey: string | string[] }>
40
- ): Promise<{ apiKey: string | string[] }> {
41
- if (provider === PROVIDER_IDS.CLAUDE_CODE || provider === PROVIDER_IDS.CODEX_APP_SERVER) {
42
- // Agent providers don't require an API key
43
- console.log(
44
- chalk.green(
45
- `✓ ${ProviderConfigUI.getProviderDisplayName(provider)} provider configured (no API key required)`
46
- )
47
- );
48
- return { apiKey: "none" }; // Doesn't use API keys
49
- }
50
- if (provider === PROVIDER_IDS.OLLAMA) {
51
- // For Ollama, ask for base URL instead of API key
52
- const currentUrl = resolveApiKey(currentProviders?.[provider]?.apiKey) || "local";
53
- const { ollamaConfig } = await inquirer.prompt([
54
- {
55
- type: "select",
56
- name: "ollamaConfig",
57
- message: "Ollama configuration:",
58
- choices: [
59
- { name: "Use local Ollama (http://localhost:11434)", value: "local" },
60
- { name: "Use custom Ollama URL", value: "custom" },
61
- ],
62
- default: currentUrl === "local" ? "local" : "custom",
63
- },
64
- ]);
65
-
66
- let baseUrl = "local";
67
- if (ollamaConfig === "custom") {
68
- const { customUrl } = await inquirer.prompt([
69
- {
70
- type: "input",
71
- name: "customUrl",
72
- message: "Enter Ollama base URL:",
73
- default: currentUrl !== "local" ? currentUrl : "http://localhost:11434",
74
- validate: (input: string) => {
75
- if (!input.trim()) return "URL is required";
76
- try {
77
- new URL(input);
78
- return true;
79
- } catch {
80
- return "Please enter a valid URL";
81
- }
82
- },
83
- },
84
- ]);
85
- baseUrl = customUrl;
86
- }
87
-
88
- return { apiKey: baseUrl };
89
- }
90
- // For other providers, ask for API key
91
- const existingKey = currentProviders?.[provider]?.apiKey;
92
-
93
- // If the provider already has a multi-key array, preserve it
94
- if (Array.isArray(existingKey) && existingKey.length > 1) {
95
- console.log(
96
- chalk.cyan(
97
- ` ℹ ${ProviderConfigUI.getProviderDisplayName(provider)} has ${existingKey.length} API keys configured (multi-key rotation).`
98
- )
99
- );
100
- console.log(
101
- chalk.gray(
102
- " To edit multi-key configs, modify providers.json directly."
103
- )
104
- );
105
- const { keep } = await inquirer.prompt([
106
- {
107
- type: "confirm",
108
- name: "keep",
109
- message: "Keep existing multi-key configuration?",
110
- default: true,
111
- },
112
- ]);
113
- if (keep) {
114
- return { apiKey: existingKey };
115
- }
116
- }
117
-
118
- const currentKey = resolveApiKey(existingKey);
119
- const { apiKey } = await inquirer.prompt([
120
- {
121
- type: "password",
122
- name: "apiKey",
123
- message: `Enter API key for ${ProviderConfigUI.getProviderDisplayName(provider)} (press Enter to keep existing):`,
124
- default: currentKey,
125
- mask: "*",
126
- validate: (input: string) => {
127
- // Allow empty input if there's an existing key
128
- if (!input.trim() && !currentKey) return "API key is required";
129
- return true;
130
- },
131
- },
132
- ]);
133
-
134
- return { apiKey: apiKey || currentKey || "" };
135
- }
136
-
137
- /**
138
- * Display current configuration status
139
- */
140
- static displayCurrentConfig(llmsConfig: TenexLLMsWithProviders): void {
141
- console.log(chalk.bold("Configured Providers:"));
29
+ static displayProviders(llmsConfig: TenexLLMsWithProviders): void {
30
+ display.context("Configured Providers");
142
31
  const providers = Object.keys(llmsConfig.providers).filter(
143
- (p) => hasApiKey(llmsConfig.providers[p]?.apiKey)
32
+ (p) => {
33
+ const key = llmsConfig.providers[p]?.apiKey;
34
+ return hasApiKey(key) || key === "none";
35
+ },
144
36
  );
145
37
  if (providers.length === 0) {
146
38
  console.log(chalk.gray(" None configured"));
147
39
  } else {
148
40
  for (const p of providers) {
149
- console.log(chalk.green(` ✓ ${ProviderConfigUI.getProviderDisplayName(p)}`));
150
- }
151
- }
152
-
153
- console.log(chalk.bold("\nConfigurations:"));
154
- const configNames = Object.keys(llmsConfig.configurations);
155
- if (configNames.length === 0) {
156
- console.log(chalk.gray(" None"));
157
- } else {
158
- for (const name of configNames) {
159
- const config = llmsConfig.configurations[name];
160
- const isDefault = name === llmsConfig.default;
161
- const isSummarization = name === llmsConfig.summarization;
162
- const marker = isDefault || isSummarization ? chalk.cyan("• ") : " ";
163
-
164
- const tags: string[] = [];
165
- if (isDefault) tags.push("default");
166
- if (isSummarization) tags.push("summarization");
167
- const tagStr = tags.length > 0 ? chalk.gray(` (${tags.join(", ")})`) : "";
168
-
169
- // Handle meta models differently - they don't have a single model
170
- const configDisplay = config.provider === "meta"
171
- ? `meta (${Object.keys((config as { variants: Record<string, unknown> }).variants).length} variants)`
172
- : `${config.provider}:${"model" in config ? config.model : "unknown"}`;
173
- console.log(` ${marker}${name}${tagStr}: ${configDisplay}`);
41
+ display.success(ProviderConfigUI.getProviderDisplayName(p));
174
42
  }
175
43
  }
44
+ display.blank();
176
45
  }
177
46
  }
@@ -24,6 +24,17 @@ export interface ModelLimits {
24
24
  output: number;
25
25
  }
26
26
 
27
+ /**
28
+ * Full model info from models.dev
29
+ */
30
+ export interface ModelsDevModel {
31
+ id: string;
32
+ name: string;
33
+ cost?: { input: number; output: number };
34
+ limit?: { context?: number; output?: number };
35
+ last_updated?: string;
36
+ }
37
+
27
38
  /**
28
39
  * models.dev API response structure
29
40
  */
@@ -31,10 +42,14 @@ interface ModelsDevResponse {
31
42
  [provider: string]: {
32
43
  models: {
33
44
  [modelId: string]: {
45
+ id?: string;
46
+ name?: string;
47
+ cost?: { input: number; output: number };
34
48
  limit?: {
35
49
  context?: number;
36
50
  output?: number;
37
51
  };
52
+ last_updated?: string;
38
53
  };
39
54
  };
40
55
  };
@@ -213,51 +228,115 @@ export async function refreshCache(): Promise<void> {
213
228
  }
214
229
 
215
230
  /**
216
- * Get model limits for a specific provider and model
231
+ * Resolve the raw model data entry from the cache.
217
232
  *
218
- * @param provider Our provider ID (e.g., "anthropic", "openai", "openrouter")
219
- * @param model Model ID (e.g., "claude-opus-4-5-20251101", "gpt-4o")
220
- * @returns Model limits or undefined if not found/unsupported
233
+ * Lookup order:
234
+ * 1. Direct: cache[mappedProvider].models[model]
235
+ * 2. Vendor split: for "vendor/bare" IDs, try cache[vendor].models[bare]
236
+ * 3. Global scan: search all providers for a matching model ID
237
+ *
238
+ * Steps 2–3 handle proxied providers (OpenRouter, Codex App Server) whose
239
+ * model IDs originate from upstream providers (OpenAI, Anthropic, etc.).
221
240
  */
222
- export function getModelLimits(provider: string, model: string): ModelLimits | undefined {
223
- if (!cache) {
224
- return undefined;
225
- }
241
+ function resolveModelData(provider: string, model: string): { modelId: string; data: ModelsDevResponse[string]["models"][string] } | undefined {
242
+ if (!cache) return undefined;
226
243
 
227
- // Map our provider ID to models.dev provider ID
244
+ // 1. Direct lookup in the mapped provider section
228
245
  const modelsDevProvider = PROVIDER_MAPPING[provider];
229
- if (modelsDevProvider === null || modelsDevProvider === undefined) {
230
- // Provider not supported by models.dev
231
- return undefined;
246
+ if (modelsDevProvider !== null && modelsDevProvider !== undefined) {
247
+ const providerData = cache[modelsDevProvider];
248
+ if (providerData?.models?.[model]) {
249
+ return { modelId: model, data: providerData.models[model] };
250
+ }
232
251
  }
233
252
 
234
- const providerData = cache[modelsDevProvider];
235
- if (!providerData?.models) {
236
- return undefined;
253
+ // 2. If the model ID contains "vendor/bare", try the vendor's section
254
+ if (model.includes("/")) {
255
+ const slashIndex = model.indexOf("/");
256
+ const vendor = model.slice(0, slashIndex);
257
+ const bareModel = model.slice(slashIndex + 1);
258
+ const vendorData = cache[vendor];
259
+ if (vendorData?.models?.[bareModel]) {
260
+ return { modelId: bareModel, data: vendorData.models[bareModel] };
261
+ }
237
262
  }
238
263
 
239
- const modelData = providerData.models[model];
240
- if (!modelData?.limit) {
241
- return undefined;
264
+ // 3. Global scan: search every provider for a matching model ID
265
+ for (const section of Object.values(cache)) {
266
+ if (section?.models?.[model]) {
267
+ return { modelId: model, data: section.models[model] };
268
+ }
242
269
  }
243
270
 
244
- const { context, output } = modelData.limit;
245
- if (context === undefined || output === undefined) {
246
- return undefined;
247
- }
271
+ return undefined;
272
+ }
273
+
274
+ /**
275
+ * Get model limits for a specific provider and model
276
+ *
277
+ * @param provider Our provider ID (e.g., "anthropic", "openai", "openrouter")
278
+ * @param model Model ID (e.g., "claude-opus-4-5-20251101", "gpt-4o")
279
+ * @returns Model limits or undefined if not found/unsupported
280
+ */
281
+ export function getModelLimits(provider: string, model: string): ModelLimits | undefined {
282
+ const resolved = resolveModelData(provider, model);
283
+ if (!resolved?.data?.limit) return undefined;
284
+
285
+ const { context, output } = resolved.data.limit;
286
+ if (context === undefined || output === undefined) return undefined;
248
287
 
249
288
  return { context, output };
250
289
  }
251
290
 
291
+ /**
292
+ * Get full model info from models.dev for a specific provider and model.
293
+ * Returns cost, limits, name, etc. for use in scoring/display.
294
+ */
295
+ export function getModelInfo(provider: string, model: string): ModelsDevModel | undefined {
296
+ const resolved = resolveModelData(provider, model);
297
+ if (!resolved) return undefined;
298
+
299
+ const { modelId, data } = resolved;
300
+ return {
301
+ id: data.id ?? modelId,
302
+ name: data.name ?? modelId,
303
+ cost: data.cost,
304
+ limit: data.limit,
305
+ last_updated: data.last_updated,
306
+ };
307
+ }
308
+
252
309
  /**
253
310
  * Get just the context window for a model
254
- * Convenience function for backwards compatibility with context-window-cache
255
311
  */
256
312
  export function getContextWindowFromModelsdev(provider: string, model: string): number | undefined {
257
313
  const limits = getModelLimits(provider, model);
258
314
  return limits?.context;
259
315
  }
260
316
 
317
+ /**
318
+ * Get all models for a provider, sorted by last_updated descending
319
+ */
320
+ export function getProviderModels(provider: string): ModelsDevModel[] {
321
+ if (!cache) return [];
322
+
323
+ const modelsDevProvider = PROVIDER_MAPPING[provider];
324
+ if (modelsDevProvider === null || modelsDevProvider === undefined) return [];
325
+
326
+ const providerData = cache[modelsDevProvider];
327
+ if (!providerData?.models) return [];
328
+
329
+ return Object.entries(providerData.models)
330
+ .map(([modelId, data]) => ({
331
+ id: data.id ?? modelId,
332
+ name: data.name ?? modelId,
333
+ cost: data.cost,
334
+ limit: data.limit,
335
+ last_updated: data.last_updated,
336
+ }))
337
+ .sort((a, b) => (b.last_updated ?? "").localeCompare(a.last_updated ?? ""));
338
+ }
339
+
261
340
  /**
262
341
  * Clear in-memory cache (for testing)
263
342
  */
@@ -0,0 +1,284 @@
1
+ import {
2
+ createPrompt,
3
+ useState,
4
+ useKeypress,
5
+ usePrefix,
6
+ useMemo,
7
+ isUpKey,
8
+ isDownKey,
9
+ isSpaceKey,
10
+ isEnterKey,
11
+ makeTheme,
12
+ type Theme,
13
+ type KeypressEvent,
14
+ } from "@inquirer/core";
15
+ import type { PartialDeep } from "@inquirer/type";
16
+ import { cursorHide } from "@inquirer/ansi";
17
+ import chalk from "chalk";
18
+ import type { ProviderCredentials } from "@/services/config/types";
19
+ import { PROVIDER_IDS } from "@/llm/providers/provider-ids";
20
+ import { ProviderConfigUI } from "@/llm/utils/ProviderConfigUI";
21
+ import * as display from "@/commands/setup/display";
22
+
23
+ // --- Public types ---
24
+
25
+ export type PromptState = {
26
+ providers: Record<string, ProviderCredentials>;
27
+ stash: Record<string, ProviderCredentials>;
28
+ active: number;
29
+ mode: "browse" | "keys";
30
+ keysTarget: string | null;
31
+ keysActive: number;
32
+ };
33
+
34
+ export type PromptResult =
35
+ | { action: "done"; providers: Record<string, ProviderCredentials> }
36
+ | { action: "add-key"; providerId: string; returnTo: "browse" | "keys"; state: PromptState };
37
+
38
+ export type ProviderSelectConfig = {
39
+ message: string;
40
+ providerIds: string[];
41
+ initialProviders: Record<string, ProviderCredentials>;
42
+ providerHints?: Record<string, string>;
43
+ resumeState?: PromptState;
44
+ theme?: PartialDeep<Theme>;
45
+ };
46
+
47
+ // --- Helpers ---
48
+
49
+ type Mode = "browse" | "keys";
50
+
51
+ export function getKeys(apiKey: string | string[] | undefined): string[] {
52
+ if (!apiKey) return [];
53
+ if (Array.isArray(apiKey)) return apiKey.filter((k) => k.length > 0);
54
+ return apiKey.length > 0 && apiKey !== "none" ? [apiKey] : [];
55
+ }
56
+
57
+ function needsApiKey(providerId: string): boolean {
58
+ return providerId !== PROVIDER_IDS.CODEX_APP_SERVER;
59
+ }
60
+
61
+ export function isOllama(providerId: string): boolean {
62
+ return providerId === PROVIDER_IDS.OLLAMA;
63
+ }
64
+
65
+ function formatKeyInfo(apiKey: string | string[] | undefined): string {
66
+ const count = getKeys(apiKey).length;
67
+ if (count === 0) return "";
68
+ return chalk.gray(` [${count} key${count !== 1 ? "s" : ""}]`);
69
+ }
70
+
71
+ function maskKey(providerId: string, key: string): string {
72
+ if (isOllama(providerId)) return key;
73
+ if (key.length <= 4) return "*".repeat(key.length);
74
+ return "*".repeat(key.length - 4) + key.slice(-4);
75
+ }
76
+
77
+ const CURSOR = chalk.hex("#FFC107")("›");
78
+ const RULE_WIDTH = 30;
79
+
80
+ // --- Prompt ---
81
+
82
+ export default createPrompt<PromptResult, ProviderSelectConfig>((config, done) => {
83
+ const { providerIds, message, resumeState } = config;
84
+ const theme = makeTheme(config.theme);
85
+ const prefix = usePrefix({ status: "idle", theme });
86
+ const doneIndex = providerIds.length;
87
+
88
+ const [active, setActive] = useState(resumeState?.active ?? 0);
89
+ const [providers, setProviders] = useState<Record<string, ProviderCredentials>>(
90
+ () => resumeState?.providers ?? { ...config.initialProviders },
91
+ );
92
+ const [stash, setStash] = useState<Record<string, ProviderCredentials>>(
93
+ () => resumeState?.stash ?? {},
94
+ );
95
+ const [mode, setMode] = useState<Mode>(resumeState?.mode ?? "browse");
96
+ const [keysTarget, setKeysTarget] = useState<string | null>(resumeState?.keysTarget ?? null);
97
+ const [keysActive, setKeysActive] = useState(resumeState?.keysActive ?? 0);
98
+
99
+ const activeProviderId = useMemo(
100
+ () => (active < providerIds.length ? providerIds[active] : null),
101
+ [active],
102
+ );
103
+
104
+ function currentState(): PromptState {
105
+ return { providers, stash, active, mode, keysTarget, keysActive };
106
+ }
107
+
108
+ function requestAddKey(providerId: string, returnTo: "browse" | "keys") {
109
+ done({ action: "add-key", providerId, returnTo, state: currentState() });
110
+ }
111
+
112
+ // --- Keypress handlers ---
113
+
114
+ useKeypress((key, rl) => {
115
+ rl.clearLine(0);
116
+ if (mode === "browse") {
117
+ handleBrowse(key);
118
+ } else {
119
+ handleKeys(key);
120
+ }
121
+ });
122
+
123
+ function handleBrowse(key: KeypressEvent) {
124
+ if (isUpKey(key)) {
125
+ setActive(Math.max(0, active - 1));
126
+ } else if (isDownKey(key)) {
127
+ setActive(Math.min(doneIndex, active + 1));
128
+ } else if (isSpaceKey(key) && activeProviderId) {
129
+ toggleProvider(activeProviderId);
130
+ } else if (isEnterKey(key)) {
131
+ if (active === doneIndex) {
132
+ done({ action: "done", providers });
133
+ } else if (activeProviderId && activeProviderId in providers && needsApiKey(activeProviderId)) {
134
+ enterKeysMode(activeProviderId);
135
+ }
136
+ }
137
+ }
138
+
139
+ function toggleProvider(pid: string) {
140
+ const enabled = pid in providers;
141
+ if (enabled) {
142
+ const updated = { ...providers };
143
+ const newStash = { ...stash };
144
+ newStash[pid] = updated[pid]!;
145
+ delete updated[pid];
146
+ setProviders(updated);
147
+ setStash(newStash);
148
+ } else if (!needsApiKey(pid)) {
149
+ setProviders({ ...providers, [pid]: { apiKey: "none" } });
150
+ } else if (stash[pid]) {
151
+ const newStash = { ...stash };
152
+ const restored = newStash[pid]!;
153
+ delete newStash[pid];
154
+ setProviders({ ...providers, [pid]: restored });
155
+ setStash(newStash);
156
+ } else {
157
+ requestAddKey(pid, "browse");
158
+ }
159
+ }
160
+
161
+ function enterKeysMode(pid: string) {
162
+ setMode("keys");
163
+ setKeysTarget(pid);
164
+ setKeysActive(0);
165
+ }
166
+
167
+ function exitKeysMode() {
168
+ setMode("browse");
169
+ setKeysTarget(null);
170
+ setKeysActive(0);
171
+ }
172
+
173
+ function handleKeys(key: KeypressEvent) {
174
+ if (!keysTarget) return;
175
+
176
+ const keys = getKeys(providers[keysTarget]?.apiKey);
177
+ const addIndex = keys.length;
178
+ const backIndex = keys.length + 1;
179
+
180
+ if (isUpKey(key)) {
181
+ setKeysActive(Math.max(0, keysActive - 1));
182
+ } else if (isDownKey(key)) {
183
+ setKeysActive(Math.min(backIndex, keysActive + 1));
184
+ } else if (key.name === "d" && keysActive < keys.length) {
185
+ deleteKey(keysTarget, keysActive, keys);
186
+ } else if (isEnterKey(key)) {
187
+ if (keysActive === addIndex) {
188
+ requestAddKey(keysTarget, "keys");
189
+ } else if (keysActive === backIndex) {
190
+ exitKeysMode();
191
+ }
192
+ } else if (key.name === "escape") {
193
+ exitKeysMode();
194
+ }
195
+ }
196
+
197
+ function deleteKey(pid: string, index: number, keys: string[]) {
198
+ const remaining = keys.filter((_, i) => i !== index);
199
+ if (remaining.length === 0) {
200
+ const updated = { ...providers };
201
+ delete updated[pid];
202
+ setProviders(updated);
203
+ exitKeysMode();
204
+ } else {
205
+ setProviders({
206
+ ...providers,
207
+ [pid]: { ...providers[pid], apiKey: remaining.length === 1 ? remaining[0]! : remaining },
208
+ });
209
+ setKeysActive(Math.min(keysActive, remaining.length - 1));
210
+ }
211
+ }
212
+
213
+ // --- Rendering ---
214
+
215
+ const styledMessage = theme.style.message(message, "idle");
216
+ const lines: string[] = [`${prefix} ${styledMessage}`];
217
+
218
+ if (mode === "keys" && keysTarget) {
219
+ renderKeysView(lines, keysTarget);
220
+ } else {
221
+ renderBrowseView(lines);
222
+ }
223
+
224
+ return `${lines.join("\n")}${cursorHide}`;
225
+
226
+ function renderBrowseView(out: string[]) {
227
+ for (let i = 0; i < providerIds.length; i++) {
228
+ const pid = providerIds[i]!;
229
+ const name = ProviderConfigUI.getProviderDisplayName(pid);
230
+ const pfx = i === active ? `${CURSOR} ` : " ";
231
+ const enabled = pid in providers;
232
+ const hint = config.providerHints?.[pid];
233
+
234
+ if (enabled) {
235
+ const keyInfo = formatKeyInfo(providers[pid]?.apiKey);
236
+ out.push(`${pfx}${display.providerCheck(name)}${keyInfo}`);
237
+ } else {
238
+ const hintSuffix = hint ? chalk.dim(` — ${hint}`) : "";
239
+ out.push(`${pfx}${display.providerUncheck(name)}${hintSuffix}`);
240
+ }
241
+ }
242
+
243
+ const donePfx = active === doneIndex ? `${CURSOR} ` : " ";
244
+ out.push(`${donePfx}${display.doneLabel()}`);
245
+
246
+ const help = [
247
+ `${chalk.bold("↑↓")} ${chalk.dim("navigate")}`,
248
+ `${chalk.bold("space")} ${chalk.dim("toggle")}`,
249
+ `${chalk.bold("⏎")} ${chalk.dim("manage keys / done")}`,
250
+ ];
251
+ out.push(chalk.dim(` ${help.join(chalk.dim(" • "))}`));
252
+ }
253
+
254
+ function renderKeysView(out: string[], pid: string) {
255
+ const name = ProviderConfigUI.getProviderDisplayName(pid);
256
+ const keys = getKeys(providers[pid]?.apiKey);
257
+ const addIndex = keys.length;
258
+ const backIndex = keys.length + 1;
259
+
260
+ out.push(` ${chalk.bold(name)} ${chalk.dim("— API Keys")}`);
261
+ out.push(` ${chalk.dim("─".repeat(RULE_WIDTH))}`);
262
+
263
+ for (let i = 0; i < keys.length; i++) {
264
+ const pfx = keysActive === i ? `${CURSOR} ` : " ";
265
+ const masked = maskKey(pid, keys[i]!);
266
+ const deleteHint = keysActive === i ? chalk.dim(" d delete") : "";
267
+ out.push(`${pfx}${masked}${deleteHint}`);
268
+ }
269
+
270
+ const addPfx = keysActive === addIndex ? `${CURSOR} ` : " ";
271
+ out.push(`${addPfx}${chalk.dim("+ Add another key")}`);
272
+
273
+ const backPfx = keysActive === backIndex ? `${CURSOR} ` : " ";
274
+ out.push(`${backPfx}${chalk.dim("← Back")}`);
275
+
276
+ const help = [
277
+ `${chalk.bold("↑↓")} ${chalk.dim("navigate")}`,
278
+ `${chalk.bold("d")} ${chalk.dim("delete key")}`,
279
+ `${chalk.bold("⏎")} ${chalk.dim("select")}`,
280
+ `${chalk.bold("esc")} ${chalk.dim("back")}`,
281
+ ];
282
+ out.push(chalk.dim(` ${help.join(chalk.dim(" • "))}`));
283
+ }
284
+ });