@p-sw/brainbox 0.1.0

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 (92) hide show
  1. package/README.md +138 -0
  2. package/package.json +41 -0
  3. package/prompts/daily_schedule.md +55 -0
  4. package/prompts/memoir.md +26 -0
  5. package/prompts/monthly_schedule.md +53 -0
  6. package/prompts/objectifier.md +14 -0
  7. package/prompts/persona_base_system_prompt.md +173 -0
  8. package/prompts/persona_base_system_prompt_fixed.md +47 -0
  9. package/prompts/persona_init.md +107 -0
  10. package/prompts/schedule_availability.md +77 -0
  11. package/prompts/send_message.md +37 -0
  12. package/prompts/start_conversation.md +55 -0
  13. package/scripts/smoke_providers.ts +176 -0
  14. package/src/brain/index.ts +936 -0
  15. package/src/brain/manager.ts +144 -0
  16. package/src/brain/memory.ts +99 -0
  17. package/src/brain/messageHistory.ts +26 -0
  18. package/src/brain/schedule.ts +11 -0
  19. package/src/brain/types.ts +26 -0
  20. package/src/channel/base.ts +527 -0
  21. package/src/channel/discord.ts +227 -0
  22. package/src/channel/telegram.ts +150 -0
  23. package/src/commands/auth.tsx +306 -0
  24. package/src/commands/brain.ts +119 -0
  25. package/src/commands/daemon/commands.ts +56 -0
  26. package/src/commands/daemon/pairingCommand.ts +15 -0
  27. package/src/commands/daemon/restartCommand.ts +17 -0
  28. package/src/commands/daemon.ts +168 -0
  29. package/src/commands/index.ts +16 -0
  30. package/src/commands/model.tsx +138 -0
  31. package/src/commands/onboard.tsx +473 -0
  32. package/src/commands/pairing.ts +32 -0
  33. package/src/commands/restart.ts +24 -0
  34. package/src/config/file/auth.ts +105 -0
  35. package/src/config/file/root.ts +37 -0
  36. package/src/config/index.ts +21 -0
  37. package/src/config/loader.ts +115 -0
  38. package/src/index.ts +61 -0
  39. package/src/provider/index.ts +122 -0
  40. package/src/provider/llm.ts +191 -0
  41. package/src/provider/promptLoader.ts +36 -0
  42. package/src/provider/providers/302ai.ts +17 -0
  43. package/src/provider/providers/MiniMax.ts +17 -0
  44. package/src/provider/providers/anthropic.ts +257 -0
  45. package/src/provider/providers/azure_cognitive.ts +40 -0
  46. package/src/provider/providers/azure_openai.ts +49 -0
  47. package/src/provider/providers/baseten.ts +17 -0
  48. package/src/provider/providers/bedrock.ts +312 -0
  49. package/src/provider/providers/cerebras.ts +17 -0
  50. package/src/provider/providers/cloudflare_gateway.ts +34 -0
  51. package/src/provider/providers/cloudflare_workers.ts +178 -0
  52. package/src/provider/providers/copilot.ts +22 -0
  53. package/src/provider/providers/cortecs.ts +17 -0
  54. package/src/provider/providers/deepinfra.ts +17 -0
  55. package/src/provider/providers/deepseek.ts +17 -0
  56. package/src/provider/providers/digitalocean.ts +17 -0
  57. package/src/provider/providers/fireworks.ts +17 -0
  58. package/src/provider/providers/gitlab_duo.ts +184 -0
  59. package/src/provider/providers/gmi.ts +17 -0
  60. package/src/provider/providers/groq.ts +17 -0
  61. package/src/provider/providers/helicone.ts +17 -0
  62. package/src/provider/providers/huggingface.ts +17 -0
  63. package/src/provider/providers/ionet.ts +17 -0
  64. package/src/provider/providers/llamacpp.ts +17 -0
  65. package/src/provider/providers/llmgateway.ts +17 -0
  66. package/src/provider/providers/lmstudio.ts +17 -0
  67. package/src/provider/providers/mistral.ts +17 -0
  68. package/src/provider/providers/moonshot.ts +17 -0
  69. package/src/provider/providers/nebius.ts +17 -0
  70. package/src/provider/providers/nvidia.ts +17 -0
  71. package/src/provider/providers/ollama.ts +17 -0
  72. package/src/provider/providers/ollama_cloud.ts +17 -0
  73. package/src/provider/providers/openai.ts +17 -0
  74. package/src/provider/providers/openai_compatible.ts +293 -0
  75. package/src/provider/providers/openrouter.ts +175 -0
  76. package/src/provider/providers/ovhcloud.ts +17 -0
  77. package/src/provider/providers/sap_aicore.ts +22 -0
  78. package/src/provider/providers/scaleway.ts +17 -0
  79. package/src/provider/providers/snowflake_cortex.ts +207 -0
  80. package/src/provider/providers/stackit.ts +17 -0
  81. package/src/provider/providers/together.ts +17 -0
  82. package/src/provider/providers/venice.ts +17 -0
  83. package/src/provider/providers/vercel.ts +17 -0
  84. package/src/provider/providers/vertex.ts +229 -0
  85. package/src/provider/providers/xai.ts +17 -0
  86. package/src/provider/providers/zai.ts +17 -0
  87. package/src/provider/providers/zenmux.ts +17 -0
  88. package/src/provider/schema.ts +143 -0
  89. package/src/ui/TextInput.tsx +114 -0
  90. package/src/utils/daemonClient.ts +85 -0
  91. package/src/utils/logger.ts +204 -0
  92. package/tsconfig.json +34 -0
