Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ All notable changes to Minerva are documented here. The format follows
## [Unreleased]

### Added
- Alibaba Bailian (DashScope) provider preset (`bailian/qwen-plus`,
`DASHSCOPE_API_KEY`) and settings-defined custom OpenAI-compatible
providers (any name + `baseUrl`), via `@ai-sdk/openai-compatible`.
- Interactive `/config` panel in the TUI: pick a provider, enter an API key
(masked), choose a model. Runs automatically on first launch when no key is
found — replacing the old print-error-and-exit behavior — and applies live
to the next prompt via the new `minerva/config/set_model` extension method.
- Settings: `model` (default model ref) and per-provider `providers` entries;
API keys are honored from the global file only, always written mode `0600`.
- CI (GitHub Actions): verify matrix on ubuntu/macOS, per-file coverage
thresholds, knip, PTY smoke + e2e for the TUI, compiled-binary smoke, and a
secret-gated live-model smoke on main pushes; Dependabot.
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ Docs: [design record](docs/DESIGN.md) · [wire protocol](docs/PROTOCOL.md) ·

```sh
bun install
export ANTHROPIC_API_KEY="sk-ant-..."
bun run --cwd packages/cli dev
```

On first run with no API key configured, the TUI opens an interactive setup
panel: pick a provider, paste a key, confirm a model. Keys can also come from
the environment (`export ANTHROPIC_API_KEY="sk-ant-..."`), which always takes
precedence over stored ones.

## Usage

