3824 lines
133 KiB
Rust
3824 lines
133 KiB
Rust
mod metrics;
|
|
mod update;
|
|
|
|
use std::io::{self, Read, Write};
|
|
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
|
|
use clap_complete::{Shell, generate};
|
|
use codewhale_agent::ModelRegistry;
|
|
use codewhale_app_server::{
|
|
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
|
|
};
|
|
use codewhale_config::{
|
|
CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
|
|
};
|
|
use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
|
|
use codewhale_mcp::{McpServerDefinition, run_stdio_server};
|
|
use codewhale_secrets::Secrets;
|
|
use codewhale_state::{StateStore, ThreadListFilters};
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
enum ProviderArg {
|
|
Deepseek,
|
|
NvidiaNim,
|
|
Openai,
|
|
Atlascloud,
|
|
WanjieArk,
|
|
Volcengine,
|
|
Openrouter,
|
|
XiaomiMimo,
|
|
Novita,
|
|
Fireworks,
|
|
Siliconflow,
|
|
Arcee,
|
|
Moonshot,
|
|
Sglang,
|
|
Vllm,
|
|
Ollama,
|
|
Huggingface,
|
|
Together,
|
|
OpenaiCodex,
|
|
}
|
|
|
|
impl From<ProviderArg> for ProviderKind {
|
|
fn from(value: ProviderArg) -> Self {
|
|
match value {
|
|
ProviderArg::Deepseek => ProviderKind::Deepseek,
|
|
ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
|
|
ProviderArg::Openai => ProviderKind::Openai,
|
|
ProviderArg::Atlascloud => ProviderKind::Atlascloud,
|
|
ProviderArg::WanjieArk => ProviderKind::WanjieArk,
|
|
ProviderArg::Volcengine => ProviderKind::Volcengine,
|
|
ProviderArg::Openrouter => ProviderKind::Openrouter,
|
|
ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
|
|
ProviderArg::Novita => ProviderKind::Novita,
|
|
ProviderArg::Fireworks => ProviderKind::Fireworks,
|
|
ProviderArg::Siliconflow => ProviderKind::Siliconflow,
|
|
ProviderArg::Arcee => ProviderKind::Arcee,
|
|
ProviderArg::Moonshot => ProviderKind::Moonshot,
|
|
ProviderArg::Sglang => ProviderKind::Sglang,
|
|
ProviderArg::Vllm => ProviderKind::Vllm,
|
|
ProviderArg::Ollama => ProviderKind::Ollama,
|
|
ProviderArg::Huggingface => ProviderKind::Huggingface,
|
|
ProviderArg::Together => ProviderKind::Together,
|
|
ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(
|
|
name = "codewhale",
|
|
version = env!("DEEPSEEK_BUILD_VERSION"),
|
|
bin_name = "codewhale",
|
|
override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
|
|
)]
|
|
struct Cli {
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
#[arg(long)]
|
|
profile: Option<String>,
|
|
#[arg(
|
|
long,
|
|
value_enum,
|
|
help = "Advanced provider selector for non-TUI registry/config commands"
|
|
)]
|
|
provider: Option<ProviderArg>,
|
|
#[arg(long)]
|
|
model: Option<String>,
|
|
#[arg(long = "output-mode")]
|
|
output_mode: Option<String>,
|
|
#[arg(long = "log-level")]
|
|
log_level: Option<String>,
|
|
#[arg(long)]
|
|
telemetry: Option<bool>,
|
|
#[arg(long)]
|
|
approval_policy: Option<String>,
|
|
#[arg(long)]
|
|
sandbox_mode: Option<String>,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
#[arg(long)]
|
|
base_url: Option<String>,
|
|
/// Workspace directory for TUI file tools
|
|
#[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
|
|
workspace: Option<PathBuf>,
|
|
#[arg(long = "no-alt-screen", hide = true)]
|
|
no_alt_screen: bool,
|
|
#[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
|
|
mouse_capture: bool,
|
|
#[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
|
|
no_mouse_capture: bool,
|
|
#[arg(long = "skip-onboarding")]
|
|
skip_onboarding: bool,
|
|
/// YOLO mode: auto-approve all tools
|
|
#[arg(long)]
|
|
yolo: bool,
|
|
/// Continue the most recent interactive session for this workspace.
|
|
#[arg(short = 'c', long = "continue")]
|
|
continue_session: bool,
|
|
#[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
|
|
prompt_flag: Option<String>,
|
|
#[arg(
|
|
value_name = "PROMPT",
|
|
trailing_var_arg = true,
|
|
allow_hyphen_values = true
|
|
)]
|
|
prompt: Vec<String>,
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum Commands {
|
|
/// Run interactive/non-interactive flows via the TUI binary.
|
|
Run(RunArgs),
|
|
/// Run CodeWhale diagnostics.
|
|
Doctor(TuiPassthroughArgs),
|
|
/// List live provider API models via the TUI binary.
|
|
Models(TuiPassthroughArgs),
|
|
/// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
|
|
#[command(visible_alias = "tts")]
|
|
Speech(TuiPassthroughArgs),
|
|
/// List saved TUI sessions.
|
|
Sessions(TuiPassthroughArgs),
|
|
/// Resume a saved TUI session.
|
|
Resume(TuiPassthroughArgs),
|
|
/// Fork a saved TUI session.
|
|
Fork(TuiPassthroughArgs),
|
|
/// Create a default AGENTS.md in the current directory.
|
|
Init(TuiPassthroughArgs),
|
|
/// Bootstrap MCP config and/or skills directories.
|
|
Setup(TuiPassthroughArgs),
|
|
/// Run a non-interactive prompt through the TUI runtime.
|
|
#[command(after_help = "\
|
|
Examples:
|
|
codewhale exec \"explain this function\"
|
|
codewhale exec --auto \"list crates/ with ls\"
|
|
codewhale exec --auto --output-format stream-json \"fix the failing test\"
|
|
|
|
Common forwarded flags:
|
|
--auto Enable tool-backed agent mode with auto-approvals
|
|
--json Emit summary JSON
|
|
--resume <SESSION_ID> Resume a previous session by ID or prefix
|
|
--session-id <SESSION_ID> Resume a previous session by ID or prefix
|
|
--continue Continue the most recent session for this workspace
|
|
--output-format <FORMAT> Output format: text or stream-json
|
|
|
|
Plain `codewhale exec` is a one-shot model response. Use `--auto` for
|
|
non-interactive filesystem/shell tool use, matching the supported automation
|
|
path used by stream-json wrappers.
|
|
")]
|
|
Exec(TuiPassthroughArgs),
|
|
/// Generate SWE-bench prediction rows from CodeWhale runs.
|
|
#[command(after_help = "\
|
|
Examples:
|
|
codewhale swebench run --instance-id django__django-12345 --issue-file issue.md
|
|
codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl
|
|
|
|
This command forwards to the TUI runtime. `run` invokes tool-backed agent mode
|
|
and writes a SWE-bench-compatible JSONL prediction row from the resulting
|
|
working-tree diff. `export` only writes the current diff.
|
|
")]
|
|
Swebench(TuiPassthroughArgs),
|
|
/// Run a CodeWhale-powered code review over a git diff.
|
|
Review(TuiPassthroughArgs),
|
|
/// Apply a patch file or stdin to the working tree.
|
|
Apply(TuiPassthroughArgs),
|
|
/// Run the offline TUI evaluation harness.
|
|
Eval(TuiPassthroughArgs),
|
|
/// Manage TUI MCP servers.
|
|
Mcp(TuiPassthroughArgs),
|
|
/// Inspect TUI feature flags.
|
|
Features(TuiPassthroughArgs),
|
|
/// Run a local TUI server.
|
|
Serve(TuiPassthroughArgs),
|
|
/// Generate shell completions for the TUI binary.
|
|
Completions(TuiPassthroughArgs),
|
|
/// Configure provider credentials.
|
|
Login(LoginArgs),
|
|
/// Remove saved authentication state.
|
|
Logout,
|
|
/// Manage authentication credentials and provider mode.
|
|
Auth(AuthArgs),
|
|
/// Run MCP server mode over stdio.
|
|
McpServer,
|
|
/// Read/write/list config values.
|
|
Config(ConfigArgs),
|
|
/// Resolve or list available models across providers.
|
|
Model(ModelArgs),
|
|
/// Manage thread/session metadata and resume/fork flows.
|
|
Thread(ThreadArgs),
|
|
/// Evaluate sandbox/approval policy decisions.
|
|
Sandbox(SandboxArgs),
|
|
/// Run the app-server transport.
|
|
AppServer(AppServerArgs),
|
|
/// Generate shell completions.
|
|
#[command(after_help = r#"Examples:
|
|
Bash (current shell only):
|
|
source <(codewhale completion bash)
|
|
|
|
Bash (persistent, Linux/bash-completion):
|
|
mkdir -p ~/.local/share/bash-completion/completions
|
|
codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
|
|
# Requires bash-completion to be installed and loaded by your shell.
|
|
|
|
Zsh:
|
|
mkdir -p ~/.zfunc
|
|
codewhale completion zsh > ~/.zfunc/_codewhale
|
|
# Add to ~/.zshrc if needed:
|
|
# fpath=(~/.zfunc $fpath)
|
|
# autoload -Uz compinit && compinit
|
|
|
|
Fish:
|
|
mkdir -p ~/.config/fish/completions
|
|
codewhale completion fish > ~/.config/fish/completions/codewhale.fish
|
|
|
|
PowerShell (current shell only):
|
|
codewhale completion powershell | Out-String | Invoke-Expression
|
|
|
|
The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
|
|
Completion {
|
|
#[arg(value_enum)]
|
|
shell: Shell,
|
|
},
|
|
/// Print a usage rollup from the audit log and session store.
|
|
Metrics(MetricsArgs),
|
|
/// Check for and apply updates to the `codewhale` binary.
|
|
Update(UpdateArgs),
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct UpdateArgs {
|
|
/// Update to the latest beta release instead of the latest stable release.
|
|
#[arg(long)]
|
|
beta: bool,
|
|
/// Only check the latest release; do not download or replace binaries.
|
|
#[arg(long)]
|
|
check: bool,
|
|
/// Proxy URL to use for update HTTP requests.
|
|
#[arg(long, value_name = "URL")]
|
|
proxy: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct MetricsArgs {
|
|
/// Emit machine-readable JSON.
|
|
#[arg(long)]
|
|
json: bool,
|
|
/// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
|
|
#[arg(long, value_name = "DURATION")]
|
|
since: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct RunArgs {
|
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
struct TuiPassthroughArgs {
|
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct LoginArgs {
|
|
#[arg(long, value_enum, hide = true)]
|
|
provider: Option<ProviderArg>,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct AuthArgs {
|
|
#[command(subcommand)]
|
|
command: AuthCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum AuthCommand {
|
|
/// Show current provider and credential source state.
|
|
/// Without `--provider`, shows all known providers.
|
|
/// With `--provider`, shows detailed status for that provider.
|
|
Status {
|
|
/// Show status for a specific provider only.
|
|
#[arg(long, value_enum)]
|
|
provider: Option<ProviderArg>,
|
|
},
|
|
/// Save an API key to the shared user config file. Reads from
|
|
/// `--api-key`, `--api-key-stdin`, or prompts on stdin when
|
|
/// neither is given. Does not echo the key.
|
|
Set {
|
|
#[arg(long, value_enum)]
|
|
provider: ProviderArg,
|
|
/// Inline value (discouraged — appears in shell history).
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
/// Read the key from stdin instead of prompting.
|
|
#[arg(long = "api-key-stdin", default_value_t = false)]
|
|
api_key_stdin: bool,
|
|
},
|
|
/// Report whether a provider has a key configured. Never prints
|
|
/// the value; just `set` / `not set` plus the source layer.
|
|
Get {
|
|
#[arg(long, value_enum)]
|
|
provider: ProviderArg,
|
|
},
|
|
/// Delete a provider's key from config and secret-store storage.
|
|
Clear {
|
|
#[arg(long, value_enum)]
|
|
provider: ProviderArg,
|
|
},
|
|
/// List all known providers with their auth state, without
|
|
/// revealing keys.
|
|
List,
|
|
/// Advanced: migrate config-file keys into a platform credential store.
|
|
#[command(hide = true)]
|
|
Migrate {
|
|
/// Don't actually write anything; print what would change.
|
|
#[arg(long, default_value_t = false)]
|
|
dry_run: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct ConfigArgs {
|
|
#[command(subcommand)]
|
|
command: ConfigCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ConfigCommand {
|
|
Get { key: String },
|
|
Set { key: String, value: String },
|
|
Unset { key: String },
|
|
List,
|
|
Path,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct ModelArgs {
|
|
#[command(subcommand)]
|
|
command: ModelCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ModelCommand {
|
|
List {
|
|
#[arg(long, value_enum)]
|
|
provider: Option<ProviderArg>,
|
|
},
|
|
Resolve {
|
|
model: Option<String>,
|
|
#[arg(long, value_enum)]
|
|
provider: Option<ProviderArg>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct ThreadArgs {
|
|
#[command(subcommand)]
|
|
command: ThreadCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ThreadCommand {
|
|
List {
|
|
#[arg(long, default_value_t = false)]
|
|
all: bool,
|
|
#[arg(long)]
|
|
limit: Option<usize>,
|
|
},
|
|
Read {
|
|
thread_id: String,
|
|
},
|
|
Resume {
|
|
thread_id: String,
|
|
},
|
|
Fork {
|
|
thread_id: String,
|
|
},
|
|
Archive {
|
|
thread_id: String,
|
|
},
|
|
Unarchive {
|
|
thread_id: String,
|
|
},
|
|
SetName {
|
|
thread_id: String,
|
|
name: String,
|
|
},
|
|
/// Remove the custom name from a thread, restoring the default
|
|
/// `(unnamed)` rendering in `thread list`.
|
|
ClearName {
|
|
thread_id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct SandboxArgs {
|
|
#[command(subcommand)]
|
|
command: SandboxCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum SandboxCommand {
|
|
Check {
|
|
command: String,
|
|
#[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
|
|
ask: ApprovalModeArg,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
enum ApprovalModeArg {
|
|
UnlessTrusted,
|
|
OnFailure,
|
|
OnRequest,
|
|
Never,
|
|
}
|
|
|
|
impl From<ApprovalModeArg> for AskForApproval {
|
|
fn from(value: ApprovalModeArg) -> Self {
|
|
match value {
|
|
ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
|
|
ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
|
|
ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
|
|
ApprovalModeArg::Never => AskForApproval::Never,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct AppServerArgs {
|
|
#[arg(long, default_value = "127.0.0.1")]
|
|
host: String,
|
|
#[arg(long, default_value_t = 8787)]
|
|
port: u16,
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
#[arg(long = "auth-token")]
|
|
auth_token: Option<String>,
|
|
#[arg(long, default_value_t = false)]
|
|
insecure_no_auth: bool,
|
|
#[arg(long = "cors-origin")]
|
|
cors_origin: Vec<String>,
|
|
#[arg(long, default_value_t = false)]
|
|
stdio: bool,
|
|
}
|
|
|
|
const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
|
|
|
|
fn install_rustls_crypto_provider() {
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
}
|
|
|
|
pub fn run_cli() -> std::process::ExitCode {
|
|
install_rustls_crypto_provider();
|
|
|
|
match run() {
|
|
Ok(()) => std::process::ExitCode::SUCCESS,
|
|
Err(err) => {
|
|
// Use the full anyhow chain so callers see the underlying
|
|
// cause (e.g. the actual TOML parse error with line/column)
|
|
// instead of just the top-level context message. The bare
|
|
// `{err}` Display impl drops the chain — see #767, where
|
|
// users hit "failed to parse config at <path>" with no
|
|
// hint that the real error was a stray BOM or unbalanced
|
|
// quote a few lines down.
|
|
eprintln!("error: {err}");
|
|
for cause in err.chain().skip(1) {
|
|
eprintln!(" caused by: {cause}");
|
|
}
|
|
std::process::ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<()> {
|
|
let mut cli = Cli::parse();
|
|
|
|
let mut store = ConfigStore::load(cli.config.clone())?;
|
|
let runtime_overrides = CliRuntimeOverrides {
|
|
provider: cli.provider.map(Into::into),
|
|
model: cli.model.clone(),
|
|
api_key: cli.api_key.clone(),
|
|
base_url: cli.base_url.clone(),
|
|
auth_mode: None,
|
|
output_mode: cli.output_mode.clone(),
|
|
log_level: cli.log_level.clone(),
|
|
telemetry: cli.telemetry,
|
|
approval_policy: cli.approval_policy.clone(),
|
|
sandbox_mode: cli.sandbox_mode.clone(),
|
|
yolo: Some(cli.yolo),
|
|
};
|
|
let command = cli.command.take();
|
|
|
|
match command {
|
|
Some(Commands::Run(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, args.args)
|
|
}
|
|
Some(Commands::Doctor(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
|
|
}
|
|
Some(Commands::Models(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
|
|
}
|
|
Some(Commands::Speech(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
|
|
}
|
|
Some(Commands::Sessions(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
|
|
}
|
|
Some(Commands::Resume(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
run_resume_command(&cli, &resolved_runtime, args)
|
|
}
|
|
Some(Commands::Fork(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
|
|
}
|
|
Some(Commands::Init(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
|
|
}
|
|
Some(Commands::Setup(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
|
|
}
|
|
Some(Commands::Exec(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
|
|
}
|
|
Some(Commands::Swebench(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("swebench", args))
|
|
}
|
|
Some(Commands::Review(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
|
|
}
|
|
Some(Commands::Apply(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
|
|
}
|
|
Some(Commands::Eval(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
|
|
}
|
|
Some(Commands::Mcp(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
|
|
}
|
|
Some(Commands::Features(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
|
|
}
|
|
Some(Commands::Serve(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
|
|
}
|
|
Some(Commands::Completions(args)) => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
|
|
}
|
|
Some(Commands::Login(args)) => run_login_command(&mut store, args),
|
|
Some(Commands::Logout) => run_logout_command(&mut store),
|
|
Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
|
|
Some(Commands::McpServer) => run_mcp_server_command(&mut store),
|
|
Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
|
|
Some(Commands::Model(args)) => run_model_command(args.command),
|
|
Some(Commands::Thread(args)) => run_thread_command(args.command),
|
|
Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
|
|
Some(Commands::AppServer(args)) => run_app_server_command(args),
|
|
Some(Commands::Completion { shell }) => {
|
|
let mut cmd = Cli::command();
|
|
generate(shell, &mut cmd, "codewhale", &mut io::stdout());
|
|
Ok(())
|
|
}
|
|
Some(Commands::Metrics(args)) => run_metrics_command(args),
|
|
Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy),
|
|
None => {
|
|
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
|
let forwarded = root_tui_passthrough(&cli)?;
|
|
delegate_to_tui(&cli, &resolved_runtime, forwarded)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
|
|
let mut forwarded = Vec::new();
|
|
if cli.continue_session {
|
|
forwarded.push("--continue".to_string());
|
|
}
|
|
|
|
let prompt =
|
|
cli.prompt_flag
|
|
.iter()
|
|
.chain(cli.prompt.iter())
|
|
.fold(String::new(), |mut acc, part| {
|
|
if !acc.is_empty() {
|
|
acc.push(' ');
|
|
}
|
|
acc.push_str(part);
|
|
acc
|
|
});
|
|
if !prompt.is_empty() {
|
|
if cli.continue_session {
|
|
bail!(
|
|
"`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
|
|
);
|
|
}
|
|
forwarded.push("--prompt".to_string());
|
|
forwarded.push(prompt);
|
|
}
|
|
|
|
Ok(forwarded)
|
|
}
|
|
|
|
fn resolve_runtime_for_dispatch(
|
|
store: &mut ConfigStore,
|
|
runtime_overrides: &CliRuntimeOverrides,
|
|
) -> ResolvedRuntimeOptions {
|
|
let runtime_secrets = Secrets::auto_detect();
|
|
resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
|
|
}
|
|
|
|
fn resolve_runtime_for_dispatch_with_secrets(
|
|
store: &mut ConfigStore,
|
|
runtime_overrides: &CliRuntimeOverrides,
|
|
secrets: &Secrets,
|
|
) -> ResolvedRuntimeOptions {
|
|
let mut resolved = store
|
|
.config
|
|
.resolve_runtime_options_with_secrets(runtime_overrides, secrets);
|
|
|
|
if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
|
|
&& !provider_config_set(store, resolved.provider)
|
|
&& let Some(api_key) = resolved.api_key.clone()
|
|
{
|
|
write_provider_api_key_to_config(store, resolved.provider, &api_key);
|
|
match store.save() {
|
|
Ok(()) => {
|
|
eprintln!(
|
|
"info: recovered API key from secret store and saved it to {}",
|
|
store.path().display()
|
|
);
|
|
resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
|
|
}
|
|
Err(err) => {
|
|
eprintln!(
|
|
"warning: recovered API key from secret store but failed to save {}: {err}",
|
|
store.path().display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
resolved
|
|
}
|
|
|
|
fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
|
|
let mut forwarded = Vec::with_capacity(args.args.len() + 1);
|
|
forwarded.push(command.to_string());
|
|
forwarded.extend(args.args);
|
|
forwarded
|
|
}
|
|
|
|
fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
|
|
run_login_command_with_secrets(store, args, &Secrets::auto_detect())
|
|
}
|
|
|
|
fn run_login_command_with_secrets(
|
|
store: &mut ConfigStore,
|
|
args: LoginArgs,
|
|
secrets: &Secrets,
|
|
) -> Result<()> {
|
|
let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
|
|
store.config.provider = provider;
|
|
|
|
let api_key = match args.api_key {
|
|
Some(v) => v,
|
|
None => read_api_key_from_stdin()?,
|
|
};
|
|
write_provider_api_key_to_config(store, provider, &api_key);
|
|
let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
|
|
store.save()?;
|
|
let destination = if keyring_saved {
|
|
format!("{} and {}", store.path().display(), secrets.backend_name())
|
|
} else {
|
|
store.path().display().to_string()
|
|
};
|
|
if provider == ProviderKind::Deepseek {
|
|
println!("logged in using API key mode (deepseek); saved key to {destination}");
|
|
} else {
|
|
println!(
|
|
"logged in using API key mode ({}); saved key to {destination}",
|
|
provider.as_str(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
|
|
run_logout_command_with_secrets(store, &Secrets::auto_detect())
|
|
}
|
|
|
|
fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
|
|
let active_provider = store.config.provider;
|
|
store.config.api_key = None;
|
|
for provider in PROVIDER_LIST {
|
|
clear_provider_api_key_from_config(store, provider);
|
|
}
|
|
clear_provider_api_key_from_keyring(secrets, active_provider);
|
|
store.config.auth_mode = None;
|
|
store.save()?;
|
|
println!("logged out");
|
|
Ok(())
|
|
}
|
|
|
|
/// Map [`ProviderKind`] to the canonical provider credential slot.
|
|
fn provider_slot(provider: ProviderKind) -> &'static str {
|
|
match provider {
|
|
ProviderKind::Deepseek => "deepseek",
|
|
ProviderKind::NvidiaNim => "nvidia-nim",
|
|
ProviderKind::Openai => "openai",
|
|
ProviderKind::Atlascloud => "atlascloud",
|
|
ProviderKind::WanjieArk => "wanjie-ark",
|
|
ProviderKind::Volcengine => "volcengine",
|
|
ProviderKind::Openrouter => "openrouter",
|
|
ProviderKind::XiaomiMimo => "xiaomi-mimo",
|
|
ProviderKind::Novita => "novita",
|
|
ProviderKind::Fireworks => "fireworks",
|
|
ProviderKind::Siliconflow => "siliconflow",
|
|
ProviderKind::SiliconflowCN => "siliconflow",
|
|
ProviderKind::Arcee => "arcee",
|
|
ProviderKind::Moonshot => "moonshot",
|
|
ProviderKind::Sglang => "sglang",
|
|
ProviderKind::Vllm => "vllm",
|
|
ProviderKind::Ollama => "ollama",
|
|
ProviderKind::Huggingface => "huggingface",
|
|
ProviderKind::Together => "together",
|
|
ProviderKind::OpenaiCodex => "openai-codex",
|
|
ProviderKind::Anthropic => "anthropic",
|
|
}
|
|
}
|
|
|
|
/// Provider order used by the `auth list` and `auth status` outputs.
|
|
const PROVIDER_LIST: [ProviderKind; 20] = [
|
|
ProviderKind::Deepseek,
|
|
ProviderKind::NvidiaNim,
|
|
ProviderKind::Openai,
|
|
ProviderKind::Atlascloud,
|
|
ProviderKind::WanjieArk,
|
|
ProviderKind::Volcengine,
|
|
ProviderKind::Openrouter,
|
|
ProviderKind::XiaomiMimo,
|
|
ProviderKind::Novita,
|
|
ProviderKind::Fireworks,
|
|
ProviderKind::Siliconflow,
|
|
ProviderKind::SiliconflowCN,
|
|
ProviderKind::Arcee,
|
|
ProviderKind::Moonshot,
|
|
ProviderKind::Sglang,
|
|
ProviderKind::Vllm,
|
|
ProviderKind::Ollama,
|
|
ProviderKind::Huggingface,
|
|
ProviderKind::Together,
|
|
ProviderKind::OpenaiCodex,
|
|
];
|
|
|
|
fn provider_is_supported_by_tui(provider: ProviderKind) -> bool {
|
|
matches!(
|
|
provider,
|
|
ProviderKind::Deepseek
|
|
| ProviderKind::NvidiaNim
|
|
| ProviderKind::Openai
|
|
| ProviderKind::Atlascloud
|
|
| ProviderKind::WanjieArk
|
|
| ProviderKind::Volcengine
|
|
| ProviderKind::Openrouter
|
|
| ProviderKind::XiaomiMimo
|
|
| ProviderKind::Novita
|
|
| ProviderKind::Fireworks
|
|
| ProviderKind::Siliconflow
|
|
| ProviderKind::SiliconflowCN
|
|
| ProviderKind::Arcee
|
|
| ProviderKind::Moonshot
|
|
| ProviderKind::Sglang
|
|
| ProviderKind::Vllm
|
|
| ProviderKind::Ollama
|
|
| ProviderKind::Huggingface
|
|
| ProviderKind::Together
|
|
| ProviderKind::OpenaiCodex
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn no_keyring_secrets() -> Secrets {
|
|
Secrets::new(std::sync::Arc::new(
|
|
codewhale_secrets::InMemoryKeyringStore::new(),
|
|
))
|
|
}
|
|
|
|
fn write_provider_api_key_to_config(
|
|
store: &mut ConfigStore,
|
|
provider: ProviderKind,
|
|
api_key: &str,
|
|
) {
|
|
store.config.auth_mode = Some("api_key".to_string());
|
|
store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
|
|
if provider == ProviderKind::Deepseek {
|
|
store.config.api_key = Some(api_key.to_string());
|
|
if store.config.default_text_model.is_none() {
|
|
store.config.default_text_model = Some(
|
|
store
|
|
.config
|
|
.providers
|
|
.deepseek
|
|
.model
|
|
.clone()
|
|
.unwrap_or_else(|| "deepseek-v4-pro".to_string()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
|
|
store.config.providers.for_provider_mut(provider).api_key = None;
|
|
if provider == ProviderKind::Deepseek {
|
|
store.config.api_key = None;
|
|
}
|
|
}
|
|
|
|
fn provider_env_set(provider: ProviderKind) -> bool {
|
|
provider_env_value(provider).is_some()
|
|
}
|
|
|
|
fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
|
|
match provider {
|
|
ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
|
|
ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
|
|
ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
|
|
ProviderKind::Novita => &["NOVITA_API_KEY"],
|
|
ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
|
|
ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
|
|
ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"],
|
|
ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"],
|
|
ProviderKind::Arcee => &["ARCEE_API_KEY"],
|
|
ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
|
|
ProviderKind::Sglang => &["SGLANG_API_KEY"],
|
|
ProviderKind::Vllm => &["VLLM_API_KEY"],
|
|
ProviderKind::Ollama => &["OLLAMA_API_KEY"],
|
|
ProviderKind::Huggingface => &["HUGGINGFACE_API_KEY", "HF_TOKEN"],
|
|
ProviderKind::Openai => &["OPENAI_API_KEY"],
|
|
ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"],
|
|
ProviderKind::Volcengine => &[
|
|
"VOLCENGINE_API_KEY",
|
|
"VOLCENGINE_ARK_API_KEY",
|
|
"ARK_API_KEY",
|
|
],
|
|
ProviderKind::WanjieArk => &[
|
|
"WANJIE_ARK_API_KEY",
|
|
"WANJIE_API_KEY",
|
|
"WANJIE_MAAS_API_KEY",
|
|
],
|
|
ProviderKind::Together => &["TOGETHER_API_KEY"],
|
|
ProviderKind::OpenaiCodex => &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"],
|
|
ProviderKind::Anthropic => &["ANTHROPIC_API_KEY"],
|
|
}
|
|
}
|
|
|
|
fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
|
|
provider_env_vars(provider).iter().find_map(|var| {
|
|
std::env::var(var)
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(|value| (*var, value))
|
|
})
|
|
}
|
|
|
|
fn openai_codex_auth_file_path() -> PathBuf {
|
|
if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
|
|
let path = PathBuf::from(path);
|
|
if !path.as_os_str().is_empty() {
|
|
return path;
|
|
}
|
|
}
|
|
|
|
let codex_home = std::env::var("CODEX_HOME")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".codex")
|
|
});
|
|
codex_home.join("auth.json")
|
|
}
|
|
|
|
fn provider_oauth_file_path(provider: ProviderKind) -> Option<PathBuf> {
|
|
(provider == ProviderKind::OpenaiCodex).then(openai_codex_auth_file_path)
|
|
}
|
|
|
|
fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
|
|
let slot = store
|
|
.config
|
|
.providers
|
|
.for_provider(provider)
|
|
.api_key
|
|
.as_deref();
|
|
let root = (provider == ProviderKind::Deepseek)
|
|
.then_some(store.config.api_key.as_deref())
|
|
.flatten();
|
|
slot.or(root).filter(|v| !v.trim().is_empty())
|
|
}
|
|
|
|
fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
|
|
provider_config_api_key(store, provider).is_some()
|
|
}
|
|
|
|
fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
|
|
secrets
|
|
.get(provider_slot(provider))
|
|
.ok()
|
|
.flatten()
|
|
.filter(|v| !v.trim().is_empty())
|
|
}
|
|
|
|
fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
|
|
provider_keyring_api_key(secrets, provider).is_some()
|
|
}
|
|
|
|
fn write_provider_api_key_to_keyring(
|
|
secrets: &Secrets,
|
|
provider: ProviderKind,
|
|
api_key: &str,
|
|
) -> bool {
|
|
secrets.set(provider_slot(provider), api_key).is_ok()
|
|
}
|
|
|
|
fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
|
|
let _ = secrets.delete(provider_slot(provider));
|
|
}
|
|
|
|
fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
|
|
let active_provider = store.config.provider;
|
|
let mut lines = Vec::new();
|
|
lines.push(format!(
|
|
"active provider: {} (set via config or CODEWHALE_PROVIDER)",
|
|
active_provider.as_str()
|
|
));
|
|
lines.push(String::new());
|
|
lines.push(format!(
|
|
"{:<14} {:<8} {:<10} {:<8} {}",
|
|
"provider", "config", "keyring", "env", "status"
|
|
));
|
|
lines.push("-".repeat(70));
|
|
|
|
for provider in PROVIDER_LIST {
|
|
let config_key = provider_config_api_key(store, provider);
|
|
let keyring_key = provider_keyring_api_key(secrets, provider);
|
|
let env_key = provider_env_value(provider);
|
|
let oauth_file_present = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
|
|
|
|
let config_status = config_key.map(|_| "set").unwrap_or("-");
|
|
let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
|
|
let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
|
|
|
|
let source = if provider == ProviderKind::OpenaiCodex {
|
|
// Keep the summary consistent with `auth status`: Codex auth is
|
|
// OAuth-file (or env token) based — config/keyring keys are not
|
|
// consulted for it.
|
|
if env_key.is_some() {
|
|
"env"
|
|
} else if oauth_file_present {
|
|
"oauth file"
|
|
} else {
|
|
"unset"
|
|
}
|
|
} else if config_key.is_some() {
|
|
"config"
|
|
} else if keyring_key.is_some() {
|
|
"keyring"
|
|
} else if env_key.is_some() {
|
|
"env"
|
|
} else if oauth_file_present {
|
|
"oauth file"
|
|
} else {
|
|
"unset"
|
|
};
|
|
|
|
let active_marker = if provider == active_provider {
|
|
" *"
|
|
} else {
|
|
""
|
|
};
|
|
|
|
lines.push(format!(
|
|
"{:<14} {:<8} {:<10} {:<8} {}{}",
|
|
provider.as_str(),
|
|
config_status,
|
|
keyring_status,
|
|
env_status,
|
|
source,
|
|
active_marker
|
|
));
|
|
}
|
|
|
|
lines.push(String::new());
|
|
lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
|
|
lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
|
|
lines
|
|
}
|
|
|
|
fn auth_status_lines_for_provider(
|
|
store: &ConfigStore,
|
|
secrets: &Secrets,
|
|
provider: ProviderKind,
|
|
) -> Vec<String> {
|
|
let config_key = provider_config_api_key(store, provider);
|
|
let keyring_key = provider_keyring_api_key(secrets, provider);
|
|
let env_key = provider_env_value(provider);
|
|
let oauth_file = provider_oauth_file_path(provider);
|
|
let oauth_file_present = oauth_file.as_ref().is_some_and(|path| path.exists());
|
|
|
|
let active_source = if provider == ProviderKind::OpenaiCodex {
|
|
if env_key.is_some() {
|
|
"env"
|
|
} else if oauth_file_present {
|
|
"Codex OAuth file"
|
|
} else {
|
|
"missing"
|
|
}
|
|
} else if config_key.is_some() {
|
|
"config"
|
|
} else if keyring_key.is_some() {
|
|
"secret store"
|
|
} else if env_key.is_some() {
|
|
"env"
|
|
} else {
|
|
"missing"
|
|
};
|
|
let active_last4 = if provider == ProviderKind::OpenaiCodex {
|
|
env_key.as_ref().map(|(_, value)| last4_label(value))
|
|
} else {
|
|
config_key
|
|
.map(last4_label)
|
|
.or_else(|| keyring_key.as_deref().map(last4_label))
|
|
.or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
|
|
};
|
|
let active_label = active_last4
|
|
.map(|last4| format!("{active_source} (last4: {last4})"))
|
|
.unwrap_or_else(|| active_source.to_string());
|
|
|
|
let env_var_label = env_key
|
|
.as_ref()
|
|
.map(|(name, _)| (*name).to_string())
|
|
.unwrap_or_else(|| provider_env_vars(provider).join("/"));
|
|
let env_status = env_key
|
|
.as_ref()
|
|
.map(|(_, value)| format!("set, last4: {}", last4_label(value)))
|
|
.unwrap_or_else(|| "unset".to_string());
|
|
|
|
let is_active = provider == store.config.provider;
|
|
let active_marker = if is_active { " (active provider)" } else { "" };
|
|
|
|
let provider_cfg = store.config.providers.for_provider(provider);
|
|
let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
|
|
let model = provider_cfg.model.as_deref().unwrap_or("(default)");
|
|
|
|
let lookup_order = if provider == ProviderKind::OpenaiCodex {
|
|
"lookup order: env -> Codex OAuth file".to_string()
|
|
} else {
|
|
"lookup order: config -> secret store -> env".to_string()
|
|
};
|
|
let auth_mode = if provider == ProviderKind::OpenaiCodex {
|
|
"codex_oauth"
|
|
} else {
|
|
store.config.auth_mode.as_deref().unwrap_or("api_key")
|
|
};
|
|
|
|
let mut lines = vec![
|
|
format!("provider: {}{}", provider.as_str(), active_marker),
|
|
format!("route: {}", base_url),
|
|
format!("model: {}", model),
|
|
format!("auth mode: {auth_mode}"),
|
|
format!("active source: {active_label}"),
|
|
lookup_order,
|
|
format!(
|
|
"config file: {} ({})",
|
|
store.path().display(),
|
|
source_status(config_key, "missing")
|
|
),
|
|
format!(
|
|
"secret store: {} ({})",
|
|
secrets.backend_name(),
|
|
source_status(keyring_key.as_deref(), "missing")
|
|
),
|
|
format!("env var: {env_var_label} ({env_status})"),
|
|
];
|
|
if let Some(path) = oauth_file {
|
|
let status = if path.exists() { "present" } else { "missing" };
|
|
lines.push(format!("Codex OAuth file: {} ({status})", path.display()));
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn source_status(value: Option<&str>, missing_label: &str) -> String {
|
|
value
|
|
.map(|v| format!("set, last4: {}", last4_label(v)))
|
|
.unwrap_or_else(|| missing_label.to_string())
|
|
}
|
|
|
|
fn last4_label(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
let chars: Vec<char> = trimmed.chars().collect();
|
|
if chars.len() <= 4 {
|
|
return "<redacted>".to_string();
|
|
}
|
|
let last4: String = chars[chars.len() - 4..].iter().collect();
|
|
format!("...{last4}")
|
|
}
|
|
|
|
fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
|
|
run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
|
|
}
|
|
|
|
fn run_auth_command_with_secrets(
|
|
store: &mut ConfigStore,
|
|
command: AuthCommand,
|
|
secrets: &Secrets,
|
|
) -> Result<()> {
|
|
match command {
|
|
AuthCommand::Status { provider } => {
|
|
match provider {
|
|
Some(p) => {
|
|
let provider: ProviderKind = p.into();
|
|
for line in auth_status_lines_for_provider(store, secrets, provider) {
|
|
println!("{line}");
|
|
}
|
|
}
|
|
None => {
|
|
for line in auth_status_all_providers(store, secrets) {
|
|
println!("{line}");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
AuthCommand::Set {
|
|
provider,
|
|
api_key,
|
|
api_key_stdin,
|
|
} => {
|
|
let provider: ProviderKind = provider.into();
|
|
let slot = provider_slot(provider);
|
|
if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
|
|
let provider_cfg = store.config.providers.for_provider_mut(provider);
|
|
if provider_cfg.base_url.is_none() {
|
|
provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
|
|
}
|
|
store.save()?;
|
|
println!(
|
|
"configured {slot} provider in {} (API key optional)",
|
|
store.path().display()
|
|
);
|
|
return Ok(());
|
|
}
|
|
let api_key = match (api_key, api_key_stdin) {
|
|
(Some(v), _) => v,
|
|
(None, true) => read_api_key_from_stdin()?,
|
|
(None, false) => prompt_api_key(slot)?,
|
|
};
|
|
write_provider_api_key_to_config(store, provider, &api_key);
|
|
let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
|
|
store.save()?;
|
|
// Don't print the key. Don't echo length.
|
|
if keyring_saved {
|
|
println!(
|
|
"saved API key for {slot} to {} and {}",
|
|
store.path().display(),
|
|
secrets.backend_name()
|
|
);
|
|
} else {
|
|
println!("saved API key for {slot} to {}", store.path().display());
|
|
}
|
|
Ok(())
|
|
}
|
|
AuthCommand::Get { provider } => {
|
|
let provider: ProviderKind = provider.into();
|
|
let slot = provider_slot(provider);
|
|
let in_file = provider_config_set(store, provider);
|
|
let in_keyring = !in_file && provider_keyring_set(secrets, provider);
|
|
let in_env = provider_env_set(provider);
|
|
// Report the highest-priority source that has it.
|
|
let source = if in_file {
|
|
Some("config-file")
|
|
} else if in_keyring {
|
|
Some("secret-store")
|
|
} else if in_env {
|
|
Some("env")
|
|
} else {
|
|
None
|
|
};
|
|
match source {
|
|
Some(source) => println!("{slot}: set (source: {source})"),
|
|
None => println!("{slot}: not set"),
|
|
}
|
|
Ok(())
|
|
}
|
|
AuthCommand::Clear { provider } => {
|
|
let provider: ProviderKind = provider.into();
|
|
let slot = provider_slot(provider);
|
|
clear_provider_api_key_from_config(store, provider);
|
|
clear_provider_api_key_from_keyring(secrets, provider);
|
|
store.save()?;
|
|
println!("cleared API key for {slot} from config and secret store");
|
|
Ok(())
|
|
}
|
|
AuthCommand::List => {
|
|
println!("provider config store env active");
|
|
for provider in PROVIDER_LIST {
|
|
let slot = provider_slot(provider);
|
|
let file = provider_config_set(store, provider);
|
|
let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
|
|
let env = provider_env_set(provider);
|
|
let active = if file {
|
|
"config"
|
|
} else if keyring == Some(true) {
|
|
"store"
|
|
} else if env {
|
|
"env"
|
|
} else {
|
|
"missing"
|
|
};
|
|
println!(
|
|
"{slot:<12} {} {} {} {active}",
|
|
yes_no(file),
|
|
keyring_status_short(keyring),
|
|
yes_no(env)
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
|
|
}
|
|
}
|
|
|
|
fn yes_no(b: bool) -> &'static str {
|
|
if b { "yes" } else { "no " }
|
|
}
|
|
|
|
fn keyring_status_short(state: Option<bool>) -> &'static str {
|
|
match state {
|
|
Some(true) => "yes",
|
|
Some(false) => "no ",
|
|
None => "n/a",
|
|
}
|
|
}
|
|
|
|
fn prompt_api_key(slot: &str) -> Result<String> {
|
|
use std::io::{IsTerminal, Write};
|
|
eprint!("Enter API key for {slot}: ");
|
|
io::stderr().flush().ok();
|
|
if !io::stdin().is_terminal() {
|
|
// Non-interactive: read directly without prompting twice.
|
|
return read_api_key_from_stdin();
|
|
}
|
|
let mut buf = String::new();
|
|
io::stdin()
|
|
.read_line(&mut buf)
|
|
.context("failed to read API key from stdin")?;
|
|
let key = buf.trim().to_string();
|
|
if key.is_empty() {
|
|
bail!("empty API key provided");
|
|
}
|
|
Ok(key)
|
|
}
|
|
|
|
/// Move plaintext keys from config.toml into the configured secret store.
|
|
/// Hidden in v0.8.8 because the normal setup path is config/env only.
|
|
fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
|
|
let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
|
|
let mut warnings: Vec<String> = Vec::new();
|
|
|
|
for provider in PROVIDER_LIST {
|
|
let slot = provider_slot(provider);
|
|
let from_provider_block = store
|
|
.config
|
|
.providers
|
|
.for_provider(provider)
|
|
.api_key
|
|
.clone()
|
|
.filter(|v| !v.trim().is_empty());
|
|
let from_root = (provider == ProviderKind::Deepseek)
|
|
.then(|| store.config.api_key.clone())
|
|
.flatten()
|
|
.filter(|v| !v.trim().is_empty());
|
|
let value = from_provider_block.or(from_root);
|
|
let Some(value) = value else { continue };
|
|
|
|
if let Ok(Some(existing)) = secrets.get(slot)
|
|
&& existing == value
|
|
{
|
|
// Already migrated; safe to strip the file slot.
|
|
} else if dry_run {
|
|
migrated.push((provider, slot));
|
|
continue;
|
|
} else if let Err(err) = secrets.set(slot, &value) {
|
|
warnings.push(format!(
|
|
"skipped {slot}: failed to write to secret store: {err}"
|
|
));
|
|
continue;
|
|
}
|
|
if !dry_run {
|
|
store.config.providers.for_provider_mut(provider).api_key = None;
|
|
if provider == ProviderKind::Deepseek {
|
|
store.config.api_key = None;
|
|
}
|
|
}
|
|
migrated.push((provider, slot));
|
|
}
|
|
|
|
if !dry_run && !migrated.is_empty() {
|
|
store
|
|
.save()
|
|
.context("failed to write updated config.toml")?;
|
|
}
|
|
|
|
println!("secret store backend: {}", secrets.backend_name());
|
|
if migrated.is_empty() {
|
|
println!("nothing to migrate (config.toml has no plaintext api_key entries)");
|
|
} else {
|
|
println!(
|
|
"{} {} provider key(s):",
|
|
if dry_run { "would migrate" } else { "migrated" },
|
|
migrated.len()
|
|
);
|
|
for (_, slot) in &migrated {
|
|
println!(" - {slot}");
|
|
}
|
|
if !dry_run {
|
|
println!(
|
|
"config.toml at {} no longer contains api_key entries for migrated providers.",
|
|
store.path().display()
|
|
);
|
|
}
|
|
}
|
|
for w in warnings {
|
|
eprintln!("warning: {w}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
|
|
match command {
|
|
ConfigCommand::Get { key } => {
|
|
if let Some(value) = store.config.get_display_value(&key) {
|
|
println!("{value}");
|
|
return Ok(());
|
|
}
|
|
bail!("key not found: {key}");
|
|
}
|
|
ConfigCommand::Set { key, value } => {
|
|
store.config.set_value(&key, &value)?;
|
|
store.save()?;
|
|
println!("set {key}");
|
|
Ok(())
|
|
}
|
|
ConfigCommand::Unset { key } => {
|
|
store.config.unset_value(&key)?;
|
|
store.save()?;
|
|
println!("unset {key}");
|
|
Ok(())
|
|
}
|
|
ConfigCommand::List => {
|
|
for (key, value) in store.config.list_values() {
|
|
println!("{key} = {value}");
|
|
}
|
|
Ok(())
|
|
}
|
|
ConfigCommand::Path => {
|
|
println!("{}", store.path().display());
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_model_command(command: ModelCommand) -> Result<()> {
|
|
let registry = ModelRegistry::default();
|
|
match command {
|
|
ModelCommand::List { provider } => {
|
|
let filter = provider.map(ProviderKind::from);
|
|
for model in registry.list().into_iter().filter(|m| match filter {
|
|
Some(p) => m.provider == p,
|
|
None => true,
|
|
}) {
|
|
println!("{} ({})", model.id, model.provider.as_str());
|
|
}
|
|
Ok(())
|
|
}
|
|
ModelCommand::Resolve { model, provider } => {
|
|
let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
|
|
println!("requested: {}", resolved.requested.unwrap_or_default());
|
|
println!("resolved: {}", resolved.resolved.id);
|
|
println!("provider: {}", resolved.resolved.provider.as_str());
|
|
println!("used_fallback: {}", resolved.used_fallback);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_thread_command(command: ThreadCommand) -> Result<()> {
|
|
let state = StateStore::open(None)?;
|
|
match command {
|
|
ThreadCommand::List { all, limit } => {
|
|
let threads = state.list_threads(ThreadListFilters {
|
|
include_archived: all,
|
|
limit,
|
|
})?;
|
|
for thread in threads {
|
|
println!(
|
|
"{} | {} | {} | {}",
|
|
thread.id,
|
|
thread
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "(unnamed)".to_string()),
|
|
thread.model_provider,
|
|
thread.cwd.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
ThreadCommand::Read { thread_id } => {
|
|
let thread = state.get_thread(&thread_id)?;
|
|
println!("{}", serde_json::to_string_pretty(&thread)?);
|
|
Ok(())
|
|
}
|
|
ThreadCommand::Resume { thread_id } => {
|
|
let args = vec!["resume".to_string(), thread_id];
|
|
delegate_simple_tui(args)
|
|
}
|
|
ThreadCommand::Fork { thread_id } => {
|
|
let args = vec!["fork".to_string(), thread_id];
|
|
delegate_simple_tui(args)
|
|
}
|
|
ThreadCommand::Archive { thread_id } => {
|
|
state.mark_archived(&thread_id)?;
|
|
println!("archived {thread_id}");
|
|
Ok(())
|
|
}
|
|
ThreadCommand::Unarchive { thread_id } => {
|
|
state.mark_unarchived(&thread_id)?;
|
|
println!("unarchived {thread_id}");
|
|
Ok(())
|
|
}
|
|
ThreadCommand::SetName { thread_id, name } => {
|
|
let mut thread = state
|
|
.get_thread(&thread_id)?
|
|
.with_context(|| format!("thread not found: {thread_id}"))?;
|
|
thread.name = Some(name);
|
|
thread.updated_at = chrono::Utc::now().timestamp();
|
|
state.upsert_thread(&thread)?;
|
|
println!("renamed {thread_id}");
|
|
Ok(())
|
|
}
|
|
ThreadCommand::ClearName { thread_id } => {
|
|
let mut thread = state
|
|
.get_thread(&thread_id)?
|
|
.with_context(|| format!("thread not found: {thread_id}"))?;
|
|
thread.name = None;
|
|
thread.updated_at = chrono::Utc::now().timestamp();
|
|
state.upsert_thread(&thread)?;
|
|
println!("cleared name for {thread_id}");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
|
|
match command {
|
|
SandboxCommand::Check { command, ask } => {
|
|
let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
let decision = engine.check(ExecPolicyContext {
|
|
command: &command,
|
|
cwd: &cwd.display().to_string(),
|
|
tool: Some("exec_shell"),
|
|
path: None,
|
|
ask_for_approval: ask.into(),
|
|
sandbox_mode: Some("workspace-write"),
|
|
})?;
|
|
println!("{}", serde_json::to_string_pretty(&decision)?);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_app_server_command(args: AppServerArgs) -> Result<()> {
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.context("failed to create tokio runtime")?;
|
|
if args.stdio {
|
|
return runtime.block_on(run_app_server_stdio(args.config));
|
|
}
|
|
let listen: SocketAddr = format!("{}:{}", args.host, args.port)
|
|
.parse()
|
|
.with_context(|| {
|
|
format!(
|
|
"invalid app-server listen address {}:{}",
|
|
args.host, args.port
|
|
)
|
|
})?;
|
|
runtime.block_on(run_app_server(AppServerOptions {
|
|
listen,
|
|
config_path: args.config,
|
|
auth_token: args.auth_token.or_else(app_server_token_from_env),
|
|
insecure_no_auth: args.insecure_no_auth,
|
|
cors_origins: args.cors_origin,
|
|
}))
|
|
}
|
|
|
|
fn app_server_token_from_env() -> Option<String> {
|
|
std::env::var("CODEWHALE_APP_SERVER_TOKEN")
|
|
.ok()
|
|
.or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
|
|
}
|
|
|
|
fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
|
|
let persisted = load_mcp_server_definitions(store);
|
|
let updated = run_stdio_server(persisted)?;
|
|
persist_mcp_server_definitions(store, &updated)
|
|
}
|
|
|
|
fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
|
|
let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
match parse_mcp_server_definitions(&raw) {
|
|
Ok(definitions) => definitions,
|
|
Err(err) => {
|
|
eprintln!(
|
|
"warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
|
|
if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
|
|
return Ok(parsed);
|
|
}
|
|
|
|
let unwrapped: String = serde_json::from_str(raw)
|
|
.with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
|
|
serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
|
|
format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
|
|
})
|
|
}
|
|
|
|
fn persist_mcp_server_definitions(
|
|
store: &mut ConfigStore,
|
|
definitions: &[McpServerDefinition],
|
|
) -> Result<()> {
|
|
let encoded =
|
|
serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
|
|
store
|
|
.config
|
|
.set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
|
|
store.save()
|
|
}
|
|
|
|
fn delegate_to_tui(
|
|
cli: &Cli,
|
|
resolved_runtime: &ResolvedRuntimeOptions,
|
|
passthrough: Vec<String>,
|
|
) -> Result<()> {
|
|
let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
|
|
let tui = PathBuf::from(cmd.get_program());
|
|
let status = cmd
|
|
.status()
|
|
.map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
|
|
exit_with_tui_status(status)
|
|
}
|
|
|
|
fn run_resume_command(
|
|
cli: &Cli,
|
|
resolved_runtime: &ResolvedRuntimeOptions,
|
|
args: TuiPassthroughArgs,
|
|
) -> Result<()> {
|
|
let passthrough = tui_args("resume", args);
|
|
if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
|
|
return run_dispatcher_resume_picker(cli, resolved_runtime);
|
|
}
|
|
delegate_to_tui(cli, resolved_runtime, passthrough)
|
|
}
|
|
|
|
fn run_dispatcher_resume_picker(
|
|
cli: &Cli,
|
|
resolved_runtime: &ResolvedRuntimeOptions,
|
|
) -> Result<()> {
|
|
let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
|
|
let tui = PathBuf::from(sessions_cmd.get_program());
|
|
let status = sessions_cmd
|
|
.status()
|
|
.map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
|
|
if !status.success() {
|
|
return exit_with_tui_status(status);
|
|
}
|
|
|
|
println!();
|
|
println!("Windows note: enter a session id or prefix from the list above.");
|
|
println!("You can also run `codewhale resume --last` to skip this prompt.");
|
|
print!("Session id/prefix (Enter to cancel): ");
|
|
io::stdout().flush()?;
|
|
|
|
let mut input = String::new();
|
|
io::stdin()
|
|
.read_line(&mut input)
|
|
.context("failed to read session selection")?;
|
|
let session_id = input.trim();
|
|
if session_id.is_empty() {
|
|
bail!("No session selected.");
|
|
}
|
|
|
|
delegate_to_tui(
|
|
cli,
|
|
resolved_runtime,
|
|
vec!["resume".to_string(), session_id.to_string()],
|
|
)
|
|
}
|
|
|
|
fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
|
|
is_windows && passthrough == ["resume"]
|
|
}
|
|
|
|
fn build_tui_command(
|
|
cli: &Cli,
|
|
resolved_runtime: &ResolvedRuntimeOptions,
|
|
passthrough: Vec<String>,
|
|
) -> Result<Command> {
|
|
let tui = locate_sibling_tui_binary()?;
|
|
|
|
let mut cmd = Command::new(&tui);
|
|
if let Some(config) = cli.config.as_ref() {
|
|
cmd.arg("--config").arg(config);
|
|
}
|
|
if let Some(profile) = cli.profile.as_ref() {
|
|
cmd.arg("--profile").arg(profile);
|
|
}
|
|
if let Some(workspace) = cli.workspace.as_ref() {
|
|
cmd.arg("--workspace").arg(workspace);
|
|
}
|
|
// Accepted for older scripts, but no longer forwarded: the interactive TUI
|
|
// always owns the alternate screen to avoid host scrollback hijacking.
|
|
let _ = cli.no_alt_screen;
|
|
if cli.mouse_capture {
|
|
cmd.arg("--mouse-capture");
|
|
}
|
|
if cli.no_mouse_capture {
|
|
cmd.arg("--no-mouse-capture");
|
|
}
|
|
if cli.skip_onboarding {
|
|
cmd.arg("--skip-onboarding");
|
|
}
|
|
cmd.args(passthrough);
|
|
|
|
if !provider_is_supported_by_tui(resolved_runtime.provider) {
|
|
let source_hint = if cli.provider.is_some() {
|
|
"set via --provider flag"
|
|
} else {
|
|
"resolved from config file or environment"
|
|
};
|
|
bail!(
|
|
"The interactive TUI does not support provider '{}' ({}).\n\
|
|
\n\
|
|
Supported TUI providers: deepseek, openai, ollama, openrouter, nvidia-nim, \n\
|
|
volcengine, siliconflow, moonshot, arcee, fireworks, novita, xiaomi-mimo,\n\
|
|
huggingface, sglang, vllm, atlascloud, wanjie-ark, together, openai-codex.\n\
|
|
\n\
|
|
To fix:\n\
|
|
- Set a supported provider in your config file (~/.codewhale/config.toml)\n\
|
|
under [providers.<id>] with an api_key, or\n\
|
|
- Pass --provider <supported-id> on the command line, or\n\
|
|
- Run `codewhale exec --provider <supported-id> \"your prompt\"` for a\n\
|
|
one-shot non-interactive session with this provider.",
|
|
resolved_runtime.provider.as_str(),
|
|
source_hint,
|
|
);
|
|
}
|
|
|
|
if let Some(provider) = cli.provider {
|
|
let provider: ProviderKind = provider.into();
|
|
cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
|
|
}
|
|
if matches!(
|
|
resolved_runtime.api_key_source,
|
|
Some(RuntimeApiKeySource::Keyring)
|
|
) && let Some(api_key) = resolved_runtime.api_key.as_ref()
|
|
{
|
|
// TUI reloads auth_mode from config/profile, but it does not re-query the
|
|
// platform keyring on normal startup. Bridge only the recovered secret;
|
|
// replaying auth_mode here would turn it back into a profile override.
|
|
cmd.env("DEEPSEEK_API_KEY", api_key);
|
|
for var in provider_env_vars(resolved_runtime.provider) {
|
|
if *var != "DEEPSEEK_API_KEY" {
|
|
cmd.env(var, api_key);
|
|
}
|
|
}
|
|
cmd.env(
|
|
"DEEPSEEK_API_KEY_SOURCE",
|
|
RuntimeApiKeySource::Keyring.as_env_value(),
|
|
);
|
|
}
|
|
|
|
if let Some(model) = cli.model.as_ref() {
|
|
cmd.env("DEEPSEEK_MODEL", model);
|
|
}
|
|
if let Some(output_mode) = cli.output_mode.as_ref() {
|
|
cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
|
|
}
|
|
if let Some(log_level) = cli.log_level.as_ref() {
|
|
cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
|
|
}
|
|
if let Some(telemetry) = cli.telemetry {
|
|
cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
|
|
}
|
|
if let Some(policy) = cli.approval_policy.as_ref() {
|
|
cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
|
|
}
|
|
if let Some(mode) = cli.sandbox_mode.as_ref() {
|
|
cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
|
|
}
|
|
if cli.yolo {
|
|
cmd.env("DEEPSEEK_YOLO", "true");
|
|
}
|
|
if let Some(api_key) = cli.api_key.as_ref() {
|
|
cmd.env("DEEPSEEK_API_KEY", api_key);
|
|
if resolved_runtime.provider == ProviderKind::Openai {
|
|
cmd.env("OPENAI_API_KEY", api_key);
|
|
}
|
|
if resolved_runtime.provider == ProviderKind::Atlascloud {
|
|
cmd.env("ATLASCLOUD_API_KEY", api_key);
|
|
}
|
|
if resolved_runtime.provider == ProviderKind::WanjieArk {
|
|
cmd.env("WANJIE_ARK_API_KEY", api_key);
|
|
}
|
|
if resolved_runtime.provider == ProviderKind::Volcengine {
|
|
cmd.env("VOLCENGINE_API_KEY", api_key);
|
|
}
|
|
if resolved_runtime.provider == ProviderKind::Siliconflow {
|
|
cmd.env("SILICONFLOW_API_KEY", api_key);
|
|
}
|
|
cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
|
|
}
|
|
if let Some(base_url) = cli.base_url.as_ref() {
|
|
cmd.env("DEEPSEEK_BASE_URL", base_url);
|
|
}
|
|
|
|
Ok(cmd)
|
|
}
|
|
|
|
fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
|
|
match status.code() {
|
|
Some(code) => std::process::exit(code),
|
|
None => bail!("codewhale-tui terminated by signal"),
|
|
}
|
|
}
|
|
|
|
fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
|
|
let tui = locate_sibling_tui_binary()?;
|
|
let status = Command::new(&tui)
|
|
.args(args)
|
|
.status()
|
|
.map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
|
|
match status.code() {
|
|
Some(code) => std::process::exit(code),
|
|
None => bail!("codewhale-tui terminated by signal"),
|
|
}
|
|
}
|
|
|
|
fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
|
|
format!(
|
|
"failed to spawn companion TUI binary at {}: {err}\n\
|
|
\n\
|
|
The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
|
|
to execute it. Common fixes:\n\
|
|
- Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
|
|
- On Windows, run `where codewhale` and `where codewhale-tui`; both should \
|
|
come from the same install directory.\n\
|
|
- If you downloaded release assets manually, keep both `codewhale` and \
|
|
`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
|
|
- Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
|
|
binary.",
|
|
tui.display()
|
|
)
|
|
}
|
|
|
|
/// Resolve the sibling `codewhale-tui` executable next to the running
|
|
/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
|
|
/// the npm-distributed Windows package — which ships
|
|
/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
|
|
///
|
|
/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
|
|
/// custom installs and CI test layouts. On Windows we additionally try
|
|
/// the suffix-less name as a fallback for users who already manually
|
|
/// renamed the file before this fix landed.
|
|
fn locate_sibling_tui_binary() -> Result<PathBuf> {
|
|
if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
|
|
let candidate = PathBuf::from(override_path);
|
|
if candidate.is_file() {
|
|
return Ok(candidate);
|
|
}
|
|
bail!(
|
|
"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
|
|
candidate.display()
|
|
);
|
|
}
|
|
|
|
let current = std::env::current_exe().context("failed to locate current executable path")?;
|
|
if let Some(found) = sibling_tui_candidate(¤t) {
|
|
return Ok(found);
|
|
}
|
|
|
|
// Build a stable error path so the user sees the platform-correct
|
|
// expected name, not "codewhale-tui" on Windows.
|
|
let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
|
|
bail!(
|
|
"Companion `codewhale-tui` binary not found at {}.\n\
|
|
\n\
|
|
The `codewhale` dispatcher delegates interactive sessions to a sibling \
|
|
`codewhale-tui` binary. To fix this, install one of:\n\
|
|
• npm: npm install -g codewhale (downloads both binaries)\n\
|
|
• cargo: cargo install codewhale-cli codewhale-tui --locked\n\
|
|
• GitHub Releases: download BOTH `codewhale-<platform>` AND \
|
|
`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
|
|
and place them in the same directory.\n\
|
|
\n\
|
|
Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
|
|
expected.display()
|
|
);
|
|
}
|
|
|
|
/// Return the first existing sibling-binary path under any of the names
|
|
/// `codewhale-tui` might use on this platform. Pure function to keep
|
|
/// `locate_sibling_tui_binary` testable.
|
|
fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
|
|
// Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
|
|
// on Windows.
|
|
let primary =
|
|
dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
|
|
if primary.is_file() {
|
|
return Some(primary);
|
|
}
|
|
// Windows fallback: a user who manually renamed `.exe` away (per the
|
|
// workaround in #247) still launches successfully under the new code.
|
|
if cfg!(windows) {
|
|
let suffixless = dispatcher.with_file_name("codewhale-tui");
|
|
if suffixless.is_file() {
|
|
return Some(suffixless);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn run_metrics_command(args: MetricsArgs) -> Result<()> {
|
|
let since = match args.since.as_deref() {
|
|
Some(s) => {
|
|
Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
|
|
}
|
|
None => None,
|
|
};
|
|
metrics::run(metrics::MetricsArgs {
|
|
json: args.json,
|
|
since,
|
|
})
|
|
}
|
|
|
|
fn read_api_key_from_stdin() -> Result<String> {
|
|
let mut input = String::new();
|
|
io::stdin()
|
|
.read_to_string(&mut input)
|
|
.context("failed to read api key from stdin")?;
|
|
let key = input.trim().to_string();
|
|
if key.is_empty() {
|
|
bail!("empty API key provided");
|
|
}
|
|
Ok(key)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::error::ErrorKind;
|
|
use std::ffi::OsString;
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
fn parse_ok(argv: &[&str]) -> Cli {
|
|
Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
|
|
}
|
|
|
|
fn help_for(argv: &[&str]) -> String {
|
|
let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
|
|
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
|
|
err.to_string()
|
|
}
|
|
|
|
fn command_env(cmd: &Command, name: &str) -> Option<String> {
|
|
let name = std::ffi::OsStr::new(name);
|
|
cmd.get_envs().find_map(|(key, value)| {
|
|
if key == name {
|
|
value.map(|v| v.to_string_lossy().into_owned())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|p| p.into_inner())
|
|
}
|
|
|
|
struct ScopedEnvVar {
|
|
name: &'static str,
|
|
previous: Option<OsString>,
|
|
}
|
|
|
|
impl ScopedEnvVar {
|
|
fn set(name: &'static str, value: &str) -> Self {
|
|
let previous = std::env::var_os(name);
|
|
// Safety: tests using this helper serialize with env_lock() and
|
|
// restore the original value in Drop.
|
|
unsafe { std::env::set_var(name, value) };
|
|
Self { name, previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for ScopedEnvVar {
|
|
fn drop(&mut self) {
|
|
// Safety: tests using this helper serialize with env_lock().
|
|
unsafe {
|
|
if let Some(previous) = self.previous.take() {
|
|
std::env::set_var(self.name, previous);
|
|
} else {
|
|
std::env::remove_var(self.name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clap_command_definition_is_consistent() {
|
|
Cli::command().debug_assert();
|
|
}
|
|
|
|
// Regression for #767: `run_cli` prints the full anyhow chain so users
|
|
// see the underlying TOML parser error (line/column, expected token)
|
|
// instead of just the top-level "failed to parse config at <path>"
|
|
// wrapper. anyhow's bare `Display` impl drops the chain — pin both
|
|
// pieces here so a future refactor of the printing path doesn't
|
|
// silently regress.
|
|
#[test]
|
|
fn anyhow_chain_surfaces_toml_parse_cause() {
|
|
use anyhow::Context;
|
|
let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
|
|
let err = Err::<(), _>(inner)
|
|
.context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
|
|
.unwrap_err();
|
|
|
|
// What `eprintln!("error: {err}")` prints (top context only).
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
|
|
);
|
|
|
|
// What the `for cause in err.chain().skip(1)` loop iterates over.
|
|
let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
|
|
assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_config_command_matrix() {
|
|
let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Config(ConfigArgs {
|
|
command: ConfigCommand::Get { ref key }
|
|
})) if key == "provider"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Config(ConfigArgs {
|
|
command: ConfigCommand::Set { ref key, ref value }
|
|
})) if key == "model" && value == "deepseek-v4-flash"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Config(ConfigArgs {
|
|
command: ConfigCommand::Unset { ref key }
|
|
})) if key == "model"
|
|
));
|
|
|
|
assert!(matches!(
|
|
parse_ok(&["deepseek", "config", "list"]).command,
|
|
Some(Commands::Config(ConfigArgs {
|
|
command: ConfigCommand::List
|
|
}))
|
|
));
|
|
assert!(matches!(
|
|
parse_ok(&["deepseek", "config", "path"]).command,
|
|
Some(Commands::Config(ConfigArgs {
|
|
command: ConfigCommand::Path
|
|
}))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_update_beta_flag() {
|
|
let cli = parse_ok(&["codewhale", "update"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Update(UpdateArgs {
|
|
beta: false,
|
|
check: false,
|
|
proxy: None
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["codewhale", "update", "--beta"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Update(UpdateArgs {
|
|
beta: true,
|
|
check: false,
|
|
proxy: None
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["codewhale", "update", "--check"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Update(UpdateArgs {
|
|
beta: false,
|
|
check: true,
|
|
proxy: None
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
|
|
let Some(Commands::Update(args)) = cli.command else {
|
|
panic!("expected update command");
|
|
};
|
|
assert!(!args.beta);
|
|
assert!(!args.check);
|
|
assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_model_command_matrix() {
|
|
let cli = parse_ok(&["deepseek", "model", "list"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::List { provider: None }
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::List {
|
|
provider: Some(ProviderArg::Openai)
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::Resolve {
|
|
model: Some(ref model),
|
|
provider: None
|
|
}
|
|
})) if model == "deepseek-v4-flash"
|
|
));
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"model",
|
|
"resolve",
|
|
"--provider",
|
|
"deepseek",
|
|
"deepseek-v4-pro",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::Resolve {
|
|
model: Some(ref model),
|
|
provider: Some(ProviderArg::Deepseek)
|
|
}
|
|
})) if model == "deepseek-v4-pro"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_thread_command_matrix() {
|
|
let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::List {
|
|
all: true,
|
|
limit: Some(50)
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Read { ref thread_id }
|
|
})) if thread_id == "thread-1"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Resume { ref thread_id }
|
|
})) if thread_id == "thread-2"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Fork { ref thread_id }
|
|
})) if thread_id == "thread-3"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Archive { ref thread_id }
|
|
})) if thread_id == "thread-4"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Unarchive { ref thread_id }
|
|
})) if thread_id == "thread-5"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::SetName {
|
|
ref thread_id,
|
|
ref name
|
|
}
|
|
})) if thread_id == "thread-6" && name == "My Thread"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::ClearName { ref thread_id }
|
|
})) if thread_id == "thread-7"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_sandbox_app_server_and_completion_matrix() {
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"sandbox",
|
|
"check",
|
|
"echo hello",
|
|
"--ask",
|
|
"on-failure",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Sandbox(SandboxArgs {
|
|
command: SandboxCommand::Check {
|
|
ref command,
|
|
ask: ApprovalModeArg::OnFailure
|
|
}
|
|
})) if command == "echo hello"
|
|
));
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"app-server",
|
|
"--host",
|
|
"0.0.0.0",
|
|
"--port",
|
|
"9999",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::AppServer(AppServerArgs {
|
|
ref host,
|
|
port: 9999,
|
|
stdio: false,
|
|
..
|
|
})) if host == "0.0.0.0"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "completion", "bash"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Completion { shell: Shell::Bash })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_direct_tui_command_aliases() {
|
|
let cli = parse_ok(&["deepseek", "doctor"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "models", "--json"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "resume", "abc123"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Setup(TuiPassthroughArgs { ref args }))
|
|
if args == &["--skills", "--local"]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
|
|
assert!(should_pick_resume_in_dispatcher(
|
|
&["resume".to_string()],
|
|
true
|
|
));
|
|
assert!(!should_pick_resume_in_dispatcher(
|
|
&["resume".to_string(), "--last".to_string()],
|
|
true
|
|
));
|
|
assert!(!should_pick_resume_in_dispatcher(
|
|
&["resume".to_string(), "abc123".to_string()],
|
|
true
|
|
));
|
|
assert!(!should_pick_resume_in_dispatcher(
|
|
&["resume".to_string()],
|
|
false
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-login-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
let secrets = no_keyring_secrets();
|
|
|
|
run_login_command_with_secrets(
|
|
&mut store,
|
|
LoginArgs {
|
|
provider: Some(ProviderArg::Deepseek),
|
|
api_key: Some("sk-test".to_string()),
|
|
},
|
|
&secrets,
|
|
)
|
|
.expect("login should write config");
|
|
|
|
assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
|
|
assert_eq!(
|
|
store.config.providers.deepseek.api_key.as_deref(),
|
|
Some("sk-test")
|
|
);
|
|
assert_eq!(
|
|
store.config.default_text_model.as_deref(),
|
|
Some("deepseek-v4-pro")
|
|
);
|
|
let saved = std::fs::read_to_string(&path).expect("config should be written");
|
|
assert!(saved.contains("api_key = \"sk-test\""));
|
|
assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_auth_subcommand_matrix() {
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Deepseek,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"auth",
|
|
"set",
|
|
"--provider",
|
|
"openrouter",
|
|
"--api-key-stdin",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Openrouter,
|
|
api_key: None,
|
|
api_key_stdin: true,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Get {
|
|
provider: ProviderArg::Novita
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Clear {
|
|
provider: ProviderArg::NvidiaNim
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Fireworks,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Siliconflow,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Arcee,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Moonshot,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::WanjieArk,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Get {
|
|
provider: ProviderArg::Sglang
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Get {
|
|
provider: ProviderArg::Vllm
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Set {
|
|
provider: ProviderArg::Ollama,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Status {
|
|
provider: Some(ProviderArg::OpenaiCodex)
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "list"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::List
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "migrate"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Migrate { dry_run: false }
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Auth(AuthArgs {
|
|
command: AuthCommand::Migrate { dry_run: true }
|
|
}))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn auth_set_writes_to_shared_config_file() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-set-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
let secrets = Secrets::new(inner.clone());
|
|
|
|
run_auth_command_with_secrets(
|
|
&mut store,
|
|
AuthCommand::Set {
|
|
provider: ProviderArg::Deepseek,
|
|
api_key: Some("sk-keyring".to_string()),
|
|
api_key_stdin: false,
|
|
},
|
|
&secrets,
|
|
)
|
|
.expect("set should succeed");
|
|
|
|
assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
|
|
assert_eq!(
|
|
store.config.providers.deepseek.api_key.as_deref(),
|
|
Some("sk-keyring")
|
|
);
|
|
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
|
assert!(saved.contains("api_key = \"sk-keyring\""));
|
|
assert_eq!(
|
|
inner.get("deepseek").unwrap().as_deref(),
|
|
Some("sk-keyring")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_set_provider_key_does_not_switch_active_provider() {
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.provider = ProviderKind::Deepseek;
|
|
let secrets = no_keyring_secrets();
|
|
|
|
run_auth_command_with_secrets(
|
|
&mut store,
|
|
AuthCommand::Set {
|
|
provider: ProviderArg::Arcee,
|
|
api_key: Some("arcee-key".to_string()),
|
|
api_key_stdin: false,
|
|
},
|
|
&secrets,
|
|
)
|
|
.expect("set should succeed");
|
|
|
|
assert_eq!(store.config.provider, ProviderKind::Deepseek);
|
|
assert_eq!(
|
|
store.config.providers.arcee.api_key.as_deref(),
|
|
Some("arcee-key")
|
|
);
|
|
|
|
let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
|
|
assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
|
|
assert_eq!(
|
|
reloaded.config.providers.arcee.api_key.as_deref(),
|
|
Some("arcee-key")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.provider = ProviderKind::Deepseek;
|
|
let secrets = no_keyring_secrets();
|
|
|
|
run_auth_command_with_secrets(
|
|
&mut store,
|
|
AuthCommand::Set {
|
|
provider: ProviderArg::Ollama,
|
|
api_key: None,
|
|
api_key_stdin: false,
|
|
},
|
|
&secrets,
|
|
)
|
|
.expect("ollama auth set should not require a key");
|
|
|
|
assert_eq!(store.config.provider, ProviderKind::Deepseek);
|
|
assert_eq!(
|
|
store.config.providers.ollama.base_url.as_deref(),
|
|
Some("http://localhost:11434/v1")
|
|
);
|
|
assert_eq!(store.config.providers.ollama.api_key, None);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_clear_removes_from_config() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-clear-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.api_key = Some("sk-stale".to_string());
|
|
store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
|
|
store.save().unwrap();
|
|
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
inner.set("deepseek", "sk-stale").unwrap();
|
|
let secrets = Secrets::new(inner.clone());
|
|
|
|
run_auth_command_with_secrets(
|
|
&mut store,
|
|
AuthCommand::Clear {
|
|
provider: ProviderArg::Deepseek,
|
|
},
|
|
&secrets,
|
|
)
|
|
.expect("clear should succeed");
|
|
|
|
assert!(store.config.api_key.is_none());
|
|
assert!(store.config.providers.deepseek.api_key.is_none());
|
|
assert_eq!(inner.get("deepseek").unwrap(), None);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
|
|
use codewhale_secrets::{KeyringStore, SecretsError};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[derive(Default)]
|
|
struct RecordingStore {
|
|
gets: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl KeyringStore for RecordingStore {
|
|
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
|
self.gets.lock().unwrap().push(key.to_string());
|
|
Ok(None)
|
|
}
|
|
|
|
fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn delete(&self, _key: &str) -> Result<(), SecretsError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn backend_name(&self) -> &'static str {
|
|
"recording"
|
|
}
|
|
}
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.provider = ProviderKind::Deepseek;
|
|
let inner = Arc::new(RecordingStore::default());
|
|
let secrets = Secrets::new(inner.clone());
|
|
|
|
run_auth_command_with_secrets(
|
|
&mut store,
|
|
AuthCommand::Status {
|
|
provider: Some(ProviderArg::Deepseek),
|
|
},
|
|
&secrets,
|
|
)
|
|
.expect("status should succeed");
|
|
run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
|
|
.expect("list should succeed");
|
|
|
|
let probed = inner.gets.lock().unwrap();
|
|
// Scoped status probes only the requested provider.
|
|
assert_eq!(probed[0], "deepseek");
|
|
// List now probes all providers (not just active) to fix the
|
|
// stale keyring-only-for-active-provider bug.
|
|
assert!(probed.len() > 1, "list should probe all providers");
|
|
assert!(
|
|
PROVIDER_LIST
|
|
.iter()
|
|
.all(|p| probed.contains(&provider_slot(*p).to_string())),
|
|
"every known provider should be probed by auth list: {:?}",
|
|
*probed
|
|
);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_reports_all_active_provider_sources_with_last4() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let _lock = env_lock();
|
|
let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.provider = ProviderKind::Deepseek;
|
|
store.config.api_key = Some("sk-config-3333".to_string());
|
|
store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
|
|
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
inner.set("deepseek", "sk-keyring-2222").unwrap();
|
|
let secrets = Secrets::new(inner);
|
|
|
|
let output =
|
|
auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
|
|
|
|
assert!(output.contains("provider: deepseek"));
|
|
assert!(output.contains("active source: config (last4: ...3333)"));
|
|
assert!(output.contains("lookup order: config -> secret store -> env"));
|
|
assert!(output.contains("config file: "));
|
|
assert!(output.contains("set, last4: ...3333"));
|
|
assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
|
|
assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
|
|
assert!(!output.contains("sk-config-3333"));
|
|
assert!(!output.contains("sk-keyring-2222"));
|
|
assert!(!output.contains("sk-env-1111"));
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_all_providers_lists_every_known_provider() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.provider = ProviderKind::Deepseek;
|
|
store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
|
|
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
inner.set("openrouter", "sk-or-test5678").unwrap();
|
|
let secrets = Secrets::new(inner);
|
|
|
|
let output = auth_status_all_providers(&store, &secrets).join("\n");
|
|
|
|
// Should list all known providers
|
|
assert!(output.contains("deepseek"));
|
|
assert!(output.contains("arcee"));
|
|
assert!(output.contains("openrouter"));
|
|
assert!(output.contains("huggingface"));
|
|
assert!(output.contains("ollama"));
|
|
|
|
// Active provider should be marked
|
|
assert!(output.contains("deepseek") && output.contains("*"));
|
|
|
|
// Arcee should show config source
|
|
assert!(output.contains("config"));
|
|
|
|
// Should NOT leak raw keys
|
|
assert!(!output.contains("sk-arcee-test1234"));
|
|
assert!(!output.contains("sk-or-test5678"));
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_openai_codex_reports_codex_oauth_file() {
|
|
use codewhale_secrets::InMemoryKeyringStore;
|
|
use std::sync::Arc;
|
|
|
|
let _lock = env_lock();
|
|
let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
|
|
let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
|
|
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let config_path = dir.path().join("config.toml");
|
|
let auth_path = dir.path().join("auth.json");
|
|
std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
|
|
.expect("write auth file");
|
|
let auth_path_str = auth_path.to_string_lossy().into_owned();
|
|
let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
|
|
|
|
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
|
|
store.config.provider = ProviderKind::OpenaiCodex;
|
|
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
|
|
|
let output =
|
|
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
|
|
|
assert!(output.contains("provider: openai-codex"));
|
|
assert!(output.contains("auth mode: codex_oauth"));
|
|
assert!(output.contains("active source: Codex OAuth file"));
|
|
assert!(output.contains("lookup order: env -> Codex OAuth file"));
|
|
assert!(output.contains(&format!(
|
|
"Codex OAuth file: {} (present)",
|
|
auth_path.display()
|
|
)));
|
|
assert!(!output.contains("secret-token"));
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_scoped_provider_shows_detailed_info() {
|
|
use codewhale_secrets::InMemoryKeyringStore;
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.provider = ProviderKind::Deepseek;
|
|
store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
|
|
|
|
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
|
|
|
let output =
|
|
auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
|
|
|
|
assert!(output.contains("provider: arcee"));
|
|
assert!(output.contains("active source: config (last4: ...9999)"));
|
|
assert!(output.contains("route:"));
|
|
assert!(output.contains("model:"));
|
|
assert!(!output.contains("sk-arcee-9999"));
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_keyring_recovery_self_heals_into_config_file() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
inner.set("deepseek", "ring-key").unwrap();
|
|
let secrets = Secrets::new(inner);
|
|
|
|
let resolved = resolve_runtime_for_dispatch_with_secrets(
|
|
&mut store,
|
|
&CliRuntimeOverrides::default(),
|
|
&secrets,
|
|
);
|
|
|
|
assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
|
|
assert_eq!(
|
|
resolved.api_key_source,
|
|
Some(RuntimeApiKeySource::ConfigFile)
|
|
);
|
|
assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
|
|
assert_eq!(
|
|
store.config.providers.deepseek.api_key.as_deref(),
|
|
Some("ring-key")
|
|
);
|
|
|
|
let saved = std::fs::read_to_string(&path).expect("config should be written");
|
|
assert!(saved.contains("api_key = \"ring-key\""));
|
|
|
|
let resolved_again = resolve_runtime_for_dispatch_with_secrets(
|
|
&mut store,
|
|
&CliRuntimeOverrides::default(),
|
|
&no_keyring_secrets(),
|
|
);
|
|
assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
|
|
assert_eq!(
|
|
resolved_again.api_key_source,
|
|
Some(RuntimeApiKeySource::ConfigFile)
|
|
);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn logout_removes_plaintext_provider_keys() {
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-logout-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.api_key = Some("sk-stale".to_string());
|
|
store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
|
|
store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
|
|
store.save().unwrap();
|
|
|
|
let secrets = no_keyring_secrets();
|
|
|
|
run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
|
|
|
|
assert!(store.config.api_key.is_none());
|
|
assert!(store.config.providers.deepseek.api_key.is_none());
|
|
assert!(store.config.providers.fireworks.api_key.is_none());
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.api_key = Some("sk-deep".to_string());
|
|
store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
|
|
store.config.providers.openrouter.api_key = Some("or-key".to_string());
|
|
store.config.providers.novita.api_key = Some("nv-key".to_string());
|
|
store.save().unwrap();
|
|
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
let secrets = Secrets::new(inner.clone());
|
|
|
|
run_auth_command_with_secrets(
|
|
&mut store,
|
|
AuthCommand::Migrate { dry_run: false },
|
|
&secrets,
|
|
)
|
|
.expect("migrate should succeed");
|
|
|
|
assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
|
|
assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
|
|
assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
|
|
|
|
// Config file must no longer contain the api keys.
|
|
assert!(store.config.api_key.is_none());
|
|
assert!(store.config.providers.deepseek.api_key.is_none());
|
|
assert!(store.config.providers.openrouter.api_key.is_none());
|
|
assert!(store.config.providers.novita.api_key.is_none());
|
|
|
|
let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
|
|
assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
|
|
assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
|
|
assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_migrate_dry_run_does_not_modify_anything() {
|
|
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
|
use std::sync::Arc;
|
|
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
store.config.providers.openrouter.api_key = Some("or-stay".to_string());
|
|
store.save().unwrap();
|
|
|
|
let inner = Arc::new(InMemoryKeyringStore::new());
|
|
let secrets = Secrets::new(inner.clone());
|
|
|
|
run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
|
|
.expect("dry-run should succeed");
|
|
|
|
assert_eq!(inner.get("openrouter").unwrap(), None);
|
|
assert_eq!(
|
|
store.config.providers.openrouter.api_key.as_deref(),
|
|
Some("or-stay")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_global_override_flags() {
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"--provider",
|
|
"openai",
|
|
"--config",
|
|
"/tmp/deepseek.toml",
|
|
"--profile",
|
|
"work",
|
|
"--model",
|
|
"deepseek-v4-pro",
|
|
"--output-mode",
|
|
"json",
|
|
"--log-level",
|
|
"debug",
|
|
"--telemetry",
|
|
"true",
|
|
"--approval-policy",
|
|
"on-request",
|
|
"--sandbox-mode",
|
|
"workspace-write",
|
|
"--base-url",
|
|
"https://openai-compatible.example/v1",
|
|
"--api-key",
|
|
"sk-test",
|
|
"--workspace",
|
|
"/tmp/workspace",
|
|
"--no-alt-screen",
|
|
"--no-mouse-capture",
|
|
"--skip-onboarding",
|
|
"model",
|
|
"resolve",
|
|
"deepseek-v4-pro",
|
|
]);
|
|
|
|
assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
|
|
assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
|
|
assert_eq!(cli.profile.as_deref(), Some("work"));
|
|
assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
|
|
assert_eq!(cli.output_mode.as_deref(), Some("json"));
|
|
assert_eq!(cli.log_level.as_deref(), Some("debug"));
|
|
assert_eq!(cli.telemetry, Some(true));
|
|
assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
|
|
assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
|
|
assert_eq!(
|
|
cli.base_url.as_deref(),
|
|
Some("https://openai-compatible.example/v1")
|
|
);
|
|
assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
|
|
assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
|
|
assert!(cli.no_alt_screen);
|
|
assert!(cli.no_mouse_capture);
|
|
assert!(!cli.mouse_capture);
|
|
assert!(cli.skip_onboarding);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_allows_openai_and_forwards_provider_key() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"--provider",
|
|
"openai",
|
|
"--workspace",
|
|
"/tmp/codewhale-workspace",
|
|
]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::Openai,
|
|
model: "glm-5".to_string(),
|
|
api_key: Some("resolved-openai-key".to_string()),
|
|
api_key_source: Some(RuntimeApiKeySource::Keyring),
|
|
base_url: "https://openai-compatible.example/v4".to_string(),
|
|
auth_mode: Some("api_key".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
|
|
Some("openai")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
|
|
Some("resolved-openai-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "OPENAI_API_KEY").as_deref(),
|
|
Some("resolved-openai-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
|
|
Some("keyring")
|
|
);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect();
|
|
assert!(
|
|
args.windows(2)
|
|
.any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
|
|
"expected workspace forwarding in args: {args:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&["codewhale", "doctor"]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::OpenaiCodex,
|
|
model: "gpt-5.5".to_string(),
|
|
api_key: None,
|
|
api_key_source: None,
|
|
base_url: "https://chatgpt.com/backend-api".to_string(),
|
|
auth_mode: Some("oauth".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
|
|
.expect("openai-codex should be accepted by the facade");
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect();
|
|
assert_eq!(args, vec!["doctor"]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_forwards_explicit_openai_codex_provider() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::OpenaiCodex,
|
|
model: "gpt-5.5".to_string(),
|
|
api_key: None,
|
|
api_key_source: None,
|
|
base_url: "https://chatgpt.com/backend-api".to_string(),
|
|
auth_mode: Some("oauth".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
|
|
.expect("openai-codex should be accepted by the facade");
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
|
|
Some("openai-codex")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&["deepseek", "--profile", "google"]);
|
|
let mut resolved_headers = std::collections::BTreeMap::new();
|
|
resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::Deepseek,
|
|
model: "deepseek-v4-pro".to_string(),
|
|
api_key: Some("config-file-key".to_string()),
|
|
api_key_source: Some(RuntimeApiKeySource::ConfigFile),
|
|
base_url: "https://api.deepseek.com/beta".to_string(),
|
|
auth_mode: Some("api_key".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: resolved_headers,
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
|
|
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect();
|
|
assert!(
|
|
args.windows(2).any(|pair| pair == ["--profile", "google"]),
|
|
"expected profile forwarding in args: {args:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&[
|
|
"codewhale",
|
|
"--provider",
|
|
"moonshot",
|
|
"--model",
|
|
"kimi-k2.6",
|
|
"--workspace",
|
|
"/tmp/codewhale-workspace",
|
|
]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::Moonshot,
|
|
model: "kimi-k2.6".to_string(),
|
|
api_key: Some("resolved-kimi-key".to_string()),
|
|
api_key_source: Some(RuntimeApiKeySource::Keyring),
|
|
base_url: "https://api.moonshot.ai/v1".to_string(),
|
|
auth_mode: Some("api_key".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
|
|
Some("moonshot")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
|
|
Some("kimi-k2.6")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
|
|
Some("resolved-kimi-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
|
|
Some("resolved-kimi-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "KIMI_API_KEY").as_deref(),
|
|
Some("resolved-kimi-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
|
|
Some("keyring")
|
|
);
|
|
assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&[
|
|
"codewhale",
|
|
"--provider",
|
|
"volcengine",
|
|
"--model",
|
|
"DeepSeek-V4-Pro",
|
|
"--workspace",
|
|
"/tmp/codewhale-workspace",
|
|
]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::Volcengine,
|
|
model: "DeepSeek-V4-Pro".to_string(),
|
|
api_key: Some("resolved-ark-key".to_string()),
|
|
api_key_source: Some(RuntimeApiKeySource::Keyring),
|
|
base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
|
|
auth_mode: Some("api_key".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
|
|
Some("volcengine")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
|
|
Some("DeepSeek-V4-Pro")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
|
|
Some("resolved-ark-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
|
|
Some("resolved-ark-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
|
|
Some("resolved-ark-key")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "ARK_API_KEY").as_deref(),
|
|
Some("resolved-ark-key")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_exports_explicit_provider_model_and_base_url() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"--profile",
|
|
"google",
|
|
"--provider",
|
|
"openai",
|
|
"--model",
|
|
"glm-5",
|
|
"--base-url",
|
|
"https://openai-compatible.example/v4",
|
|
]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider: ProviderKind::Openai,
|
|
model: "glm-5".to_string(),
|
|
api_key: None,
|
|
api_key_source: None,
|
|
base_url: "https://openai-compatible.example/v4".to_string(),
|
|
auth_mode: None,
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
|
|
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
|
|
Some("openai")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
|
|
Some("glm-5")
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
|
|
Some("https://openai-compatible.example/v4")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
// (provider, cli flag, extra env vars that must be forwarded besides DEEPSEEK_API_KEY)
|
|
let cases: &[(ProviderKind, &str, &[&str])] = &[
|
|
(
|
|
ProviderKind::Openrouter,
|
|
"openrouter",
|
|
&["OPENROUTER_API_KEY"],
|
|
),
|
|
(
|
|
ProviderKind::XiaomiMimo,
|
|
"xiaomi-mimo",
|
|
&["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
|
|
),
|
|
(ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]),
|
|
(
|
|
ProviderKind::NvidiaNim,
|
|
"nvidia-nim",
|
|
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
|
|
),
|
|
(ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]),
|
|
(
|
|
ProviderKind::Siliconflow,
|
|
"siliconflow",
|
|
&["SILICONFLOW_API_KEY"],
|
|
),
|
|
(ProviderKind::Arcee, "arcee", &["ARCEE_API_KEY"]),
|
|
(ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]),
|
|
(ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]),
|
|
(ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]),
|
|
(
|
|
ProviderKind::Atlascloud,
|
|
"atlascloud",
|
|
&["ATLASCLOUD_API_KEY"],
|
|
),
|
|
(
|
|
ProviderKind::WanjieArk,
|
|
"wanjie-ark",
|
|
&[
|
|
"WANJIE_ARK_API_KEY",
|
|
"WANJIE_API_KEY",
|
|
"WANJIE_MAAS_API_KEY",
|
|
],
|
|
),
|
|
];
|
|
|
|
for &(provider, flag, expected_vars) in cases {
|
|
let cli = parse_ok(&[
|
|
"codewhale",
|
|
"--provider",
|
|
flag,
|
|
"--workspace",
|
|
"/tmp/codewhale-workspace",
|
|
]);
|
|
let resolved = ResolvedRuntimeOptions {
|
|
provider,
|
|
model: "test-model".to_string(),
|
|
api_key: Some("test-key".to_string()),
|
|
api_key_source: Some(RuntimeApiKeySource::Keyring),
|
|
base_url: "http://localhost:8000/v1".to_string(),
|
|
auth_mode: Some("api_key".to_string()),
|
|
insecure_skip_tls_verify: false,
|
|
output_mode: None,
|
|
log_level: None,
|
|
telemetry: false,
|
|
approval_policy: None,
|
|
sandbox_mode: None,
|
|
yolo: None,
|
|
http_headers: std::collections::BTreeMap::new(),
|
|
};
|
|
|
|
let cmd = build_tui_command(&cli, &resolved, Vec::new())
|
|
.unwrap_or_else(|e| panic!("{flag}: {e}"));
|
|
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
|
|
Some("test-key"),
|
|
"{flag}: DEEPSEEK_API_KEY not forwarded"
|
|
);
|
|
for var in expected_vars {
|
|
assert_eq!(
|
|
command_env(&cmd, var).as_deref(),
|
|
Some("test-key"),
|
|
"{flag}: {var} not forwarded"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
|
|
Some("keyring"),
|
|
"{flag}: expected keyring source bridge"
|
|
);
|
|
assert_eq!(
|
|
command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
|
|
None,
|
|
"{flag}: auth mode should come from config/profile, not env handoff"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
|
|
let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
|
|
|
|
assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
|
|
assert!(cli.prompt.is_empty());
|
|
assert_eq!(
|
|
root_tui_passthrough(&cli).unwrap(),
|
|
vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_top_level_continue_for_interactive_resume() {
|
|
let cli = parse_ok(&["codewhale", "--continue"]);
|
|
|
|
assert!(cli.continue_session);
|
|
assert!(cli.prompt_flag.is_none());
|
|
assert!(cli.prompt.is_empty());
|
|
assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_continue_rejects_startup_prompt() {
|
|
let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
|
|
|
|
let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("codewhale exec --continue <PROMPT>")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
|
|
let cli = parse_ok(&["deepseek", "hello", "world"]);
|
|
|
|
assert_eq!(cli.prompt, vec!["hello", "world"]);
|
|
assert!(cli.command.is_none());
|
|
assert_eq!(
|
|
root_tui_passthrough(&cli).unwrap(),
|
|
vec!["--prompt".to_string(), "hello world".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
|
|
let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
|
|
|
|
assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
|
|
assert_eq!(cli.prompt, vec!["world"]);
|
|
assert_eq!(
|
|
root_tui_passthrough(&cli).unwrap(),
|
|
vec!["--prompt".to_string(), "hello world".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn known_subcommands_still_parse_before_prompt_tail() {
|
|
let cli = parse_ok(&["deepseek", "doctor"]);
|
|
|
|
assert!(cli.prompt.is_empty());
|
|
assert!(matches!(cli.command, Some(Commands::Doctor(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn root_help_surface_contains_expected_subcommands_and_globals() {
|
|
let rendered = help_for(&["deepseek", "--help"]);
|
|
|
|
for token in [
|
|
"run",
|
|
"doctor",
|
|
"models",
|
|
"sessions",
|
|
"resume",
|
|
"setup",
|
|
"login",
|
|
"logout",
|
|
"auth",
|
|
"mcp-server",
|
|
"config",
|
|
"model",
|
|
"thread",
|
|
"sandbox",
|
|
"app-server",
|
|
"completion",
|
|
"metrics",
|
|
"--provider",
|
|
"--model",
|
|
"--config",
|
|
"--profile",
|
|
"--output-mode",
|
|
"--log-level",
|
|
"--telemetry",
|
|
"--base-url",
|
|
"--api-key",
|
|
"--approval-policy",
|
|
"--sandbox-mode",
|
|
"--mouse-capture",
|
|
"--no-mouse-capture",
|
|
"--skip-onboarding",
|
|
"--continue",
|
|
"--prompt",
|
|
] {
|
|
assert!(
|
|
rendered.contains(token),
|
|
"expected help to contain token: {token}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn subcommand_help_surfaces_are_stable() {
|
|
let cases = [
|
|
("config", vec!["get", "set", "unset", "list", "path"]),
|
|
("model", vec!["list", "resolve"]),
|
|
(
|
|
"thread",
|
|
vec![
|
|
"list",
|
|
"read",
|
|
"resume",
|
|
"fork",
|
|
"archive",
|
|
"unarchive",
|
|
"set-name",
|
|
"clear-name",
|
|
],
|
|
),
|
|
("sandbox", vec!["check"]),
|
|
(
|
|
"exec",
|
|
vec![
|
|
"--auto",
|
|
"--json",
|
|
"--resume",
|
|
"--session-id",
|
|
"--continue",
|
|
"--output-format",
|
|
"stream-json",
|
|
],
|
|
),
|
|
(
|
|
"app-server",
|
|
vec!["--host", "--port", "--config", "--stdio"],
|
|
),
|
|
(
|
|
"completion",
|
|
vec![
|
|
"<SHELL>",
|
|
"bash",
|
|
"source <(codewhale completion bash)",
|
|
"~/.local/share/bash-completion/completions/codewhale",
|
|
"fpath=(~/.zfunc $fpath)",
|
|
"codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
|
|
"codewhale completion powershell | Out-String | Invoke-Expression",
|
|
],
|
|
),
|
|
("metrics", vec!["--json", "--since"]),
|
|
];
|
|
|
|
for (subcommand, expected_tokens) in cases {
|
|
let argv = ["deepseek", subcommand, "--help"];
|
|
let rendered = help_for(&argv);
|
|
for token in expected_tokens {
|
|
assert!(
|
|
rendered.contains(token),
|
|
"expected help for `{subcommand}` to include `{token}`"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Regression for issue #247: on Windows the dispatcher must find the
|
|
/// sibling `codewhale-tui.exe`, not bail out looking for an
|
|
/// extension-less `codewhale-tui`. The candidate resolver also accepts
|
|
/// the suffix-less name on Windows so users who manually renamed the
|
|
/// file as a workaround keep working after the upgrade.
|
|
#[test]
|
|
fn sibling_tui_candidate_picks_platform_correct_name() {
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let dispatcher = dir
|
|
.path()
|
|
.join("codewhale")
|
|
.with_extension(std::env::consts::EXE_EXTENSION);
|
|
// Touch the dispatcher so its parent dir is the lookup root.
|
|
std::fs::write(&dispatcher, b"").unwrap();
|
|
|
|
// No sibling yet — resolver returns None.
|
|
assert!(sibling_tui_candidate(&dispatcher).is_none());
|
|
|
|
let target =
|
|
dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&target, b"").unwrap();
|
|
|
|
let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
|
|
assert_eq!(found, target, "primary platform-correct name wins");
|
|
}
|
|
|
|
#[test]
|
|
fn dispatcher_spawn_error_names_path_and_recovery_checks() {
|
|
let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
|
|
let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
|
|
|
|
assert!(message.contains("C:/tools/codewhale-tui.exe"));
|
|
assert!(message.contains("access is denied"));
|
|
assert!(message.contains("where codewhale"));
|
|
assert!(message.contains("DEEPSEEK_TUI_BIN"));
|
|
}
|
|
|
|
/// Windows-only fallback: the user from #247 manually renamed the
|
|
/// file to drop `.exe`. After the fix lands, that workaround must
|
|
/// still resolve via the suffix-less fallback so they don't have to
|
|
/// rename it back.
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let dispatcher = dir.path().join("codewhale.exe");
|
|
std::fs::write(&dispatcher, b"").unwrap();
|
|
|
|
// Only the suffixless name exists — emulates the manual rename.
|
|
let suffixless = dispatcher.with_file_name("codewhale-tui");
|
|
std::fs::write(&suffixless, b"").unwrap();
|
|
|
|
let found = sibling_tui_candidate(&dispatcher)
|
|
.expect("Windows fallback must locate suffixless codewhale-tui");
|
|
assert_eq!(found, suffixless);
|
|
}
|
|
|
|
/// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
|
|
/// custom Windows install layouts and CI test rigs.
|
|
#[test]
|
|
fn locate_sibling_tui_binary_honours_env_override() {
|
|
let _lock = env_lock();
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let custom = dir
|
|
.path()
|
|
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&custom, b"").unwrap();
|
|
let custom_str = custom.to_string_lossy().into_owned();
|
|
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
|
|
|
|
let resolved = locate_sibling_tui_binary().expect("override must resolve");
|
|
assert_eq!(resolved, custom);
|
|
}
|
|
}
|