@@ -0,0 +1,227 @@
1
+ import {
2
+ Client,
3
+ Events,
4
+ GatewayIntentBits,
5
+ type SendableChannels,
6
+ } from "discord.js";
7
+ import type { AvailabilityStatus } from "@/provider/schema";
8
+ import { logger } from "@/utils/logger";
9
+ import { BaseChannel, type PairingEntry, type PairingInbound } from "./base";
10
+ import type { BrainItemDiscord } from "@/brain/manager";
11
+ import type { Brain } from "@/brain";
12
+ import type { MessageHistoryEntry } from "@/brain/messageHistory";
13
+
14
+ const HISTORY_CAP = 1000;
15
+ const AVAILABILITY_STATUS_MAP: Record<
16
+ AvailabilityStatus,
17
+ "online" | "dnd" | "invisible"
18
+ > = {
19
+ online: "online",
20
+ "do-not-disturb": "dnd",
21
+ offline: "invisible",
22
+ };
23
+
24
+ export class DiscordChannel extends BaseChannel<BrainItemDiscord> {
25
+ private client?: Client;
26
+ private targetChannel?: SendableChannels;
27
+ private history: MessageHistoryEntry[] = [];
28
+
29
+ constructor(brain: Brain<BrainItemDiscord>) {
30
+ super(brain);
31
+ }
32
+
33
+ async init(): Promise<void> {
34
+ this.client = new Client({
35
+ intents: [
36
+ GatewayIntentBits.Guilds,
37
+ GatewayIntentBits.GuildMessages,
38
+ GatewayIntentBits.DirectMessages,
39
+ GatewayIntentBits.MessageContent, // ponytail: privileged intent — required for msg.content; disable if unavailable, text will be empty
40
+ ],
41
+ });
42
+ if (this.brain.brainbase.discord.channelId) {
43
+ this.isReady = true;
44
+ logger.debug(
45
+ `DiscordChannel.init: pre-bound channelId=${this.brain.brainbase.discord.channelId}`,
46
+ );
47
+ } else {
48
+ this.engagePairing();
49
+ logger.debug(`DiscordChannel.init: entering pairing mode`);
50
+ }
51
+ this.registerActive();
52
+ this.client.once(Events.ClientReady, (c) => {
53
+ logger.success(`Discord ready as ${c.user.tag}`);
54
+ const channelId = this.brain.brainbase.discord.channelId;
55
+ if (channelId && !this.targetChannel) {
56
+ logger.debug(`DiscordClientReady: resolving configured channel ${channelId}`);
57
+ void this.resolveConfiguredChannel(channelId);
58
+ }
59
+ });
60
+ this.client.on(Events.MessageCreate, (msg) => {
61
+ if (msg.author.bot) return;
62
+ const content = msg.content;
63
+ if (!content) return;
64
+ const channelId = this.brain.brainbase.discord.channelId;
65
+ if (channelId !== undefined && msg.channelId !== channelId) {
66
+ logger.debug(
67
+ `MessageCreate: ignoring from channel=${msg.channelId} (not bound)`,
68
+ );
69
+ return;
70
+ }
71
+ const inbound: PairingInbound = {
72
+ content,
73
+ time: msg.createdAt,
74
+ replyTo: msg.id,
75
+ channelId: msg.channelId,
76
+ };
77
+ if (channelId === undefined) {
78
+ logger.debug(`MessageCreate: routing to pairing (no channelId bound)`);
79
+ void this.onPairing(inbound);
80
+ return;
81
+ }
82
+ const entry: MessageHistoryEntry = {
83
+ sender: "user",
84
+ time: msg.createdAt,
85
+ content,
86
+ };
87
+ this.pushHistory(entry);
88
+ logger.debug(
89
+ `MessageCreate: stored in history, dispatching (channel=${msg.channelId})`,
90
+ );
91
+ void this.onMessage(entry);
92
+ });
93
+ logger.debug(`DiscordChannel.init: logging in`);
94
+ await this.client.login(this.brain.brainbase.discord.token);
95
+ }
96
+
97
+ protected async sendPairingReply(
98
+ text: string,
99
+ inbound: PairingInbound,
100
+ ): Promise<void> {
101
+ if (!this.client || inbound.channelId === undefined) {
102
+ logger.debug(`sendPairingReply: no client or channelId, skip`);
103
+ return;
104
+ }
105
+ const channel = await this.client.channels.fetch(inbound.channelId);
106
+ if (!channel || !channel.isSendable()) {
107
+ logger.debug(
108
+ `sendPairingReply: channel ${inbound.channelId} not sendable`,
109
+ );
110
+ return;
111
+ }
112
+ logger.debug(`sendPairingReply: posting to ${inbound.channelId}`);
113
+ await channel.send({
114
+ content: text,
115
+ ...(inbound.replyTo
116
+ ? { reply: { messageReference: inbound.replyTo } }
117
+ : {}),
118
+ });
119
+ }
120
+
121
+ protected override async completePairing(entry: PairingEntry): Promise<void> {
122
+ if (entry.channelId !== undefined) {
123
+ this.brain.brainbase.discord.channelId = entry.channelId;
124
+ await this.brain.persistBrainBase();
125
+ this.targetChannel = undefined;
126
+ if (this.client) {
127
+ const channel = await this.client.channels.fetch(entry.channelId);
128
+ if (channel && channel.isSendable()) {
129
+ this.targetChannel = channel;
130
+ }
131
+ }
132
+ logger.success(
133
+ `Discord channel bound: ${this.brain.brainbase.displayName} → ${entry.channelId}`,
134
+ );
135
+ }
136
+ await super.completePairing(entry);
137
+ }
138
+
139
+ async send(text: string, opts?: { replyTo?: string }): Promise<void> {
140
+ const channel = await this.resolveSendChannel();
141
+ if (!channel) {
142
+ throw new Error(
143
+ "DiscordChannel.send: no channel yet (no inbound message)",
144
+ );
145
+ }
146
+ logger.debug(
147
+ `send: posting ${text.length} chars${opts?.replyTo ? ` (reply to ${opts.replyTo})` : ""}`,
148
+ );
149
+ if (opts?.replyTo) {
150
+ await channel.send({
151
+ content: text,
152
+ reply: { messageReference: opts.replyTo },
153
+ });
154
+ } else {
155
+ await channel.send(text);
156
+ }
157
+ }
158
+
159
+ async setAvailability(status: AvailabilityStatus): Promise<void> {
160
+ if (!this.client?.user) {
161
+ logger.debug(`setAvailability: no client/user, skip`);
162
+ return;
163
+ }
164
+ const mapped = AVAILABILITY_STATUS_MAP[status];
165
+ logger.debug(`setAvailability: ${status} → ${mapped}`);
166
+ this.client.user.setStatus(mapped);
167
+ }
168
+
169
+ async getMessageHistoryBetween(
170
+ start: Date,
171
+ end: Date,
172
+ ): Promise<ReadonlyArray<MessageHistoryEntry>> {
173
+ return this.history.filter((m) => m.time >= start && m.time <= end);
174
+ }
175
+
176
+ private pushHistory(entry: MessageHistoryEntry): void {
177
+ this.history.push(entry);
178
+ if (this.history.length > HISTORY_CAP) this.history.shift();
179
+ }
180
+
181
+ private async resolveSendChannel(): Promise<SendableChannels | undefined> {
182
+ if (this.targetChannel) {
183
+ logger.debug(`resolveSendChannel: cache hit`);
184
+ return this.targetChannel;
185
+ }
186
+ if (!this.client?.isReady()) {
187
+ logger.debug(`resolveSendChannel: client not ready, returning undefined`);
188
+ return undefined;
189
+ }
190
+ const channelId = this.brain.brainbase.discord.channelId;
191
+ if (!channelId) {
192
+ logger.debug(`resolveSendChannel: no channelId bound`);
193
+ return undefined;
194
+ }
195
+ logger.debug(`resolveSendChannel: fetching ${channelId}`);
196
+ const channel = await this.client.channels.fetch(channelId);
197
+ if (channel && channel.isSendable()) {
198
+ this.targetChannel = channel;
199
+ logger.debug(`resolveSendChannel: cached`);
200
+ } else {
201
+ logger.debug(`resolveSendChannel: ${channelId} not sendable`);
202
+ }
203
+ return this.targetChannel;
204
+ }
205
+
206
+ private async resolveConfiguredChannel(channelId: string): Promise<void> {
207
+ if (!this.client) {
208
+ logger.debug(`resolveConfiguredChannel: no client`);
209
+ return;
210
+ }
211
+ logger.debug(`resolveConfiguredChannel: fetching ${channelId}`);
212
+ const channel = await this.client.channels.fetch(channelId);
213
+ if (channel && channel.isSendable()) {
214
+ this.targetChannel = channel;
215
+ logger.debug(`resolveConfiguredChannel: cached`);
216
+ } else {
217
+ logger.debug(`resolveConfiguredChannel: ${channelId} not sendable`);
218
+ }
219
+ }
220
+
221
+ protected async teardownClient(): Promise<void> {
222
+ logger.debug(`teardownClient: destroying discord client`);
223
+ this.client?.destroy();
224
+ this.client = undefined;
225
+ this.targetChannel = undefined;
226
+ }
227
+ }
@@ -0,0 +1,150 @@
1
+ import { Bot } from "gramio";
2
+ import type { AvailabilityStatus } from "@/provider/schema";
3
+ import { logger } from "@/utils/logger";
4
+ import { BaseChannel, type PairingInbound, type PairingEntry } from "./base";
5
+ import type { BrainItemTelegram } from "@/brain/manager";
6
+ import type { Brain } from "@/brain";
7
+ import type { MessageHistoryEntry } from "@/brain/messageHistory";
8
+
9
+ const HISTORY_CAP = 1000;
10
+
11
+ export class TelegramChannel extends BaseChannel<BrainItemTelegram> {
12
+ private bot?: Bot;
13
+ private chatId?: number;
14
+ private history: MessageHistoryEntry[] = [];
15
+
16
+ constructor(brain: Brain<BrainItemTelegram>) {
17
+ super(brain);
18
+ }
19
+
20
+ async init(): Promise<void> {
21
+ this.bot = new Bot(this.brain.brainbase.telegram.token);
22
+ this.chatId = this.brain.brainbase.telegram.chatId;
23
+ if (this.chatId !== undefined) {
24
+ this.isReady = true;
25
+ logger.debug(
26
+ `TelegramChannel.init: pre-bound chatId=${this.chatId}`,
27
+ );
28
+ } else {
29
+ this.engagePairing();
30
+ logger.debug(`TelegramChannel.init: entering pairing mode`);
31
+ }
32
+ this.registerActive();
33
+ this.bot.onStart(({ info }) => {
34
+ logger.success(`Telegram ready as @${info.username}`);
35
+ });
36
+ this.bot.on("message", (ctx) => {
37
+ if (ctx.from?.isBot()) return;
38
+ const text = ctx.text;
39
+ if (!text) return;
40
+ const chatId = this.brain.brainbase.telegram.chatId;
41
+ if (chatId !== undefined && ctx.chat.id !== chatId) {
42
+ logger.debug(
43
+ `Telegram message: ignoring chat=${ctx.chat.id} (not bound to ${chatId})`,
44
+ );
45
+ return;
46
+ }
47
+ const inbound: PairingInbound = {
48
+ content: text,
49
+ time: new Date(ctx.createdAt * 1000),
50
+ replyTo: String(ctx.id),
51
+ chatId: ctx.chat.id,
52
+ };
53
+ if (chatId === undefined) {
54
+ logger.debug(
55
+ `Telegram message: routing to pairing (no chatId bound)`,
56
+ );
57
+ void this.onPairing(inbound);
58
+ return;
59
+ }
60
+ this.chatId = ctx.chat.id;
61
+ const entry: MessageHistoryEntry = {
62
+ sender: "user",
63
+ time: inbound.time,
64
+ content: text,
65
+ };
66
+ this.pushHistory(entry);
67
+ logger.debug(
68
+ `Telegram message: stored in history, dispatching (chat=${ctx.chat.id})`,
69
+ );
70
+ void this.onMessage(entry);
71
+ });
72
+ logger.debug(`TelegramChannel.init: starting bot`);
73
+ await this.bot.start();
74
+ }
75
+
76
+ protected async sendPairingReply(
77
+ text: string,
78
+ inbound: PairingInbound,
79
+ ): Promise<void> {
80
+ if (!this.bot || inbound.chatId === undefined) {
81
+ logger.debug(`sendPairingReply: no bot or chatId, skip`);
82
+ return;
83
+ }
84
+ logger.debug(`sendPairingReply: posting to ${inbound.chatId}`);
85
+ await this.bot.api.sendMessage({
86
+ chat_id: inbound.chatId,
87
+ text,
88
+ ...(inbound.replyTo
89
+ ? { reply_parameters: { message_id: Number(inbound.replyTo) } }
90
+ : {}),
91
+ });
92
+ }
93
+
94
+ protected override async completePairing(entry: PairingEntry): Promise<void> {
95
+ if (entry.chatId !== undefined) {
96
+ this.brain.brainbase.telegram.chatId = entry.chatId;
97
+ this.chatId = entry.chatId;
98
+ await this.brain.persistBrainBase();
99
+ logger.success(
100
+ `Telegram chat bound: ${this.brain.brainbase.displayName} → ${entry.chatId}`,
101
+ );
102
+ }
103
+ await super.completePairing(entry);
104
+ }
105
+
106
+ async send(text: string, opts?: { replyTo?: string }): Promise<void> {
107
+ if (!this.bot || this.chatId === undefined) {
108
+ throw new Error("TelegramChannel.send: no chat yet (no inbound message)");
109
+ }
110
+ logger.debug(
111
+ `send: posting ${text.length} chars${opts?.replyTo ? ` (reply to ${opts.replyTo})` : ""}`,
112
+ );
113
+ await this.bot.api.sendMessage({
114
+ chat_id: this.chatId,
115
+ text,
116
+ ...(opts?.replyTo
117
+ ? { reply_parameters: { message_id: Number(opts.replyTo) } }
118
+ : {}),
119
+ });
120
+ }
121
+
122
+ async setAvailability(_status: AvailabilityStatus): Promise<void> {
123
+ logger.debug(
124
+ `setAvailability: ${_status} (no-op, Telegram has no bot presence)`,
125
+ );
126
+ // ponytail: Telegram Bot API exposes no bot presence concept — no-op.
127
+ }
128
+
129
+ async getMessageHistoryBetween(
130
+ start: Date,
131
+ end: Date,
132
+ ): Promise<ReadonlyArray<MessageHistoryEntry>> {
133
+ return this.history.filter((m) => m.time >= start && m.time <= end);
134
+ }
135
+
136
+ private pushHistory(entry: MessageHistoryEntry): void {
137
+ this.history.push(entry);
138
+ if (this.history.length > HISTORY_CAP) this.history.shift();
139
+ }
140
+
141
+ protected async teardownClient(): Promise<void> {
142
+ if (!this.bot) {
143
+ logger.debug(`teardownClient: no bot, nothing to stop`);
144
+ return;
145
+ }
146
+ logger.debug(`teardownClient: stopping telegram bot`);
147
+ await this.bot.stop(1000);
148
+ this.bot = undefined;
149
+ }
150
+ }
@@ -0,0 +1,306 @@
1
+ import { useState } from "react";
2
+ import { Box, Text, render } from "ink";
3
+ import type { Command } from "commander";
4
+ import { logger } from "@/utils/logger";
5
+ import { TextInput } from "@/ui/TextInput";
6
+ import { listProviderNames } from "@/provider/llm";
7
+ import {
8
+ PROVIDER_EXTRA_FIELDS,
9
+ readAuthFile,
10
+ removeProviderAuth,
11
+ setProviderAuth,
12
+ } from "@/config/file/auth";
13
+ import { registerCommand } from "@/commands";
14
+
15
+ type Stage =
16
+ | { kind: "provider" }
17
+ | { kind: "apiKey"; provider: string }
18
+ | {
19
+ kind: "extras";
20
+ provider: string;
21
+ fields: string[];
22
+ values: Record<string, string>;
23
+ };
24
+
25
+ function maskKey(key: string): string {
26
+ if (key.length <= 8) return "****";
27
+ return `${key.slice(0, 4)}****${key.slice(-4)}`;
28
+ }
29
+
30
+ function ProviderPicker({
31
+ providers,
32
+ onSelect,
33
+ }: {
34
+ providers: string[];
35
+ onSelect: (provider: string) => void;
36
+ }): React.ReactElement {
37
+ return (
38
+ <Box flexDirection="column">
39
+ <TextInput
40
+ prompt="provider: "
41
+ onSubmit={(v) => onSelect(v.trim())}
42
+ />
43
+ <Text dimColor>
44
+ matches ({providers.length}/{providers.length}):
45
+ </Text>
46
+ {providers.slice(0, 8).map((p) => (
47
+ <Text key={p}> - {p}</Text>
48
+ ))}
49
+ {providers.length > 8 && <Text dimColor> ... and more</Text>}
50
+ </Box>
51
+ );
52
+ }
53
+
54
+ function AddApp({
55
+ providers,
56
+ initialProvider,
57
+ onDone,
58
+ }: {
59
+ providers: string[];
60
+ initialProvider?: string;
61
+ onDone: () => void;
62
+ }): React.ReactElement {
63
+ const [stage, setStage] = useState<Stage>(
64
+ initialProvider
65
+ ? { kind: "apiKey", provider: initialProvider }
66
+ : { kind: "provider" },
67
+ );
68
+ const [error, setError] = useState<string | null>(null);
69
+
70
+ if (stage.kind === "provider") {
71
+ return (
72
+ <Box flexDirection="column">
73
+ <Text>Select a provider to add:</Text>
74
+ <Text dimColor>(Enter an exact provider name; see list below)</Text>
75
+ <ProviderPicker
76
+ providers={providers}
77
+ onSelect={(p) => {
78
+ if (!p) {
79
+ setError("Provider name is required");
80
+ return;
81
+ }
82
+ if (!providers.includes(p)) {
83
+ setError(`Unknown provider "${p}"`);
84
+ return;
85
+ }
86
+ setError(null);
87
+ setStage({ kind: "apiKey", provider: p });
88
+ }}
89
+ />
90
+ {error && <Text color="red">{error}</Text>}
91
+ </Box>
92
+ );
93
+ }
94
+
95
+ if (stage.kind === "apiKey") {
96
+ return (
97
+ <Box flexDirection="column">
98
+ <Text>
99
+ Provider: <Text color="cyan">{stage.provider}</Text>
100
+ </Text>
101
+ <TextInput
102
+ prompt={`${stage.provider} apiKey: `}
103
+ onSubmit={(apiKey) => {
104
+ const trimmed = apiKey.trim();
105
+ if (!trimmed) {
106
+ setError("apiKey cannot be empty");
107
+ return;
108
+ }
109
+ const extras = PROVIDER_EXTRA_FIELDS[stage.provider] ?? [];
110
+ if (extras.length === 0) {
111
+ setProviderAuth(stage.provider, { apiKey: trimmed });
112
+ logger.success(`Saved ${stage.provider} to auth.yaml`);
113
+ onDone();
114
+ return;
115
+ }
116
+ setError(null);
117
+ setStage({
118
+ kind: "extras",
119
+ provider: stage.provider,
120
+ fields: extras,
121
+ values: { apiKey: trimmed },
122
+ });
123
+ }}
124
+ />
125
+ {error && <Text color="red">{error}</Text>}
126
+ </Box>
127
+ );
128
+ }
129
+
130
+ // stage.kind === "extras"
131
+ const nextField = stage.fields[0];
132
+ if (!nextField) {
133
+ setProviderAuth(stage.provider, stage.values);
134
+ logger.success(`Saved ${stage.provider} to auth.yaml`);
135
+ return <Text>Done.</Text>;
136
+ }
137
+ return (
138
+ <Box flexDirection="column">
139
+ <Text>
140
+ {stage.provider}: <Text color="cyan">{stage.values["apiKey"]}</Text>
141
+ </Text>
142
+ <Text>Optional fields remaining: {stage.fields.join(", ")}</Text>
143
+ <TextInput
144
+ prompt={`${nextField} (leave empty to skip): `}
145
+ onSubmit={(raw) => {
146
+ const value = raw.trim();
147
+ const remaining = stage.fields.slice(1);
148
+ const values: Record<string, string> = { ...stage.values };
149
+ if (value) values[nextField] = value;
150
+ setStage({
151
+ kind: "extras",
152
+ provider: stage.provider,
153
+ fields: remaining,
154
+ values,
155
+ });
156
+ }}
157
+ />
158
+ </Box>
159
+ );
160
+ }
161
+
162
+ function ListApp(): React.ReactElement {
163
+ const auth = readAuthFile();
164
+ const names = Object.keys(auth).sort();
165
+ if (names.length === 0) {
166
+ return (
167
+ <Text>
168
+ No providers configured. Run <Text color="cyan">brainbox auth add</Text>{" "}
169
+ to add one.
170
+ </Text>
171
+ );
172
+ }
173
+ return (
174
+ <Box flexDirection="column">
175
+ <Text>Configured providers ({names.length}):</Text>
176
+ {names.map((name) => {
177
+ const fields = auth[name] ?? {};
178
+ const fieldList = Object.entries(fields)
179
+ .map(
180
+ ([k, v]) =>
181
+ `${k}=${k === "apiKey" ? maskKey(String(v)) : String(v)}`,
182
+ )
183
+ .join(", ");
184
+ return (
185
+ <Text key={name}>
186
+ {" "}- <Text color="cyan">{name}</Text>: {fieldList}
187
+ </Text>
188
+ );
189
+ })}
190
+ </Box>
191
+ );
192
+ }
193
+
194
+ function RemoveApp({
195
+ providers,
196
+ onDone,
197
+ }: {
198
+ providers: string[];
199
+ onDone: () => void;
200
+ }): React.ReactElement {
201
+ const [picked, setPicked] = useState<string | null>(null);
202
+ if (!picked) {
203
+ return (
204
+ <Box flexDirection="column">
205
+ <Text>Select a provider to remove:</Text>
206
+ <ProviderPicker
207
+ providers={providers}
208
+ onSelect={(p) => {
209
+ if (!providers.includes(p)) {
210
+ logger.error(`Provider "${p}" is not configured.`);
211
+ process.exit(1);
212
+ }
213
+ setPicked(p);
214
+ }}
215
+ />
216
+ </Box>
217
+ );
218
+ }
219
+ return (
220
+ <Box flexDirection="column">
221
+ <Text>
222
+ Confirm remove <Text color="cyan">{picked}</Text> (yes/no):
223
+ </Text>
224
+ <TextInput
225
+ prompt="> "
226
+ onSubmit={(v) => {
227
+ if (v.trim().toLowerCase() !== "yes") {
228
+ logger.info("Cancelled.");
229
+ onDone();
230
+ return;
231
+ }
232
+ removeProviderAuth(picked);
233
+ logger.success(`Removed ${picked} from auth.yaml`);
234
+ onDone();
235
+ }}
236
+ />
237
+ </Box>
238
+ );
239
+ }
240
+
241
+ async function runAdd(providerArg?: string): Promise<void> {
242
+ const providers = listProviderNames().slice().sort();
243
+ if (providerArg && !providers.includes(providerArg)) {
244
+ logger.error(
245
+ `Unknown provider "${providerArg}". Registered: ${providers.join(", ")}`,
246
+ );
247
+ process.exit(1);
248
+ }
249
+ const app = render(
250
+ <AddApp
251
+ providers={providers}
252
+ initialProvider={providerArg}
253
+ onDone={() => app.unmount()}
254
+ />,
255
+ );
256
+ await app.waitUntilExit();
257
+ }
258
+
259
+ async function runList(): Promise<void> {
260
+ const app = render(<ListApp />);
261
+ // ponytail: list is read-only, no input needed — unmount on next tick.
262
+ setImmediate(() => app.unmount());
263
+ await app.waitUntilExit();
264
+ }
265
+
266
+ async function runRemove(): Promise<void> {
267
+ const auth = readAuthFile();
268
+ const configured = Object.keys(auth).sort();
269
+ if (configured.length === 0) {
270
+ logger.info("No providers configured. Nothing to remove.");
271
+ return;
272
+ }
273
+ const app = render(
274
+ <RemoveApp providers={configured} onDone={() => app.unmount()} />,
275
+ );
276
+ await app.waitUntilExit();
277
+ }
278
+
279
+ export function register(program: Command): Command {
280
+ return registerCommand(program, {
281
+ name: "auth",
282
+ description: "Manage provider authentication in auth.yaml",
283
+ configure: (cmd) => {
284
+ cmd
285
+ .command("add [provider]")
286
+ .description("Add provider authentication (interactive)")
287
+ .action(async (provider?: string) => {
288
+ await runAdd(provider);
289
+ });
290
+ cmd
291
+ .command("list")
292
+ .description("List configured providers")
293
+ .action(async () => {
294
+ await runList();
295
+ });
296
+ cmd
297
+ .command("remove")
298
+ .alias("rm")
299
+ .description("Remove provider authentication (interactive)")
300
+ .action(async () => {
301
+ await runRemove();
302
+ });
303
+ return cmd;
304
+ },
305
+ });
306
+ }