```
Expand All @@ -43,6 +47,7 @@ Inside the TUI:
| Command | Effect |
|---|---|
| `/help` | List commands |
| `/config` | Choose provider, API key, and model — applies to the next prompt, no restart |
| `/mode [id]` | Show or set the session mode (`plan` \| `default` \| `acceptEdits` \| `auto`) |
| `/compact` | Summarize the conversation and reset the model context |
| `/sessions` | List recent sessions for this directory |
Expand All @@ -55,12 +60,19 @@ project rule), `n` (reject), `esc` (cancel the turn).

## Providers

Model references are `provider/model`; the matching key must be exported:
Model references are `provider/model`. Keys resolve as **env var → key stored
in global settings** (set either via `/config`):

| Provider | Example ref | Key |
|---|---|---|
| Anthropic (default) | `claude-opus-4-8` or `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` |
| OpenAI | `openai/gpt-5.2` | `OPENAI_API_KEY` |
| Alibaba Bailian (DashScope) | `bailian/qwen-plus` | `DASHSCOPE_API_KEY` |

Bailian uses the China endpoint by default; for the international one,
override its `baseUrl` in settings (see below). Any other OpenAI-compatible
endpoint (DeepSeek, Ollama, a proxy…) can be added as a custom provider —
via `/config` → `custom…`, or directly in settings.

## Configuration

Expand All @@ -69,6 +81,17 @@ Settings merge from `~/.minerva/settings.json` (global; override the root with

```json
{
"model": "bailian/qwen-plus",
"providers": {
"bailian": {
"apiKey": "sk-...",
"baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
},
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"defaultModel": "deepseek-chat"
}
},
"defaultMode": "default",
"permissions": {
"allow": ["bash(git status)", "bash(bun test*)"],
Expand All @@ -81,6 +104,13 @@ Settings merge from `~/.minerva/settings.json` (global; override the root with
}
```

`model` picks the default model ref (`--model` and `MINERVA_MODEL` override
it). `providers` overrides a built-in (e.g. Bailian's endpoint) or defines a
new OpenAI-compatible one — a new name needs `baseUrl`, and its key env var
defaults to `<NAME>_API_KEY`. **API keys are honored from the global file
only** (never the shareable project file), and any file that may hold keys is
written with mode `0600`.

Permission rules are `tool` or `tool(pattern)` where `*` matches any run of
characters, `?` one character, and `\*` a literal asterisk. Precedence:
**deny** → **ask** → read-only auto-allow → **plan mode deny** → **allow** →
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

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

6 changes: 5 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ minerva/
```

Config: `~/.minerva/settings.json` (global) + `.minerva/settings.json`
(project) — provider/model selection, permission rules, MCP server definitions.
(project) — provider/model selection (`model`, `providers`; API keys
global-only, file written `0600`), permission rules, MCP server definitions.
The config extension method is `minerva/config/set_model` (persist + live
provider swap); provider construction is host-injected via
`KernelOptions.resolveProvider` so the kernel never imports the AI SDK.

## v0.1 internal slices (checkpoints, not releases)

Expand Down
10 changes: 10 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ Params `{ sessionId }` → `{ summary }`. Runs one summarization turn, appends a
`session.compacted` event, and resets the model context to the summary. The
event log — and therefore the replayed transcript — keeps the full history.

### `minerva/config/set_model`
Params `{ modelRef, provider?, apiKey? }` → `{ providerId }`. Persists the
model ref — and optionally a provider definition (`{ name, baseUrl?,
apiKeyEnv?, defaultModel? }`) plus API key — to global settings (written
`0600`), then swaps the kernel's live provider; the next prompt in any session
uses it. Provider construction is host-injected (`KernelOptions.
resolveProvider`), keeping the kernel free of AI SDK knowledge; hosts without
a resolver reject the method. Open sessions log a `session.model_changed`
audit event, which replay ignores.

## Versioning

`PROTOCOL_VERSION` is exchanged in `initialize`. Additive changes (new
Expand Down
54 changes: 52 additions & 2 deletions packages/cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MinervaClient, SessionStore, ViewItem } from "@minerva/client";
import { Box, Text, useApp, useInput } from "ink";
import TextInput from "ink-text-input";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { ConfigPanel, type ConfigResult, type ProviderChoice } from "./config-panel";
import type { PendingPermission, PermissionBridge } from "./permission-bridge";

interface AppProps {
Expand All @@ -11,12 +12,18 @@ interface AppProps {
cwd: string;
/** null = new session; "latest" = most recent for cwd; else a session id. */
resume: string | null;
/** Rows for the /config panel's provider selector. */
providers: ProviderChoice[];
/** No usable API key at startup — open the config panel instead of exiting. */
needsConfig: boolean;
}

export function App({ client, bridge, model, cwd, resume }: AppProps) {
export function App({ client, bridge, model, cwd, resume, providers, needsConfig }: AppProps) {
const [session, setSession] = useState<{ id: string; store: SessionStore } | null>(null);
const [pending, setPending] = useState<PendingPermission | null>(null);
const [error, setError] = useState<string | null>(null);
// Header model ref; updated live when /config swaps the provider.
const [modelRef, setModelRef] = useState(model);

useEffect(() => {
bridge.handler = (request) =>
Expand Down Expand Up @@ -63,21 +70,26 @@ export function App({ client, bridge, model, cwd, resume }: AppProps) {
return (
<Box flexDirection="column">
<Text dimColor>
Minerva · {model} · {cwd} · /help for commands, esc to cancel
Minerva · {modelRef} · {cwd} · /help for commands, esc to cancel
</Text>
<Chat
client={client}
session={session}
pending={pending}
cwd={cwd}
onNewSession={startNewSession}
providers={providers}
model={modelRef}
onModelChanged={setModelRef}
initialConfigOpen={needsConfig}
/>
</Box>
);
}

const HELP_TEXT = [
"/help show this help",
"/config choose provider, API key, and model",
"/mode [id] show or set the session mode (plan | default | acceptEdits | auto)",
"/compact summarize the conversation and reset the model context",
"/sessions list recent sessions for this directory",
Expand All @@ -91,19 +103,28 @@ function Chat({
pending,
cwd,
onNewSession,
providers,
model,
onModelChanged,
initialConfigOpen,
}: {
client: MinervaClient;
session: { id: string; store: SessionStore };
pending: PendingPermission | null;
cwd: string;
onNewSession: () => void;
providers: ProviderChoice[];
model: string;
onModelChanged: (providerId: string) => void;
initialConfigOpen: boolean;
}) {
const subscribe = useCallback(
(listener: () => void) => session.store.subscribe(listener),
[session.store],
);
const viewModel = useSyncExternalStore(subscribe, () => session.store.snapshot);
const [draft, setDraft] = useState("");
const [configOpen, setConfigOpen] = useState(initialConfigOpen);
const { exit } = useApp();

useInput((_input, key) => {
Expand All @@ -125,6 +146,9 @@ function Chat({
case "help":
info(HELP_TEXT);
break;
case "config":
setConfigOpen(true);
break;
case "mode":
if (!argument) {
info(
Expand Down Expand Up @@ -175,13 +199,39 @@ function Chat({
client.prompt(session.id, text).catch(reportError);
};

const applyConfig = async (result: ConfigResult) => {
const providerId = await client.setModel({
modelRef: result.modelRef,
...(result.provider ? { provider: result.provider } : {}),
...(result.apiKey ? { apiKey: result.apiKey } : {}),
});
onModelChanged(providerId);
setConfigOpen(false);
info(`model set to ${providerId} (saved to global settings)`);
};

const cancelConfig = () => {
setConfigOpen(false);
if (initialConfigOpen) {
info("no API key configured — prompts will fail until you run /config");
}
};

return (
<Box flexDirection="column">
{viewModel.items.map((item, index) => (
<ItemView key={itemKey(item, index)} item={item} />
))}
{pending ? (
<PermissionPrompt pending={pending} />
) : configOpen ? (
<ConfigPanel
providers={providers}
currentModel={model}
firstRun={initialConfigOpen}
onSubmit={applyConfig}
onCancel={cancelConfig}
/>
) : viewModel.busy ? (
<Text color="yellow">✳ working… (esc to cancel)</Text>
) : (
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

interface CliArgs {
command: "tui" | "acp";
model: string;
/** Model ref from -m/--model; null when the flag was not given, so the
* entrypoint can fall back to MINERVA_MODEL and then settings. */
model: string | null;
resume: string | null;
}

Expand All @@ -26,18 +28,22 @@ Commands:
Options:
-c, --continue Resume the most recent session for this directory
-r, --resume <id> Resume a specific session
-m, --model <ref> Model as [provider/]model, e.g. openai/gpt-5.2 or
-m, --model <ref> Model as [provider/]model, e.g. bailian/qwen-plus or
claude-opus-4-8 (default: ${defaultModel}, env: MINERVA_MODEL)
-h, --help Show this help

Environment:
ANTHROPIC_API_KEY Key for anthropic/* models
OPENAI_API_KEY Key for openai/* models
MINERVA_DATA_DIR Session/config root (default: ~/.minerva)`;
DASHSCOPE_API_KEY Key for bailian/* models (Alibaba Bailian)
MINERVA_DATA_DIR Session/config root (default: ~/.minerva)

Model, provider, and API-key configuration can also live in settings
(~/.minerva/settings.json) — run the TUI and use /config to set it up.`;
}

export function parseCliArgs(argv: string[], defaultModel: string): ParsedCli {
let model = defaultModel;
export function parseCliArgs(argv: string[]): ParsedCli {
let model: string | null = null;
let resume: string | null = null;
let command: CliArgs["command"] = "tui";

Expand Down
Loading
Loading