Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ TUI / HTTP client → Express (bytebell-server) → BullMQ (in-process) → Inge
- **Local persistence**: `~/.bytebell/` (config, logs)
- **LLM Provider**: OpenRouter (default) or local Ollama, selected via `Config.LlmProvider`
- **Logging**: Winston (file + stdout)
- **Secret storage**: plaintext in `~/.bytebell/config.json` (mode `0600`). OS-keychain integration is not implemented.
- **Secret storage**: secrets (`openrouter_api_key`, `neo4j_password`) live in the OS keychain via `@napi-rs/keyring` (`@bb/config`). `bytebell set <key> <value>` stores them there (its secret-key setters call `storeSecret`); `config.json` (mode `0600`) holds an empty string for keychain-backed secrets. On load, `loadConfig` cleanly migrates any plaintext secret it finds into the keychain and clears the field. A plaintext secret only persists when the OS has no keychain backend (e.g. headless Linux without Secret Service), and the server warns about it at boot.
- **Package manager**: Bun (workspaces)

---
Expand Down Expand Up @@ -156,7 +156,8 @@ The `~/.bytebell/` directory is the **single source of truth** for runtime confi
config.json server_port, mongo_uri, neo4j_uri/user/password,
redis_url, openrouter_api_key, openrouter_model,
concurrency.github, log_level, log_retention_days
(mode 0600; openrouter_api_key stored in plaintext)
(mode 0600; secrets like openrouter_api_key /
neo4j_password live in the OS keychain, empty here)
install_id UUID generated on first run (local-only, never transmitted)
repos/<knowledgeId>/ cloned source trees for every indexed repo
logs/
Expand All @@ -165,9 +166,9 @@ The `~/.bytebell/` directory is the **single source of truth** for runtime confi
pid running server PID
```

There is no OS-keychain integration; `openrouter_api_key` lives in plaintext in `config.json` (mode `0600`).
Secrets (`openrouter_api_key`, `neo4j_password`) are stored in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager) via `@napi-rs/keyring`; `config.json` (mode `0600`) keeps an empty string for them. On load, `@bb/config` migrates any plaintext secret it finds into the keychain and clears the field — keychain is authoritative thereafter. A plaintext secret only persists on systems with no keychain backend and triggers a boot warning.

- `bytebell set <key> <value>` is the only sanctioned write path to `config.json`. Manual edits work but are not advertised.
- `bytebell set <key> <value>` is the sanctioned write path for `config.json`. For secret keys (`openrouter-api-key`, `neo4j-password`) it routes the value to the OS keychain instead of writing plaintext. Manual edits work but are not advertised.

---

Expand Down
27 changes: 27 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 28 additions & 9 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,36 @@ infra/docker/docker-compose.yml up -d` for **only the services the

The package does **not** own:

- Any other subcommand (index, ls, clean, models, keys, cost, server,
- Any other subcommand (index, ls, clean, models, cost, server,
mcp, update) — all deferred per the catalog below.
- Live infra connection probes — the CLI cannot import `@bb/mongo` /
`@bb/redis` per the tier rule. Format-only validation in v0; future
`bytebell config doctor` will probe via a running server.
- The Ink dashboard (`bytebell` no-args) — needs the server's HTTP API
- activity feed.
- OpenRouter API key handling — own subcommand (`bytebell keys set`)
with `keytar` keychain backing.

### Secrets — handled by `bytebell set`, stored in the OS keychain

There is no separate secrets command. The secret-bearing keys
(`openrouter-api-key`, `neo4j-password`) are written by the **same `bytebell
set`** path as everything else; their `KEY_MAP` setters call
`@bb/config.storeSecret`, which stores the value in the OS keychain
(macOS Keychain / Linux Secret Service / Windows Credential Manager) and clears
any plaintext copy in `config.json`:

- `bytebell set openrouter-api-key <key>` / `bytebell set neo4j-password <pwd>` —
store in the keychain. If no keychain backend is available (e.g. headless
Linux without Secret Service, CI), it falls back to a plaintext write in
`config.json` and prints a warning.
- `bytebell set` (no args) / `bytebell setup` — the interactive forms use the
same setters (masked input), so secrets entered there also land in the keychain.

Reads are transparent: on first load `@bb/config` performs a **clean migration**
— any plaintext secret found in `config.json` is moved into the keychain and the
plaintext field is cleared on disk. The keychain is the source of truth from
then on; `getConfigValue` resolves it without callers knowing. The only case a
plaintext secret persists is on a system with no keychain backend (headless
Linux without Secret Service, CI, etc.), and the server warns about it at boot.

## Public exports

Expand Down Expand Up @@ -201,7 +222,6 @@ will touch when implemented. Only the **bolded** entries ship in v0.
| `bytebell` (first-run auto-launch of setup form) | If `isConfigComplete()` returns false, redirect to `bytebell set` form ([docs/arch.md:170](../../docs/arch.md#L170)) | After dashboard lands |
| `bytebell models set <model-id>` | Validate model via OpenRouter API + write `openrouter_model` | After OpenRouter helper |
| `bytebell models ls` | Curated 5-10 models, on-the-fly OpenRouter pricing | Same |
| `bytebell keys set` | Interactive masked prompt → `keytar` keychain → write key | After `keytar` integration |
| `bytebell cost` | Read `~/.bytebell/cost-ledger.sqlite` via `bun:sqlite`, render breakdowns | After cost ledger lands in `@bb/llm` |
| `bytebell server stop \| status \| logs` | Kill / inspect `bytebell-server`, tail server logs (start is shipped — see above) | After `@bb/server` health surface |
| `bytebell mcp` | Print MCP endpoint URL + sample MCP-client config | After dashboard pane |
Expand All @@ -223,7 +243,6 @@ will touch when implemented. Only the **bolded** entries ship in v0.
| `bytebell` (first-run auto-launch of setup form) | If `isConfigComplete()` returns false, redirect to `bytebell set` form ([docs/arch.md:170](../../docs/arch.md#L170)) | After dashboard lands |
| `bytebell models set <model-id>` | Validate model via OpenRouter API + write `openrouter_model` | After OpenRouter helper |
| `bytebell models ls` | Curated 5-10 models, on-the-fly OpenRouter pricing | Same |
| `bytebell keys set` | Interactive masked prompt → `keytar` keychain → write key | After `keytar` integration |
| `bytebell cost` | Read `~/.bytebell/cost-ledger.sqlite` via `bun:sqlite`, render breakdowns | After cost ledger lands in `@bb/llm` |
| `bytebell server stop \| status \| logs` | Kill / inspect `bytebell-server`, tail server logs (start is shipped — see above) | After `@bb/server` health surface |
| **`bytebell mcp install`** | **Detect installed coding tools (Claude Code, Cursor, Claude Desktop, Windsurf, VS Code) and merge a `bytebell` MCP server entry into each one's config, pointing at `http://127.0.0.1:<port>/mcp`.** | **Shipped** |
Expand Down Expand Up @@ -256,7 +275,6 @@ will touch when implemented. Only the **bolded** entries ship in v0.
defaults
- Live connection probes inside the setup form
- First-run auto-launch of setup form (needs the dashboard pane first)
- OpenRouter API key in the setup form (separate `bytebell keys set`)
- Tests — workspace has no test infra yet
- Color theming via `kleur` / `picocolors` — manual ANSI for now
- Distinct exit codes per failure mode (today: `1` = typed/handled error,
Expand Down Expand Up @@ -288,9 +306,10 @@ Adding a new subcommand:
3. Wire into `src/index.ts`: `program.addCommand(build<Name>Command())`.
4. If the command speaks to `bytebell-server`: HTTP only (e.g. `fetch`
to `http://localhost:<server_port>`). Never import `@bb/server`.
5. If the command needs OS primitives (`keytar`, `bun:sqlite`,
`child_process`): add the dep to `package.json`, but never import a
domain / strategy / infra-non-config workspace package.
5. If the command needs OS primitives (`bun:sqlite`, `child_process`) or a
secret store, prefer reusing `@bb/config` (which owns OS-keychain access via
`@napi-rs/keyring`); otherwise add the dep to `package.json`, but never
import a domain / strategy / infra-non-config workspace package.
6. Update _Public exports_ / _Out of scope_ in this file and the table
above — move the row from "deferred" to "shipped".

Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/SetCommand.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Command } from "commander";
import React from "react";
import { render } from "ink";
import { HINTS, getConfigValue } from "@bb/config";
import { SecretSource } from "@bb/types";
import { HINTS, getConfigValue, getSecretSource, isSecretKey } from "@bb/config";
import { KEY_MAP, validKeysList } from "./keyMap.ts";
import { SetupForm } from "./SetupForm.tsx";
import { error, list, success } from "./output.ts";
import { error, list, success, warn } from "./output.ts";

export function buildSetCommand(): Command {
const cmd = new Command("set");
Expand Down Expand Up @@ -58,7 +59,17 @@ async function runSet(key?: string, value?: string): Promise<void> {

try {
mappedKey.setter(value);
success(`Set ${key} to ${mappedKey.redact ? "<redacted>" : value}`);
// Secrets route to the OS keychain via their setter; report where it landed.
if (isSecretKey(mappedKey.configKey)) {
if (getSecretSource(mappedKey.configKey) === SecretSource.Keychain) {
success(`Set ${key} (stored in OS keychain).`);
} else {
success(`Set ${key} (plaintext).`);
warn(`No OS keychain backend available — ${key} was written to config.json in plaintext.`);
}
} else {
success(`Set ${key} to ${mappedKey.redact ? "<redacted>" : value}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
error(`Failed to set ${key}: ${message}`);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/SetupCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function applyConfig(result: InstallWizardResult): void {
providerEntry.setter(result.provider);

if (result.provider === "openrouter") {
// keyEntry.setter routes the API key to the OS keychain (see keyMap.ts).
const keyEntry = KEY_MAP["openrouter-api-key"];
const modelEntry = KEY_MAP["openrouter-model"];
if (keyEntry === undefined) {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/SetupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function SetupForm({ onDone }: SetupFormProps): ReactElement {
if (entry === undefined) {
throw new Error(`No KEY_MAP entry for "${row.cliKey}"`);
}
// Secret keys route to the OS keychain via their KEY_MAP setter.
entry.setter(values[row.id] ?? "");
}
exit();
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/bootConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export function applyInfraDefaults(): ApplyDefaultsResult {
continue;
}
const value = entry.computeDefault();
// Secret keys (e.g. an auto-generated Neo4j password) route to the OS keychain
// via their KEY_MAP setter, so a routine boot leaves no plaintext secret.
const setter = KEY_MAP[entry.cliKey];
if (setter === undefined) {
throw new Error(`internal: KEY_MAP entry "${entry.cliKey}" missing`);
Expand All @@ -112,8 +114,12 @@ const CONFIG_HINT_KEYS: Partial<Record<Config, string>> = {
};

export function checkPreflight(): PreflightResult {
const provider = getConfigValue(Config.LlmProvider);
const required = requiredKeysFor(provider);
const required = requiredKeysFor(
getConfigValue(Config.LlmProvider),
getConfigValue(Config.DbProvider),
getConfigValue(Config.GraphProvider),
getConfigValue(Config.QueueProvider),
);
const missing: PreflightResult["missing"] = [];
for (const configKey of required) {
const value = getConfigValue(configKey);
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/keyMap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LLM_PROVIDERS, LOG_LEVELS, setConfigValue, type LlmProvider, type LogLevel } from "@bb/config";
import { LLM_PROVIDERS, LOG_LEVELS, setConfigValue, storeSecret, type LlmProvider, type LogLevel } from "@bb/config";
import { Config, DbProviderType, GraphProviderType, IngestionStrategyType, QueueProviderType } from "@bb/types";

type Setter = (raw: string) => void;
Expand Down Expand Up @@ -90,7 +90,8 @@ export const KEY_MAP: Record<string, KeyEntry> = {
"neo4j-password": {
configKey: Config.Neo4jPassword,
redact: true,
setter: (s) => setConfigValue(Config.Neo4jPassword, s),
// Secret: store in the OS keychain (plaintext fallback only without a backend).
setter: (s) => storeSecret(Config.Neo4jPassword, s),
},
redis: {
configKey: Config.RedisUrl,
Expand Down Expand Up @@ -120,7 +121,8 @@ export const KEY_MAP: Record<string, KeyEntry> = {
"openrouter-api-key": {
configKey: Config.OpenrouterApiKey,
redact: true,
setter: (s) => setConfigValue(Config.OpenrouterApiKey, s),
// Secret: store in the OS keychain (plaintext fallback only without a backend).
setter: (s) => storeSecret(Config.OpenrouterApiKey, s),
},
"openrouter-model": {
configKey: Config.OpenrouterModel,
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export function error(line: string, hint?: string): void {
}
}

export function warn(line: string): void {
const YELLOW = "";
process.stderr.write(`${paint(YELLOW, `⚠ ${line}`, process.stderr)}\n`);
}

export function list(label: string, items: readonly string[]): void {
process.stderr.write(`${label}\n`);
for (const item of items) {
Expand Down
Loading
Loading