#![allow(clippy::uninlined_format_args)]
mod cloud;
mod credential_handoff;
mod metrics;
#[cfg(not(target_env = "ohos"))]
mod update;
use std::io::{self, IsTerminal, 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, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
classify_config_api_key_value, provider_base_url_is_official,
};
use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
use codewhale_mcp::{McpServerDefinition, run_stdio_server};
use codewhale_secrets::Secrets;
use codewhale_state::{StateStore, ThreadListFilters};
use codewhale_telemetry::{
self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
TelemetryDecision, TurnWall,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ProviderArg {
Deepseek,
NvidiaNim,
Openai,
Atlascloud,
WanjieArk,
Volcengine,
Openrouter,
Orcarouter,
XiaomiMimo,
Novita,
Fireworks,
Siliconflow,
#[value(
alias = "silicon-flow-cn",
alias = "siliconflow-CN",
alias = "silicon_flow_cn",
alias = "siliconflow_cn",
alias = "siliconflow-china",
alias = "siliconflow_china"
)]
SiliconflowCn,
Arcee,
Moonshot,
Sglang,
Vllm,
Ollama,
#[value(alias = "ollama_cloud")]
OllamaCloud,
Huggingface,
Together,
OpenaiCodex,
Anthropic,
#[value(alias = "open-model", alias = "open_model")]
Openmodel,
Zai,
Stepfun,
Minimax,
#[value(
alias = "minimax_anthropic",
alias = "mini-max-anthropic",
alias = "mini_max_anthropic"
)]
MinimaxAnthropic,
#[value(alias = "deep-infra", alias = "deep_infra")]
Deepinfra,
#[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
Sakana,
#[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
LongCat,
#[value(alias = "opencode_go", alias = "opencodego")]
OpencodeGo,
#[value(
alias = "opencode_zen",
alias = "opencodezen",
alias = "zen",
alias = "opencode"
)]
OpencodeZen,
#[value(
alias = "meta-ai",
alias = "meta_ai",
alias = "meta-model-api",
alias = "muse",
alias = "muse-spark"
)]
Meta,
#[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
Xai,
#[value(
alias = "mistral-ai",
alias = "mistral_ai",
alias = "mistralai",
alias = "la-plateforme",
alias = "la_plateforme"
)]
Mistral,
Google,
#[value(alias = "agy")]
Antigravity,
#[value(alias = "eden-ai", alias = "eden_ai")]
Edenai,
}
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::Orcarouter => ProviderKind::Orcarouter,
ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
ProviderArg::Novita => ProviderKind::Novita,
ProviderArg::Fireworks => ProviderKind::Fireworks,
ProviderArg::Siliconflow => ProviderKind::Siliconflow,
ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
ProviderArg::Arcee => ProviderKind::Arcee,
ProviderArg::Moonshot => ProviderKind::Moonshot,
ProviderArg::Sglang => ProviderKind::Sglang,
ProviderArg::Vllm => ProviderKind::Vllm,
ProviderArg::Ollama => ProviderKind::Ollama,
ProviderArg::OllamaCloud => ProviderKind::OllamaCloud,
ProviderArg::Huggingface => ProviderKind::Huggingface,
ProviderArg::Together => ProviderKind::Together,
ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
ProviderArg::Anthropic => ProviderKind::Anthropic,
ProviderArg::Openmodel => ProviderKind::Openmodel,
ProviderArg::Zai => ProviderKind::Zai,
ProviderArg::Stepfun => ProviderKind::Stepfun,
ProviderArg::Minimax => ProviderKind::Minimax,
ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
ProviderArg::Deepinfra => ProviderKind::Deepinfra,
ProviderArg::Sakana => ProviderKind::Sakana,
ProviderArg::LongCat => ProviderKind::LongCat,
ProviderArg::OpencodeGo => ProviderKind::OpencodeGo,
ProviderArg::OpencodeZen => ProviderKind::OpencodeZen,
ProviderArg::Meta => ProviderKind::Meta,
ProviderArg::Xai => ProviderKind::Xai,
ProviderArg::Mistral => ProviderKind::Mistral,
ProviderArg::Google => ProviderKind::Google,
ProviderArg::Antigravity => ProviderKind::Antigravity,
ProviderArg::Edenai => ProviderKind::Edenai,
}
}
}
fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
ProviderArg::from_str(value, false).ok()
}
fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
if value.is_empty()
|| value == "__custom__"
|| !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
{
return Err(
"provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
.to_string(),
);
}
Ok(value.to_string())
}
#[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_name = "PROVIDER",
value_parser = parse_provider_identifier,
help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long = "output-mode")]
output_mode: Option<String>,
#[arg(
long = "verbosity",
value_name = "LEVEL",
help = "Controls transcript and output verbosity (normal, concise)"
)]
verbosity: Option<String>,
#[arg(long = "log-level")]
log_level: Option<String>,
#[arg(
long,
value_name = "BOOL",
help = "Control anonymous usage counting for this run (default on; \
CODEWHALE_TELEMETRY=0 always wins)"
)]
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>,
#[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
workspace: Option<PathBuf>,
#[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,
#[arg(long = "no-project-config")]
no_project_config: bool,
#[arg(long, hide = true)]
yolo: bool,
#[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(RunArgs),
Doctor(TuiPassthroughArgs),
Models(TuiPassthroughArgs),
#[command(visible_alias = "tts")]
Speech(TuiPassthroughArgs),
Sessions(TuiPassthroughArgs),
Resume(TuiPassthroughArgs),
Rc(TuiPassthroughArgs),
Fork(TuiPassthroughArgs),
Init(TuiPassthroughArgs),
Setup(TuiPassthroughArgs),
RemoteSetup(RemoteSetupArgs),
#[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),
Fleet(TuiPassthroughArgs),
#[command(name = "workflow-tool", hide = true)]
WorkflowTool(TuiPassthroughArgs),
#[command(name = "lane-log-proxy", hide = true)]
LaneLogProxy(LaneLogProxyArgs),
#[command(after_help = "\
Examples:
codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
codewhale workflow run stopship --fleet stopship --runtime inline --verify
`workflow run` validates the checked-in Workflow source and named Fleet roster,
creates a Lane record, then dispatches the Workflow tool directly through the
selected Runtime backend without an operator model turn.
")]
Workflow(WorkflowArgs),
#[command(after_help = "\
Examples:
codewhale lane list
codewhale lane status <lane-id>
codewhale lane attach <lane-id>
codewhale lane logs <lane-id>
codewhale lane interrupt <lane-id>
codewhale lane interrupt <lane-id>@<lifecycle-seq>
codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
Runtime, not Fleet.
list/status/interrupt/restart/resume share one control-plane contract with the
`/lane` slash command and its hotbar action: same verb ids, same availability,
same read-vs-write authority, same exact-identity target selection, and the
same receipt (`--json`). `lane stop` is a compatibility spelling of
`lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
lifecycle generation you observed.
")]
Lane(LaneArgs),
Review(TuiPassthroughArgs),
Apply(TuiPassthroughArgs),
Eval(TuiPassthroughArgs),
Mcp(TuiPassthroughArgs),
Features(TuiPassthroughArgs),
Integrations(TuiPassthroughArgs),
#[command(after_help = "\
Forwarded serve options:
--mcp Start MCP server over stdio
--http Start runtime HTTP/SSE API server
--mobile Start runtime HTTP/SSE API server with the mobile control page
--web Start the embedded loopback-only browser client
--qr Show a QR code for the mobile URL (requires --mobile)
--acp Start ACP server over stdio for editor clients
--host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
--port <PORT> Bind port [default: 7878]
--workers <WORKERS> Background task worker count (1-8)
--cors-origin <URL> Additional CORS origin to allow (repeatable)
--auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
--insecure Disable runtime API auth when no token is configured
`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
New integrations should prefer `codewhale app-server`.")]
Serve(TuiPassthroughArgs),
#[command(
after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated."
)]
Web(WebArgs),
Completions(TuiPassthroughArgs),
Login(LoginArgs),
Logout,
Auth(AuthArgs),
#[command(visible_alias = "cloud")]
Account(cloud::CloudArgs),
McpServer,
Config(ConfigArgs),
Model(ModelArgs),
Thread(ThreadArgs),
Sandbox(SandboxArgs),
#[command(after_help = "\
Transports:
codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
See docs/RUNTIME_API.md.")]
AppServer(AppServerArgs),
#[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,
},
Metrics(MetricsArgs),
Update(UpdateArgs),
}
fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
}
fn top_level_provider_override(
provider: Option<&str>,
command: Option<&Commands>,
) -> Result<Option<ProviderKind>> {
let Some(provider) = provider else {
return Ok(None);
};
if let Some(provider) = builtin_provider_arg(provider) {
return Ok(Some(provider.into()));
}
if command_accepts_raw_provider(command) {
return Ok(None);
}
let expected = ProviderArg::value_variants()
.iter()
.filter_map(ValueEnum::to_possible_value)
.map(|value| value.get_name().to_string())
.collect::<Vec<_>>()
.join(", ");
bail!(
"invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
)
}
fn prepare_raw_provider_tui_dispatch(
cli: &Cli,
command: Option<&Commands>,
runtime_overrides: &CliRuntimeOverrides,
) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
let Some(provider) = cli.provider.as_deref() else {
return Ok(None);
};
if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
return Ok(None);
}
let passthrough = match command {
Some(Commands::Exec(args)) => {
reject_exec_global_flags(&args.args)?;
tui_args("exec", args.clone())
}
Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
_ => unreachable!("raw provider validation only permits Exec and Fleet"),
};
let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
Ok(Some((resolved_runtime, passthrough)))
}
#[derive(Debug, Args)]
struct UpdateArgs {
#[arg(long)]
beta: bool,
#[arg(long)]
check: bool,
#[arg(long, value_name = "URL")]
proxy: Option<String>,
}
#[derive(Debug, Args)]
struct MetricsArgs {
#[arg(long)]
json: bool,
#[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 WebArgs {
#[arg(long, default_value_t = 7878)]
port: u16,
}
#[derive(Debug, Args)]
struct LaneLogProxyArgs {
#[arg(long, value_name = "PATH")]
log_path: PathBuf,
#[arg(long, value_name = "PATH")]
receipt_path: PathBuf,
#[arg(long, value_name = "PATH")]
receipt_tmp_path: PathBuf,
#[arg(long, value_name = "PATH")]
environment_path: Option<PathBuf>,
#[arg(long)]
lane_id: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
command: Vec<String>,
}
#[derive(Debug, Args)]
struct LaneArgs {
#[command(subcommand)]
command: LaneCommand,
}
#[derive(Debug, Subcommand)]
#[allow(clippy::large_enum_variant)]
enum LaneCommand {
List {
#[arg(long, default_value_t = false)]
json: bool,
},
Status {
lane_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Attach {
lane_id: String,
#[arg(long, default_value_t = false)]
print: bool,
},
Logs {
lane_id: String,
#[arg(long, short = 'f', default_value_t = false)]
follow: bool,
#[arg(long, default_value_t = 50)]
tail: usize,
},
Stop { lane_id: String },
Interrupt {
lane_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Restart {
lane_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Resume {
lane_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Start {
#[arg(long)]
workflow: Option<String>,
#[arg(long)]
fleet: Option<String>,
#[arg(long)]
issue: Option<String>,
#[arg(long)]
goal: Option<String>,
#[arg(long, default_value = "tmux")]
runtime: String,
#[arg(long, value_name = "DIR")]
worktree_repo: Option<PathBuf>,
#[arg(long)]
branch: Option<String>,
#[arg(long, value_name = "DIR")]
worktree_path: Option<PathBuf>,
#[arg(long)]
worktree_ttl_secs: Option<u64>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
}
#[derive(Debug, Args)]
struct WorkflowArgs {
#[command(subcommand)]
command: WorkflowCommand,
}
#[derive(Debug, Subcommand)]
enum WorkflowCommand {
Run {
workflow: String,
#[arg(long)]
fleet: Option<String>,
#[arg(long)]
issue: Option<String>,
#[arg(long)]
goal: Option<String>,
#[arg(long, default_value = "tmux")]
runtime: String,
#[arg(long, value_name = "PATH")]
source_path: Option<PathBuf>,
#[arg(long)]
token_budget: Option<u64>,
#[arg(long, default_value_t = false)]
verify: bool,
#[arg(long, value_name = "DIR")]
worktree_repo: Option<PathBuf>,
#[arg(long)]
branch: Option<String>,
#[arg(long, value_name = "DIR")]
worktree_path: Option<PathBuf>,
#[arg(long)]
worktree_ttl_secs: Option<u64>,
},
}
struct LaneStartRequest {
workflow: Option<String>,
fleet: Option<String>,
issue: Option<String>,
goal: Option<String>,
runtime: String,
worktree_repo: Option<PathBuf>,
branch: Option<String>,
worktree_path: Option<PathBuf>,
worktree_ttl_secs: Option<u64>,
command: Vec<String>,
environment: Vec<(String, String)>,
cwd: Option<PathBuf>,
}
fn start_lane(request: LaneStartRequest) -> Result<()> {
use codewhale_lane::{
LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
};
let LaneStartRequest {
workflow,
fleet,
issue,
goal,
runtime,
worktree_repo,
branch,
worktree_path,
worktree_ttl_secs,
command,
environment,
cwd,
} = request;
let kind = RuntimeBackendKind::parse(&runtime)?;
let reg = LaneRegistry::open_default()?;
let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
let worktree = match (worktree_repo, branch) {
(Some(repo_root), Some(branch_name)) => {
let path = worktree_path
.unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
Some(WorktreeProvision {
repo_root,
branch: branch_name,
path,
base_ref: None,
})
}
(None, None) => None,
_ => bail!("--worktree-repo and --branch must be provided together"),
};
let cmd = if command.is_empty() {
vec![
"sh".into(),
"-c".into(),
format!("echo lane {} started", record.id),
]
} else {
command
};
let spec = LaneStartSpec {
command: cmd,
cwd,
environment,
log_proxy: (kind == RuntimeBackendKind::Tmux)
.then(std::env::current_exe)
.transpose()
.context("resolve current Codewhale executable for tmux log proxy")?,
worktree,
};
let backend = resolve_backend(kind);
backend.start(®, &mut record, &spec)?;
println!("started {}", record.id);
println!("status: {}", record.status.as_str());
println!("runtime: {}", record.runtime.as_str());
println!("log: {}", record.log_path.display());
if let Some(attach) = backend.attach_command(&record) {
println!("attach: {attach}");
}
Ok(())
}
fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
if json {
match receipt.operation {
codewhale_lane::ControlOperation::LaneList => {
println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
}
codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
None if receipt.is_error() => {}
None => println!("{}", serde_json::to_string_pretty(receipt)?),
},
_ => println!("{}", serde_json::to_string_pretty(receipt)?),
}
} else if receipt.is_error() {
eprintln!("{}", receipt.render());
} else {
println!("{}", receipt.render());
}
if receipt.is_error() {
let detail = receipt
.failure
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| receipt.outcome.as_str().to_string());
bail!("{}: {detail}", receipt.operation_id);
}
Ok(())
}
fn run_lane_control(
operation: codewhale_lane::ControlOperation,
lane_id: Option<&str>,
json: bool,
) -> Result<()> {
let receipt = codewhale_lane::control::execute_lane_control(
codewhale_lane::ControlSurface::Cli,
operation,
lane_id,
);
emit_control_receipt(&receipt, json)
}
fn run_lane_command(args: LaneArgs) -> Result<()> {
use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
use std::io::{BufRead, Seek, Write};
use std::process::Command;
use std::thread;
use std::time::Duration;
match args.command {
LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
LaneCommand::Status { lane_id, json } => {
run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
}
LaneCommand::Interrupt { lane_id, json } => {
run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
}
LaneCommand::Restart { lane_id, json } => {
run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
}
LaneCommand::Resume { lane_id, json } => {
run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
}
LaneCommand::Attach { lane_id, print } => {
let reg = LaneRegistry::open_default()?;
let mut lane = reg.load(&lane_id)?;
let backend = backend_for(&lane);
backend.reconcile(®, &mut lane)?;
let Some(attach) = backend.attach_command(&lane) else {
if !lane.status.is_active() {
bail!(
"lane `{lane_id}` is {} and has no active attach target",
lane.status.as_str()
);
}
bail!(
"lane `{lane_id}` runtime `{}` has no attach target",
lane.runtime.as_str()
);
};
if print {
println!("{attach}");
return Ok(());
}
if let Some(session) = lane.tmux_session.as_deref() {
let socket = lane
.tmux_socket
.as_deref()
.context("tmux lane is missing its pinned server socket")?;
let status = Command::new("tmux")
.arg("-S")
.arg(socket)
.args(["attach", "-t", session])
.status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
Err(err) => {
eprintln!("could not exec tmux: {err}");
println!("{attach}");
bail!("tmux attach unavailable");
}
}
} else {
println!("{attach}");
Ok(())
}
}
LaneCommand::Logs {
lane_id,
follow,
tail,
} => {
let reg = LaneRegistry::open_default()?;
let lane = reg.load(&lane_id)?;
let path = lane.log_path;
if !path.exists() {
bail!("log file missing: {}", path.display());
}
let content = std::fs::read(&path)?;
let lines: Vec<&[u8]> = content
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.collect();
let start = lines.len().saturating_sub(tail);
let mut stdout = std::io::stdout().lock();
for line in &lines[start..] {
stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
stdout.write_all(b"\n")?;
}
stdout.flush()?;
if !follow {
return Ok(());
}
let mut file = std::fs::File::open(&path)?;
file.seek(std::io::SeekFrom::End(0))?;
let mut reader = std::io::BufReader::new(file);
loop {
let mut line = Vec::new();
match reader.read_until(b'\n', &mut line) {
Ok(0) => {
thread::sleep(Duration::from_millis(200));
continue;
}
Ok(_) => {
let mut stdout = std::io::stdout().lock();
stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
stdout.flush()?;
}
Err(err) => return Err(err.into()),
}
}
}
LaneCommand::Stop { lane_id } => {
run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
}
LaneCommand::Start {
workflow,
fleet,
issue,
goal,
runtime,
worktree_repo,
branch,
worktree_path,
worktree_ttl_secs,
command,
} => start_lane(LaneStartRequest {
workflow,
fleet,
issue,
goal,
runtime,
worktree_repo,
branch,
worktree_path,
worktree_ttl_secs,
command,
environment: Vec::new(),
cwd: None,
}),
}
}
fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
command: args.command,
log_path: args.log_path,
receipt_path: args.receipt_path,
receipt_tmp_path: args.receipt_tmp_path,
environment_path: args.environment_path,
lane_id: args.lane_id,
})?;
std::process::exit(exit_code);
}
fn run_workflow_command(
cli: &Cli,
resolved_runtime: &ResolvedRuntimeOptions,
config_path: &Path,
args: WorkflowArgs,
) -> Result<()> {
match args.command {
WorkflowCommand::Run {
workflow,
fleet,
issue,
goal,
runtime,
source_path,
token_budget,
verify,
worktree_repo,
branch,
worktree_path,
worktree_ttl_secs,
} => {
let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
let source_path =
resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
validate_workflow_source_file(&source_path)?;
let source_root = if let Some(repo) = worktree_repo.as_deref() {
repo.canonicalize()
.with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
} else {
workspace.clone()
};
if let Some(name) = fleet.as_deref() {
let roots = named_fleet_search_roots(&workspace);
let loaded =
codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
format!("load fleet `{name}` from {}", display_roots(&roots))
})?;
if workflow == "stopship" || name == "stopship" {
loaded
.validate_stopship_roles()
.with_context(|| format!("validate stopship roles in fleet `{name}`"))?;
}
}
let process = workflow_exec_command(WorkflowExecSpec {
cli,
resolved_runtime,
config_path,
source_root: &source_root,
source_path: &source_path,
workflow: &workflow,
fleet: fleet.as_deref(),
issue: issue.as_deref(),
goal: goal.as_deref(),
token_budget,
verify,
})?;
start_lane(LaneStartRequest {
workflow: Some(workflow),
fleet,
issue,
goal,
runtime,
worktree_repo,
branch,
worktree_path,
worktree_ttl_secs,
command: process.command,
environment: process.environment,
cwd: Some(workspace),
})
}
}
}
fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = explicit {
return path
.canonicalize()
.with_context(|| format!("resolve workflow workspace {}", path.display()));
}
let cwd = std::env::current_dir().context("resolve current directory")?;
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(&cwd)
.output();
if let Ok(output) = output
&& output.status.success()
{
let text = String::from_utf8_lossy(&output.stdout);
let root = text.trim();
if !root.is_empty() {
let root = PathBuf::from(root);
return Ok(root.canonicalize().unwrap_or(root));
}
}
Ok(cwd)
}
fn resolve_workflow_source_path(
workflow: &str,
source_path: Option<&PathBuf>,
workspace: &Path,
) -> Result<PathBuf> {
let candidates = workflow_source_candidates(workflow, source_path, workspace);
for candidate in &candidates {
if candidate.is_file() {
return Ok(candidate.clone());
}
}
bail!(
"workflow source for `{workflow}` not found; tried {}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
}
fn workflow_source_candidates(
workflow: &str,
source_path: Option<&PathBuf>,
workspace: &Path,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = source_path {
candidates.push(resolve_against_workspace(path, workspace));
return candidates;
}
let raw = workflow.trim();
let workflow_path = PathBuf::from(raw);
if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
candidates.push(resolve_against_workspace(&workflow_path, workspace));
return candidates;
}
let normalized = raw.replace('-', "_");
for rel in [
format!("workflows/{raw}.workflow.js"),
format!("workflows/{normalized}.workflow.js"),
] {
let path = workspace.join(rel);
if !candidates.iter().any(|existing| existing == &path) {
candidates.push(path);
}
}
candidates
}
fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace.join(path)
}
}
fn validate_workflow_source_file(path: &Path) -> Result<()> {
let source =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
if source.trim_start().starts_with("export default workflow(")
|| source.trim_start().starts_with("workflow(")
|| source.contains("\nworkflow(")
{
let identifier = path.display().to_string();
if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
codewhale_workflow::compile_typescript_workflow(&identifier, &source)
.with_context(|| format!("parse declarative Workflow {}", path.display()))?;
} else {
codewhale_workflow::compile_javascript_workflow(&identifier, &source)
.with_context(|| format!("parse declarative Workflow {}", path.display()))?;
}
}
Ok(())
}
fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(home) = codewhale_config::codewhale_home() {
roots.push(home);
}
roots.push(workspace.to_path_buf());
roots
}
fn display_roots(roots: &[PathBuf]) -> String {
roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
struct WorkflowExecSpec<'a> {
cli: &'a Cli,
resolved_runtime: &'a ResolvedRuntimeOptions,
config_path: &'a Path,
source_root: &'a Path,
source_path: &'a Path,
workflow: &'a str,
fleet: Option<&'a str>,
issue: Option<&'a str>,
goal: Option<&'a str>,
token_budget: Option<u64>,
verify: bool,
}
struct WorkflowProcessSpec {
command: Vec<String>,
environment: Vec<(String, String)>,
}
fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
let WorkflowExecSpec {
cli,
resolved_runtime,
config_path,
source_root,
source_path,
workflow,
fleet,
issue,
goal,
token_budget,
verify,
} = spec;
let source_arg = source_path
.strip_prefix(source_root)
.with_context(|| {
format!(
"workflow source {} must be inside execution root {}",
source_path.display(),
source_root.display()
)
})?
.display()
.to_string();
let mut payload = serde_json::json!({
"action": "run",
"source_path": source_arg,
"fleet": fleet,
"args": {
"workflow": workflow,
"fleet": fleet,
"issue": issue,
"goal": goal,
},
"verify": verify,
});
if let Some(token_budget) = token_budget {
payload["token_budget"] = serde_json::json!(token_budget);
}
let input_json = serde_json::to_string(&payload)?;
let passthrough = vec![
"workflow-tool".to_string(),
"--approval-source".to_string(),
"explicit-workflow-command".to_string(),
"--input-json".to_string(),
input_json,
];
let argv = {
let mut args = Vec::new();
let executable = std::env::current_exe()
.context("resolve current Codewhale executable for workflow lane")?;
let executable = executable.into_os_string().into_string().map_err(|path| {
anyhow!(
"current Codewhale executable path is not valid UTF-8: {}",
PathBuf::from(path).display()
)
})?;
args.push(executable);
let cfg = Some(config_path);
if let Some(cp) = cfg {
args.push("--config".to_string());
args.push(cp.display().to_string());
} else if let Some(cp) = cli.config.as_deref() {
args.push("--config".to_string());
args.push(cp.display().to_string());
}
if let Some(profile) = cli.profile.as_ref() {
args.push("--profile".to_string());
args.push(profile.clone());
}
if cli.mouse_capture {
args.push("--mouse-capture".to_string());
}
if cli.no_mouse_capture {
args.push("--no-mouse-capture".to_string());
}
if cli.skip_onboarding {
args.push("--skip-onboarding".to_string());
}
if cli.no_project_config {
args.push("--no-project-config".to_string());
}
args.extend(passthrough.clone());
args
};
apply_tui_env(cli, resolved_runtime, &passthrough);
lane_process_spec_from_argv(&argv)
}
fn valid_lane_environment_key(key: &str) -> bool {
let mut chars = key.chars();
chars
.next()
.is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn shell_owned_lane_environment(key: &str) -> bool {
matches!(
key,
"PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
)
}
fn lane_process_spec_from_argv(argv: &[String]) -> Result<WorkflowProcessSpec> {
let mut environment = std::collections::BTreeMap::new();
for (key, value) in std::env::vars_os() {
let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
continue;
};
if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
environment.insert(key.to_string(), value.to_string());
}
}
Ok(WorkflowProcessSpec {
command: argv.to_vec(),
environment: environment.into_iter().collect(),
})
}
#[derive(Debug, Args, Clone, Default)]
struct RemoteSetupArgs {
#[arg(long)]
cloud: Option<String>,
#[arg(long)]
bridge: Option<String>,
#[arg(long)]
provider: Option<String>,
#[arg(long, value_name = "DIR")]
out: Option<PathBuf>,
#[arg(long, default_value_t = false)]
generate_only: bool,
#[arg(long, default_value_t = false, conflicts_with = "generate_only")]
apply: bool,
#[arg(long, default_value_t = false)]
yes: bool,
#[arg(long, default_value_t = false)]
non_interactive: bool,
}
fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
let mut forwarded = vec!["remote-setup".to_string()];
if let Some(cloud) = args.cloud {
forwarded.push("--cloud".to_string());
forwarded.push(cloud);
}
if let Some(bridge) = args.bridge {
forwarded.push("--bridge".to_string());
forwarded.push(bridge);
}
if let Some(provider) = args.provider {
forwarded.push("--provider".to_string());
forwarded.push(provider);
}
if let Some(out) = args.out {
forwarded.push("--out".to_string());
forwarded.push(out.to_string_lossy().into_owned());
}
if args.generate_only {
forwarded.push("--generate-only".to_string());
}
if args.apply {
forwarded.push("--apply".to_string());
}
if args.yes {
forwarded.push("--yes".to_string());
}
if args.non_interactive {
forwarded.push("--non-interactive".to_string());
}
forwarded
}
#[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 {
#[command(name = "xai-device")]
XaiDevice,
#[command(name = "external-consent")]
ExternalConsent {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_enum)]
mode: ExternalCredentialModeArg,
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long, default_value_t = false)]
yes: bool,
},
#[command(name = "external-revoke")]
ExternalRevoke {
#[arg(long, value_enum)]
provider: ProviderArg,
},
Status {
#[arg(long, value_enum)]
provider: Option<ProviderArg>,
},
Set {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long)]
api_key: Option<String>,
#[arg(long = "api-key-stdin", default_value_t = false)]
api_key_stdin: bool,
},
Get {
#[arg(long, value_enum)]
provider: ProviderArg,
},
PrintApiKey {
#[arg(long, value_enum)]
provider: ProviderArg,
},
Clear {
#[arg(long, value_enum)]
provider: ProviderArg,
},
List,
#[command(hide = true)]
Migrate {
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ExternalCredentialModeArg {
ReadOnly,
Managed,
}
#[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>,
},
Set { model: String },
}
#[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,
},
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, conflicts_with_all = ["stdio", "mobile"])]
http: bool,
#[arg(long, conflicts_with = "stdio")]
mobile: bool,
#[arg(long, default_value_t = false)]
stdio: bool,
#[arg(long, requires = "mobile")]
qr: bool,
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
workers: Option<usize>,
#[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>,
}
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) => {
eprintln!("error: {err}");
for cause in err.chain().skip(1) {
eprintln!(" caused by: {cause}");
}
std::process::ExitCode::FAILURE
}
}
}
fn split_lane_log_proxy_command(
command: Option<Commands>,
) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
match command {
Some(Commands::LaneLogProxy(args)) => (Some(args), None),
command => (None, command),
}
}
fn run() -> Result<()> {
let mut cli = Cli::parse();
let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
if let Some(args) = proxy {
return run_lane_log_proxy_command(args);
}
let pipe_api_key_handoff = matches!(
&command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::PrintApiKey { .. }
}))
);
if pipe_api_key_handoff {
credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
}
let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
let runtime_overrides = CliRuntimeOverrides {
provider: runtime_provider,
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),
verbosity: cli.verbosity.clone(),
};
if uses_raw_tui_provider
&& let Some((resolved_runtime, passthrough)) =
prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
{
return run_tui_in_process(&cli, &resolved_runtime, passthrough);
}
let mut store = ConfigStore::load(cli.config.clone()).map_err(|error| {
if pipe_api_key_handoff {
anyhow!("unavailable credential")
} else {
error
}
})?;
match command {
Some(Commands::Run(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, args.args)
}
Some(Commands::Doctor(args)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("doctor", args))
}
Some(Commands::Models(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("models", args))
}
Some(Commands::Speech(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("speech", args))
}
Some(Commands::Sessions(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&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::Rc(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
let mut passthrough = vec!["--remote-control".to_string()];
passthrough.extend(args.args);
run_tui_in_process(&cli, &resolved_runtime, passthrough)
}
Some(Commands::Fork(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("fork", args))
}
Some(Commands::Init(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("init", args))
}
Some(Commands::Setup(args)) => {
let resolved_runtime = if setup_is_status_report(&args) {
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
} else {
resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
};
run_tui_in_process(&cli, &resolved_runtime, tui_args("setup", args))
}
Some(Commands::RemoteSetup(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, remote_setup_tui_args(args))
}
Some(Commands::Exec(args)) => {
reject_exec_global_flags(&args.args)?;
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("exec", args))
}
Some(Commands::Fleet(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("fleet", args))
}
Some(Commands::WorkflowTool(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("workflow-tool", args))
}
Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
Some(Commands::Workflow(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
let config_path = store.path().to_path_buf();
run_workflow_command(&cli, &resolved_runtime, &config_path, args)
}
Some(Commands::Lane(args)) => run_lane_command(args),
Some(Commands::Review(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("review", args))
}
Some(Commands::Apply(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("apply", args))
}
Some(Commands::Eval(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("eval", args))
}
Some(Commands::Mcp(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("mcp", args))
}
Some(Commands::Integrations(args)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("integrations", args))
}
Some(Commands::Features(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("features", args))
}
Some(Commands::Serve(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_server_in_process(&cli, &resolved_runtime, tui_args("serve", args))
}
Some(Commands::Web(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_server_in_process(&cli, &resolved_runtime, web_serve_passthrough(&args))
}
Some(Commands::Completions(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&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)) => match args.command {
AuthCommand::XaiDevice => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(
&cli,
&resolved_runtime,
vec!["auth".to_string(), "xai-device".to_string()],
)
}
command => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
let session = start_cli_telemetry(
&resolved_runtime,
Some(store.path().to_path_buf()),
Surface::Cli,
);
let outcome =
run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
finish_cli_telemetry(session, &outcome);
outcome
}
},
Some(Commands::Account(args)) => {
cloud::reject_inline_api_key(cli.api_key.as_deref())?;
cloud::run(args, cli.profile.as_deref(), &store)
}
Some(Commands::McpServer) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
let session = start_cli_telemetry(
&resolved_runtime,
Some(store.path().to_path_buf()),
Surface::McpServer,
);
let outcome = run_mcp_server_command(&mut store);
finish_cli_telemetry(session, &outcome);
outcome
}
Some(Commands::Config(args)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
let session = start_cli_telemetry(
&resolved_runtime,
Some(store.path().to_path_buf()),
Surface::Cli,
);
let outcome = run_config_command(&mut store, args.command);
finish_cli_telemetry(session, &outcome);
outcome
}
Some(Commands::Model(args)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_model_command(
&mut store,
args.command,
runtime_overrides.provider,
&resolved_runtime,
)
}
Some(Commands::Thread(args)) => {
run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
}
Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
Some(Commands::AppServer(args)) => {
if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
cli.config = args.config.clone();
store = ConfigStore::load(cli.config.clone())?;
}
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_app_server_command(&cli, &resolved_runtime, 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)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
let session = start_cli_telemetry(
&resolved_runtime,
Some(store.path().to_path_buf()),
Surface::Cli,
);
#[cfg(not(target_env = "ohos"))]
let outcome = update::run_update(args.beta, args.check, args.proxy);
#[cfg(target_env = "ohos")]
let outcome = {
let _ = args;
Err(anyhow!(
"self-update is not supported on HarmonyOS/OpenHarmony yet"
))
};
finish_cli_telemetry(session, &outcome);
outcome
}
None => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
let forwarded = root_tui_passthrough(&cli)?;
run_tui_in_process(&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_diagnostic_dispatch(
store: &ConfigStore,
runtime_overrides: &CliRuntimeOverrides,
) -> ResolvedRuntimeOptions {
store.config.resolve_runtime_options(runtime_overrides)
}
struct CliTelemetrySession {
started: std::time::Instant,
}
fn start_cli_telemetry(
resolved: &ResolvedRuntimeOptions,
config_path: Option<PathBuf>,
surface: Surface,
) -> Option<CliTelemetrySession> {
let consent = resolve_cli_telemetry_consent(
resolved,
config_path,
surface,
telemetry::load_setup_state_for_decision(),
)?;
telemetry::init(consent);
telemetry::record(Event::SessionStart {
source: SessionSource::Unknown,
});
Some(CliTelemetrySession {
started: std::time::Instant::now(),
})
}
fn resolve_cli_telemetry_consent(
resolved: &ResolvedRuntimeOptions,
config_path: Option<PathBuf>,
surface: Surface,
setup: Option<SetupState>,
) -> Option<telemetry::TelemetryConsent> {
let setup = setup?;
let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
return None;
};
Some(consent.with_config_path(config_path))
}
fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>) {
let Some(session) = session else {
return;
};
telemetry::set_exit_class(if outcome.is_ok() {
ExitClass::Clean
} else {
ExitClass::Error
});
telemetry::record(Event::SessionEnd {
duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
exit_class: telemetry::exit_class(),
cold_start_bucket: None,
providers: Vec::new(),
counters: Counters::default(),
errors: Errors::default(),
turn_wall: TurnWall::default(),
});
let _ = telemetry::shutdown_blocking(telemetry::SHUTDOWN_FLUSH_TIMEOUT);
}
fn resolve_runtime_for_dispatch_with_secrets(
store: &mut ConfigStore,
runtime_overrides: &CliRuntimeOverrides,
secrets: &Secrets,
) -> ResolvedRuntimeOptions {
store
.config
.resolve_runtime_options_with_secrets(runtime_overrides, secrets)
}
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 setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
args.args.iter().any(|arg| arg == "--status")
}
fn reject_exec_global_flags(args: &[String]) -> Result<()> {
const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
for arg in args {
if arg == "--" {
break;
}
let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
if GLOBAL_ONLY_FLAGS.contains(&flag) {
bail!(
"{flag} must be placed before `exec`.\n\nUse:\n codewhale {flag} <value> exec \"<prompt>\""
);
}
}
Ok(())
}
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();
let api_key = match args.api_key {
Some(v) => v,
None => read_api_key_from_stdin()?,
};
let mut credential_store = credential_metadata_store(store)?;
let store = credential_store.as_mut().unwrap_or(store);
store.config.provider = provider;
let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
let destination = if secret_store_saved {
secrets.backend_name().to_string()
} else {
codewhale_config::quote_os_path(store.path())
};
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<()> {
codewhale_config::with_xai_oauth_revocation_transaction(|| {
run_logout_command_with_secrets_unlocked(store, secrets)
})
}
fn run_logout_command_with_secrets_unlocked(
store: &mut ConfigStore,
secrets: &Secrets,
) -> Result<()> {
let original_config = store.config.clone();
store.config.api_key = None;
for provider in ProviderKind::ALL {
clear_provider_api_key_from_config(store, provider);
store
.config
.providers
.for_provider_mut(provider)
.external_credentials = None;
}
let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
xai.oauth_credential_generation = None;
xai.auth_mode = None;
store.config.auth_mode = None;
if let Err(error) = store.save() {
store.config = original_config;
return Err(error);
}
let keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
if keyring_failures.is_empty() {
println!("logged out");
} else {
eprintln!(
"failed to delete stored credentials for: {}",
keyring_failures.join(", ")
);
println!("logged out (some stored credentials could not be deleted)");
}
Ok(())
}
fn provider_slot(provider: ProviderKind) -> &'static str {
provider.secret_store_slot()
}
fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
if !codewhale_config::config_path_is_workspace_scoped(store.path()) {
return Ok(None);
}
let global = codewhale_config::default_config_path()?;
eprintln!(
"ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
codewhale_config::quote_os_path(store.path()),
codewhale_config::quote_os_path(&global),
);
ConfigStore::load(Some(global)).map(Some)
}
#[cfg(test)]
fn no_keyring_secrets() -> Secrets {
Secrets::new(std::sync::Arc::new(
codewhale_secrets::InMemoryKeyringStore::new(),
))
}
fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
store.config.auth_mode = Some("api_key".to_string());
let provider_config = store.config.providers.for_provider_mut(provider);
provider_config.auth_mode = Some("api_key".to_string());
provider_config.external_credentials = None;
if provider == ProviderKind::Xai {
provider_config.oauth_credential_generation = None;
}
if provider == ProviderKind::Deepseek && 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 persist_provider_api_key(
store: &mut ConfigStore,
secrets: &Secrets,
provider: ProviderKind,
api_key: &str,
) -> Result<bool> {
if provider == ProviderKind::Xai {
return codewhale_config::with_xai_oauth_revocation_transaction(|| {
persist_provider_api_key_unlocked(store, secrets, provider, api_key)
});
}
persist_provider_api_key_unlocked(store, secrets, provider, api_key)
}
fn persist_provider_api_key_unlocked(
store: &mut ConfigStore,
secrets: &Secrets,
provider: ProviderKind,
api_key: &str,
) -> Result<bool> {
let original_config = store.config.clone();
prepare_provider_api_key_metadata(store, provider);
let slot = provider_slot(provider);
let prior_secret = secrets.get(slot);
let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
Ok(_) => match secrets.set(slot, api_key) {
Ok(()) => {
clear_provider_api_key_from_config(store, provider);
true
}
Err(err) => {
store.config = original_config;
return Err(anyhow::anyhow!(
"Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
codewhale_config::quote_os_path(store.path())
));
}
},
Err(error) => {
store.config = original_config;
return Err(anyhow::anyhow!(
"Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
codewhale_config::quote_os_path(store.path())
));
}
};
if let Err(error) = store.save() {
store.config = original_config;
if secret_store_saved {
let current = secrets
.get(slot)
.map_err(|rollback| anyhow::anyhow!(
"{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
))?;
if current.as_deref() == Some(api_key) {
match prior_secret.expect("snapshot succeeded before secret write") {
Some(previous) => secrets.set(slot, &previous),
None => secrets.delete(slot),
}
.map_err(|rollback| anyhow::anyhow!(
"{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
))?;
}
}
return Err(error);
}
codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
Ok(secret_store_saved)
}
fn clear_auth_provider(
store: &mut ConfigStore,
secrets: &Secrets,
provider: ProviderKind,
) -> Result<()> {
let slot = provider_slot(provider);
let original_config = store.config.clone();
clear_provider_api_key_from_config(store, provider);
if provider == ProviderKind::Xai {
let xai = store.config.providers.for_provider_mut(provider);
xai.oauth_credential_generation = None;
xai.auth_mode = None;
xai.external_credentials = None;
}
if let Err(error) = store.save() {
store.config = original_config;
return Err(error);
}
clear_provider_api_key_from_keyring(secrets, provider);
if provider == ProviderKind::Xai {
println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
} else {
println!("cleared API key for {slot} from config and secret store");
}
Ok(())
}
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] {
provider.provider().env_vars()
}
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 codewhale_config::resolve_external_credential_path(&path).unwrap_or(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")
});
let path = codex_home.join("auth.json");
codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
}
fn grok_auth_file_path() -> PathBuf {
for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
if let Ok(path) = std::env::var(key) {
let path = PathBuf::from(path.trim());
if !path.as_os_str().is_empty() {
return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
}
}
}
if let Ok(home) = std::env::var("GROK_HOME") {
let home = PathBuf::from(home.trim());
if !home.as_os_str().is_empty() {
let path = home.join("auth.json");
return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
}
}
let path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".grok")
.join("auth.json");
codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
}
fn external_credential_target(
provider: ProviderKind,
path_override: Option<PathBuf>,
) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
let (source, default_path) = match provider {
ProviderKind::OpenaiCodex => (
codewhale_config::ExternalCredentialSource::CodexCli,
openai_codex_auth_file_path(),
),
ProviderKind::Xai => (
codewhale_config::ExternalCredentialSource::GrokCli,
grok_auth_file_path(),
),
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => (
codewhale_config::ExternalCredentialSource::DshCli,
codewhale_config::default_dsh_credentials_path(),
),
ProviderKind::Antigravity => (
codewhale_config::ExternalCredentialSource::AgyCli,
codewhale_config::default_agy_credentials_path(),
),
ProviderKind::Moonshot => bail!(
"Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
),
_ => bail!(
"{} has no supported external CLI credential source",
provider.as_str()
),
};
let path =
codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
Ok((source, 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(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
}
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 clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
let _ = secrets.delete(provider_slot(provider));
}
fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
let mut failures = Vec::new();
let mut cleared_slots = std::collections::HashSet::new();
for provider in ProviderKind::ALL {
let slot = provider_slot(provider);
if !cleared_slots.insert(slot) {
continue;
}
if !provider_keyring_set(secrets, provider) {
continue;
}
if let Err(error) = secrets.delete(slot) {
failures.push(format!("{slot}: {error}"));
}
}
failures
}
fn external_consent(
store: &ConfigStore,
provider: ProviderKind,
) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
store
.config
.providers
.for_provider(provider)
.external_credentials
.as_ref()
}
fn external_read_consent(
store: &ConfigStore,
provider: ProviderKind,
) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
let (source, expected_path) = external_credential_target(provider, None).ok()?;
external_consent(store, provider)
.filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
}
fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
if external_read_consent(store, provider).is_none() {
return false;
}
if provider == ProviderKind::OpenaiCodex {
return true;
}
provider == ProviderKind::Xai
&& xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum XaiOAuthGenerationPointer {
Absent,
Valid,
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum XaiAuthDiagnosticRoute {
ApiKey,
OwnedOAuth,
NeedsRepair,
ExternalConsent,
}
#[derive(Debug, Clone)]
struct XaiAuthDiagnostics {
base_url: String,
official_endpoint: bool,
auth_mode: Option<String>,
oauth_selected: bool,
generation: XaiOAuthGenerationPointer,
route: XaiAuthDiagnosticRoute,
}
impl XaiAuthDiagnostics {
fn evaluates_runtime_api_key(&self) -> bool {
matches!(
self.route,
XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
)
}
fn is_custom_endpoint(&self) -> bool {
!self.official_endpoint
}
}
#[derive(Debug, Clone, Default)]
struct XaiRuntimeApiKey {
source: Option<RuntimeApiKeySource>,
last4: Option<String>,
}
impl XaiRuntimeApiKey {
fn source_name(&self) -> Option<&'static str> {
match self.source {
Some(RuntimeApiKeySource::Cli) => Some("cli"),
Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
Some(RuntimeApiKeySource::Env) => Some("env"),
None => None,
}
}
fn source_with_last4(&self) -> Option<String> {
self.source_name()
.map(|source| match self.last4.as_deref() {
Some(last4) => format!("{source} (last4: {last4})"),
None => source.to_string(),
})
}
fn uses(&self, source: RuntimeApiKeySource) -> bool {
self.source == Some(source)
}
}
fn runtime_overrides_for_provider(
runtime_overrides: &CliRuntimeOverrides,
provider: ProviderKind,
) -> CliRuntimeOverrides {
let mut overrides = runtime_overrides.clone();
overrides.provider = Some(provider);
overrides
}
fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
auth_mode.is_some_and(|mode| {
matches!(
mode.trim()
.to_ascii_lowercase()
.replace(['-', ' '], "_")
.as_str(),
"oauth"
| "xai_oauth"
| "xai"
| "grok"
| "grok_oauth"
| "grok_cli"
| "device"
| "device_code"
| "device_auth"
)
})
}
fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
match store
.config
.providers
.xai
.oauth_credential_generation
.as_deref()
{
None => XaiOAuthGenerationPointer::Absent,
Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
XaiOAuthGenerationPointer::Valid
}
Some(_) => XaiOAuthGenerationPointer::Invalid,
}
}
fn xai_auth_diagnostics(
store: &ConfigStore,
runtime_overrides: &CliRuntimeOverrides,
) -> XaiAuthDiagnostics {
let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
route_overrides.api_key = None;
route_overrides.auth_mode = Some("none".to_string());
let resolved = store.config.resolve_runtime_options(&route_overrides);
let official_endpoint =
provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
let auth_mode = store.config.providers.xai.auth_mode.clone();
let generation = xai_oauth_generation_pointer(store);
let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
let route = if !official_endpoint || !oauth_selected {
XaiAuthDiagnosticRoute::ApiKey
} else {
match generation {
XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
XaiOAuthGenerationPointer::Absent
if external_read_consent(store, ProviderKind::Xai).is_some() =>
{
XaiAuthDiagnosticRoute::ExternalConsent
}
XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
}
};
XaiAuthDiagnostics {
base_url: resolved.base_url,
official_endpoint,
auth_mode,
oauth_selected,
generation,
route,
}
}
fn xai_runtime_api_key(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> XaiRuntimeApiKey {
let resolved = store.config.resolve_runtime_options_with_secrets(
&runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
secrets,
);
debug_assert_eq!(resolved.provider, ProviderKind::Xai);
XaiRuntimeApiKey {
source: resolved.api_key_source,
last4: resolved.api_key.as_deref().map(last4_label),
}
}
fn api_key_source_name(
config_key: Option<&str>,
keyring_key: Option<&str>,
env_key: Option<&(&'static str, String)>,
) -> Option<&'static str> {
if config_key.is_some() {
Some("config")
} else if keyring_key.is_some() {
Some("secret store")
} else if env_key.is_some() {
Some("env")
} else {
None
}
}
fn xai_status_summary_source(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
"Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
}
XaiAuthDiagnosticRoute::NeedsRepair => {
let api_key = api_key
.and_then(XaiRuntimeApiKey::source_name)
.unwrap_or("no runtime-effective API key");
format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
}
XaiAuthDiagnosticRoute::ExternalConsent => {
"external consent configured/unprobed".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => api_key
.and_then(XaiRuntimeApiKey::source_name)
.unwrap_or("unset")
.to_string(),
}
}
fn xai_credential_route_label(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
"Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
.to_string()
}
XaiAuthDiagnosticRoute::NeedsRepair => {
let api_key = api_key
.and_then(XaiRuntimeApiKey::source_with_last4)
.unwrap_or_else(|| "no runtime-effective API key".to_string());
format!(
"xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
)
}
XaiAuthDiagnosticRoute::ExternalConsent => {
"external read-only consent configured/unprobed".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => api_key
.and_then(XaiRuntimeApiKey::source_with_last4)
.unwrap_or_else(|| "missing".to_string()),
}
}
fn xai_table_storage_status(
api_key: Option<&XaiRuntimeApiKey>,
source: RuntimeApiKeySource,
) -> &'static str {
match api_key {
Some(api_key) if api_key.uses(source) => "set",
Some(_) => "-",
None => "unprobed",
}
}
fn xai_list_storage_status(
api_key: Option<&XaiRuntimeApiKey>,
source: RuntimeApiKeySource,
) -> &'static str {
match api_key {
Some(api_key) if api_key.uses(source) => "yes",
Some(_) => "no",
None => "?",
}
}
fn xai_list_route(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
) -> &'static str {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
Some(RuntimeApiKeySource::Cli) => "cli",
Some(RuntimeApiKeySource::ConfigFile) => "config",
Some(RuntimeApiKeySource::Keyring) => "store",
Some(RuntimeApiKeySource::Env) => "env",
None => "missing",
},
}
}
fn xai_storage_detail(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
source: RuntimeApiKeySource,
) -> String {
match api_key {
Some(api_key) if api_key.uses(source) => api_key
.last4
.as_deref()
.map(|last4| format!("runtime-effective, last4: {last4}"))
.unwrap_or_else(|| "runtime-effective".to_string()),
Some(_) if diagnostics.is_custom_endpoint() => {
"not eligible for this custom xAI endpoint".to_string()
}
Some(_) => "not selected by the runtime resolver".to_string(),
None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
"not eligible for this custom xAI endpoint".to_string()
}
None if diagnostics.evaluates_runtime_api_key() => {
"not set for this runtime route".to_string()
}
None => "unprobed (structural OAuth/consent route)".to_string(),
}
}
fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
"lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
}
XaiAuthDiagnosticRoute::NeedsRepair => {
"lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
}
XaiAuthDiagnosticRoute::ExternalConsent => {
"lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
}
XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
"lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => {
"lookup order: CLI -> config -> secret store -> env".to_string()
}
}
}
fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
"xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
}
XaiAuthDiagnosticRoute::NeedsRepair => {
let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
Some("config") => "config-file".to_string(),
Some("secret store") => "secret-store".to_string(),
Some("env") => "env".to_string(),
Some("cli") => "cli".to_string(),
Some(other) => other.to_string(),
None => "no runtime-effective API key".to_string(),
};
format!(
"xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
)
}
XaiAuthDiagnosticRoute::ExternalConsent => {
"xai: configured (source: external read-only consent; availability unprobed)".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
Some("config") => "xai: set (source: config-file)".to_string(),
Some("secret store") => "xai: set (source: secret-store)".to_string(),
Some("env") => "xai: set (source: env)".to_string(),
Some("cli") => "xai: set (source: cli)".to_string(),
Some(other) => format!("xai: set (source: {other})"),
None => "xai: not set".to_string(),
},
}
}
fn auth_get_line_with_runtime(
store: &ConfigStore,
secrets: &Secrets,
provider: ProviderKind,
runtime_overrides: &CliRuntimeOverrides,
) -> String {
let slot = provider_slot(provider);
if provider == ProviderKind::Xai {
let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
let api_key = diagnostics
.evaluates_runtime_api_key()
.then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
return xai_get_line(&diagnostics, api_key.as_ref());
}
let config_key = provider_config_api_key(store, provider);
let keyring_key = config_key
.is_none()
.then(|| provider_keyring_api_key(secrets, provider))
.flatten();
let env_key = provider_env_value(provider);
match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
Some("config") => format!("{slot}: set (source: config-file)"),
Some("secret store") => format!("{slot}: set (source: secret-store)"),
Some("env") => format!("{slot}: set (source: env)"),
Some(other) => format!("{slot}: set (source: {other})"),
None => format!("{slot}: not set"),
}
}
#[cfg(test)]
fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
}
fn auth_status_all_providers_with_runtime(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> 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 ProviderKind::ALL {
if provider == ProviderKind::Xai {
let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
let api_key = diagnostics
.evaluates_runtime_api_key()
.then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
let active_marker = if provider == active_provider {
" *"
} else {
""
};
lines.push(format!(
"{:<14} {:<8} {:<10} {:<8} {}{}",
provider.as_str(),
xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
xai_status_summary_source(&diagnostics, api_key.as_ref()),
active_marker
));
continue;
}
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 external_selected = external_oauth_selected(store, provider);
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 {
if env_key.is_some() {
"env".to_string()
} else if external_selected {
"external consent (not probed)".to_string()
} else {
"unset".to_string()
}
} else if external_selected {
"external consent (not probed)".to_string()
} else if config_key.is_some() {
"config".to_string()
} else if keyring_key.is_some() {
"keyring".to_string()
} else if env_key.is_some() {
"env".to_string()
} else {
"unset".to_string()
};
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
}
#[cfg(test)]
fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
}
fn auth_list_lines_with_runtime(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> Vec<String> {
let mut lines = Vec::new();
lines.push("provider config store env route".to_string());
for provider in ProviderKind::ALL {
let slot = provider_slot(provider);
if provider == ProviderKind::Xai {
let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
let api_key = diagnostics
.evaluates_runtime_api_key()
.then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
lines.push(format!(
"{slot:<12} {} {} {} {}",
xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
xai_list_route(&diagnostics, api_key.as_ref())
));
continue;
}
let file = provider_config_set(store, provider);
let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
let env = provider_env_set(provider);
let external_selected = external_oauth_selected(store, provider);
let active = if provider == ProviderKind::OpenaiCodex {
if env {
"env"
} else if external_selected {
"external-consent"
} else {
"missing"
}
} else if external_selected {
"external-consent"
} else if file {
"config"
} else if keyring == Some(true) {
"store"
} else if env {
"env"
} else {
"missing"
};
lines.push(format!(
"{slot:<12} {} {} {} {active}",
yes_no(file),
keyring_status_short(keyring),
yes_no(env)
));
}
lines
}
#[cfg(test)]
fn auth_status_lines_for_provider(
store: &ConfigStore,
secrets: &Secrets,
provider: ProviderKind,
) -> Vec<String> {
auth_status_lines_for_provider_with_runtime(
store,
secrets,
provider,
&CliRuntimeOverrides::default(),
)
}
fn auth_status_lines_for_provider_with_runtime(
store: &ConfigStore,
secrets: &Secrets,
provider: ProviderKind,
runtime_overrides: &CliRuntimeOverrides,
) -> Vec<String> {
if provider == ProviderKind::Xai {
return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
}
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 external = external_consent(store, provider);
let external_selected = external_oauth_selected(store, provider);
let active_label = {
let active_source = if provider == ProviderKind::OpenaiCodex {
if env_key.is_some() {
"env"
} else if external_selected {
"external read-only consent (availability not probed)"
} else {
"missing"
}
} else if external_selected {
"external read-only consent (availability not probed)"
} 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)))
};
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 -> consent-gated exact Codex CLI file".to_string()
} else {
"lookup order: config -> secret store -> env".to_string()
};
let auth_mode = if provider == ProviderKind::OpenaiCodex {
"codex_oauth".to_string()
} else {
provider_cfg
.auth_mode
.as_deref()
.or(store.config.auth_mode.as_deref())
.unwrap_or("api_key")
.to_string()
};
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: {} ({})",
codewhale_config::quote_os_path(store.path()),
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 Ok((source, expected_path)) = external_credential_target(provider, None) {
let status = codewhale_config::external_credential_consent_status(
external,
provider,
source,
&expected_path,
store.config.provider,
);
lines.push(format!(
"external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
status.access.as_str(),
status.provider,
status.source.as_str(),
status.owner,
codewhale_config::quote_os_path(&status.path),
status.consent_version,
status.route_state,
status.scope_valid,
status.ambient_path_changed,
));
lines.push(format!("semantics: {}", status.semantics));
lines.push(format!("revoke: {}", status.revoke_command));
if let Some(warning) = status.ambient_path_warning() {
lines.push(warning);
}
} else {
lines.push("external credentials: disabled (no file was probed)".to_string());
}
lines
}
fn xai_auth_status_lines_for_provider(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> Vec<String> {
let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
let api_key = diagnostics
.evaluates_runtime_api_key()
.then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
let external = external_consent(store, ProviderKind::Xai);
let selected_marker = if store.config.provider == ProviderKind::Xai {
" (selected provider)"
} else {
""
};
let provider_cfg = &store.config.providers.xai;
let model = provider_cfg.model.as_deref().unwrap_or("(default)");
let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
let mut lines = vec![
format!("provider: xai{selected_marker}"),
format!("route: {}", diagnostics.base_url),
format!("model: {model}"),
format!("auth mode: {auth_mode}"),
format!(
"credential route: {}",
xai_credential_route_label(&diagnostics, api_key.as_ref())
),
xai_lookup_order(&diagnostics),
format!(
"config file: {} ({})",
codewhale_config::quote_os_path(store.path()),
xai_storage_detail(
&diagnostics,
api_key.as_ref(),
RuntimeApiKeySource::ConfigFile
)
),
format!(
"secret store: {} ({})",
secrets.backend_name(),
xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
),
format!(
"env var: {} ({})",
provider_env_vars(ProviderKind::Xai).join("/"),
xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
),
format!(
"endpoint policy: {}",
if diagnostics.official_endpoint {
"official xAI endpoint"
} else {
"custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
}
),
];
lines.push(match diagnostics.generation {
XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
XaiOAuthGenerationPointer::Valid
if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
{
"xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
.to_string()
}
XaiOAuthGenerationPointer::Valid => {
"xAI OAuth generation: valid but inactive for this route".to_string()
}
XaiOAuthGenerationPointer::Invalid => {
"xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
}
});
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
lines.push(
"external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
.to_string(),
);
return lines;
}
XaiAuthDiagnosticRoute::NeedsRepair => {
lines.push(
"external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
.to_string(),
);
lines.push(
"repair: run `codewhale auth xai-device` to replace the owned generation, or switch [providers.xai] auth_mode to \"api_key\" and remove oauth_credential_generation. Grok CLI consent remains blocked until the pointer is absent."
.to_string(),
);
return lines;
}
XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
lines.push(
"external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
.to_string(),
);
return lines;
}
XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
lines.push(
"external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
.to_string(),
);
return lines;
}
XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
}
if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
let status = codewhale_config::external_credential_consent_status(
external,
ProviderKind::Xai,
source,
&expected_path,
store.config.provider,
);
lines.push(format!(
"external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
status.access.as_str(),
status.provider,
status.source.as_str(),
status.owner,
codewhale_config::quote_os_path(&status.path),
status.consent_version,
status.route_state,
status.scope_valid,
status.ambient_path_changed,
));
lines.push(format!("semantics: {}", status.semantics));
lines.push(format!("revoke: {}", status.revoke_command));
if let Some(warning) = status.ambient_path_warning() {
lines.push(warning);
}
} else {
lines.push("external credentials: disabled (no file was probed)".to_string());
}
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_with_runtime(
store: &mut ConfigStore,
command: AuthCommand,
runtime_overrides: &CliRuntimeOverrides,
) -> Result<()> {
run_auth_command_with_secrets_and_runtime(
store,
command,
&Secrets::auto_detect(),
runtime_overrides,
)
}
#[cfg(test)]
fn run_auth_command_with_secrets(
store: &mut ConfigStore,
command: AuthCommand,
secrets: &Secrets,
) -> Result<()> {
run_auth_command_with_secrets_and_runtime(
store,
command,
secrets,
&CliRuntimeOverrides::default(),
)
}
fn run_auth_command_with_secrets_and_runtime(
store: &mut ConfigStore,
command: AuthCommand,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> Result<()> {
match command {
AuthCommand::XaiDevice => {
let argv = vec![
"codewhale".to_string(),
"auth".to_string(),
"xai-device".to_string(),
];
let code = codewhale_tui::run(argv);
std::process::exit(if code == std::process::ExitCode::SUCCESS {
0
} else {
1
})
}
AuthCommand::ExternalConsent {
provider,
mode,
path,
yes,
} => {
let provider: ProviderKind = provider.into();
let (source, path) = external_credential_target(provider, path)?;
let preview = external_consent_preview_lines(provider, source, &path);
for line in &preview {
println!("{line}");
}
if mode == ExternalCredentialModeArg::Managed {
bail!(
"managed external credential access is unsupported in v0.9.1: no provider has a reviewed schema-safe preservation adapter. Use --mode read-only, or use Codewhale-owned login/API-key storage."
);
}
confirm_external_consent(yes)?;
let path_value = path.to_str().context(
"external credential path cannot be persisted losslessly because it is not valid UTF-8",
)?;
let provider_key = provider.provider().provider_config_key();
codewhale_config::mutate_config_document(store.path(), |document| {
if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
codewhale_config::set_config_document_value(
document,
&["providers", provider_key, "auth_mode"],
"oauth",
)?;
}
let prefix = &["providers", provider_key, "external_credentials"];
codewhale_config::set_config_document_value(
document,
&[prefix[0], prefix[1], prefix[2], "access"],
"read_only",
)?;
codewhale_config::set_config_document_value(
document,
&[prefix[0], prefix[1], prefix[2], "provider"],
provider.as_str(),
)?;
codewhale_config::set_config_document_value(
document,
&[prefix[0], prefix[1], prefix[2], "source"],
source.as_str(),
)?;
codewhale_config::set_config_document_value(
document,
&[prefix[0], prefix[1], prefix[2], "path"],
path_value,
)?;
codewhale_config::set_config_document_value(
document,
&[prefix[0], prefix[1], prefix[2], "consent_version"],
i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
)
})?;
store
.reload()
.context("external consent was saved, but config reload failed")?;
println!(
"saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
provider.as_str(),
source.as_str(),
codewhale_config::quote_os_path(&path),
codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
);
println!(
"revoke with: codewhale auth external-revoke --provider {}",
provider.as_str()
);
Ok(())
}
AuthCommand::ExternalRevoke { provider } => {
let provider: ProviderKind = provider.into();
let provider_key = provider.provider().provider_config_key();
codewhale_config::mutate_config_document(store.path(), |document| {
codewhale_config::unset_config_document_value(
document,
&["providers", provider_key, "external_credentials"],
)?;
Ok(())
})?;
store
.reload()
.context("external consent was revoked, but config reload failed")?;
println!(
"external credential access disabled for {}",
provider.as_str()
);
Ok(())
}
AuthCommand::Status { provider } => {
match provider {
Some(p) => {
let provider: ProviderKind = p.into();
for line in auth_status_lines_for_provider_with_runtime(
store,
secrets,
provider,
runtime_overrides,
) {
println!("{line}");
}
}
None => {
for line in
auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
{
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)?,
};
let mut credential_store = credential_metadata_store(store)?;
let store = credential_store.as_mut().unwrap_or(store);
let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
if secret_store_saved {
println!(
"saved API key for {slot} to {} (config contains metadata only)",
secrets.backend_name(),
);
} else {
println!("saved API key for {slot} to {}", store.path().display());
}
Ok(())
}
AuthCommand::Get { provider } => {
let provider: ProviderKind = provider.into();
println!(
"{}",
auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
);
Ok(())
}
AuthCommand::PrintApiKey { provider } => {
let provider: ProviderKind = provider.into();
let mut stdout = io::stdout().lock();
credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
})
}
AuthCommand::Clear { provider } => {
let provider: ProviderKind = provider.into();
if provider == ProviderKind::Xai {
codewhale_config::with_xai_oauth_revocation_transaction(|| {
clear_auth_provider(store, secrets, provider)
})
} else {
clear_auth_provider(store, secrets, provider)
}
}
AuthCommand::List => {
for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
println!("{line}");
}
Ok(())
}
AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
}
}
fn external_consent_preview_lines(
provider: ProviderKind,
source: codewhale_config::ExternalCredentialSource,
path: &Path,
) -> Vec<String> {
vec![
"External credential consent preview (nothing has been saved):".to_string(),
format!(" provider: {}", provider.as_str()),
format!(
" owning CLI: {} ({})",
source.owner_label(),
source.as_str()
),
format!(
" exact resolved path: {}",
codewhale_config::quote_os_path(path)
),
format!(
" access: read_only ({})",
codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
),
" managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
format!(
" revoke: codewhale auth external-revoke --provider {}",
provider.as_str()
),
]
}
fn confirm_external_consent(yes: bool) -> Result<()> {
use std::io::IsTerminal;
if yes {
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!(
"external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
);
}
confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
}
fn confirm_external_consent_answer(
reader: &mut impl std::io::BufRead,
writer: &mut impl std::io::Write,
) -> Result<()> {
write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
writer.flush()?;
let mut answer = String::new();
reader
.read_line(&mut answer)
.context("reading external credential consent confirmation")?;
if answer.trim() != "yes" {
bail!("external credential consent cancelled; no configuration was changed");
}
Ok(())
}
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() {
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)
}
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();
let literal =
|value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
for provider in ProviderKind::ALL {
let slot = provider_slot(provider);
let from_provider_block = store
.config
.providers
.for_provider(provider)
.api_key
.clone()
.filter(literal);
let from_root = (provider == ProviderKind::Deepseek)
.then(|| store.config.api_key.clone())
.flatten()
.filter(literal);
let value = from_provider_block.or(from_root);
let Some(value) = value else { continue };
if let Ok(Some(existing)) = secrets.get(slot)
&& existing == value
{
} 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")?;
}
if !dry_run {
codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
.context("failed to remove plaintext API keys from config backup")?;
}
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 } => {
clear_recorded_telemetry_opt_out_if_reenabled(&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 => {
println!("# configured values ({})", store.path().display());
println!(
"# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
);
for (key, value) in store.config.list_values() {
println!("{key} = {value}");
}
Ok(())
}
ConfigCommand::Path => {
println!("{}", store.path().display());
Ok(())
}
}
}
fn clear_recorded_telemetry_opt_out_if_reenabled(key: &str, value: &str) -> Result<()> {
let turning_on = matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on" | "enabled"
);
if key != "telemetry" || !turning_on {
return Ok(());
}
if let Some(mut state) = SetupState::load()?
&& state.telemetry_opted_out()
{
state.record_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION, true);
state.save()?;
}
Ok(())
}
fn model_command_provider_hint(
command_provider: Option<ProviderArg>,
top_level_provider: Option<ProviderKind>,
) -> Option<ProviderKind> {
command_provider
.map(ProviderKind::from)
.or(top_level_provider)
}
fn provider_source_label(source: ProviderSource) -> String {
match source {
ProviderSource::Cli => "--provider".to_string(),
ProviderSource::Env(name) => format!("environment ({name})"),
ProviderSource::Config => "config".to_string(),
}
}
fn run_model_command(
store: &mut ConfigStore,
command: ModelCommand,
top_level_provider: Option<ProviderKind>,
resolved_runtime: &ResolvedRuntimeOptions,
) -> Result<()> {
let registry = ModelRegistry::default();
match command {
ModelCommand::List { provider } => {
let filter = model_command_provider_hint(provider, top_level_provider);
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 subcommand_provider = provider.map(ProviderKind::from);
let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
if queried.is_none() && subcommand_provider.is_none() {
let source = resolved_runtime.model_source;
println!(
"requested: {}",
if source.is_explicit() {
resolved_runtime.model.as_str()
} else {
""
}
);
println!("resolved: {}", resolved_runtime.model);
println!("provider: {}", resolved_runtime.provider.as_str());
println!("used_fallback: {}", !source.is_explicit());
println!(
"provider_source: {}",
provider_source_label(resolved_runtime.provider_source)
);
println!("model_source: {}", source.as_str());
return Ok(());
}
let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
let mut resolved = registry.resolve(queried, provider_hint);
let provider_named_by_user =
subcommand_provider.is_some() || top_level_provider.is_some();
if !provider_named_by_user && queried.is_some() && resolved.used_fallback {
resolved = registry.resolve(queried, None);
}
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);
println!(
"provider_source: {}",
if subcommand_provider.is_some() {
"--provider".to_string()
} else {
provider_source_label(resolved_runtime.provider_source)
}
);
println!(
"model_source: {}",
if queried.is_some() {
"argument"
} else {
resolved_runtime.model_source.as_str()
}
);
Ok(())
}
ModelCommand::Set { model } => {
let trimmed = model.trim();
if trimmed.is_empty() {
bail!("Model name cannot be empty");
}
let canonical = match trimmed.to_ascii_lowercase().as_str() {
"pro" | "deepseek-v4pro" => "deepseek-v4-pro",
"flash" | "deepseek-v4flash" => "deepseek-v4-flash",
"auto" => "auto",
_ => trimmed,
};
store.config.default_text_model = Some(canonical.to_string());
store.save()?;
println!("Default model set to '{canonical}'");
Ok(())
}
}
}
fn thread_delegation(command: &ThreadCommand) -> Option<Vec<String>> {
match command {
ThreadCommand::Resume { thread_id } => Some(vec!["resume".to_string(), thread_id.clone()]),
ThreadCommand::Fork { thread_id } => Some(vec!["fork".to_string(), thread_id.clone()]),
ThreadCommand::List { .. }
| ThreadCommand::Read { .. }
| ThreadCommand::Archive { .. }
| ThreadCommand::Unarchive { .. }
| ThreadCommand::SetName { .. }
| ThreadCommand::ClearName { .. } => None,
}
}
fn run_thread_command(
cli: &Cli,
store: &mut ConfigStore,
runtime_overrides: &CliRuntimeOverrides,
command: ThreadCommand,
) -> Result<()> {
if let Some(passthrough) = thread_delegation(&command) {
let resolved_runtime = resolve_runtime_for_dispatch(store, runtime_overrides);
return run_tui_in_process(cli, &resolved_runtime, passthrough);
}
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 { .. } | ThreadCommand::Fork { .. } => {
unreachable!("thread_delegation routes resume and fork before this match")
}
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(
cli: &Cli,
resolved_runtime: &ResolvedRuntimeOptions,
args: AppServerArgs,
) -> Result<()> {
if args.http || args.mobile {
return run_tui_server_in_process(
cli,
resolved_runtime,
app_server_serve_passthrough(&args),
);
}
let session = start_cli_telemetry(
resolved_runtime,
args.config.clone().or_else(|| cli.config.clone()),
Surface::AppServer,
);
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to create tokio runtime")
{
Ok(runtime) => runtime,
Err(error) => {
let outcome = Err(error);
finish_cli_telemetry(session, &outcome);
return outcome;
}
};
if args.stdio {
let outcome = runtime.block_on(run_app_server_stdio(args.config));
finish_cli_telemetry(session, &outcome);
return outcome;
}
let host = args.host.as_deref().unwrap_or("127.0.0.1");
let port = args.port.unwrap_or(8787);
let outcome = format!("{host}:{port}")
.parse::<SocketAddr>()
.with_context(|| format!("invalid app-server listen address {host}:{port}"))
.and_then(|listen| {
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,
}))
});
finish_cli_telemetry(session, &outcome);
outcome
}
fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
let mut forwarded = vec!["serve".to_string()];
forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
if let Some(host) = args.host.as_ref() {
forwarded.push("--host".to_string());
forwarded.push(host.clone());
}
if let Some(port) = args.port {
forwarded.push("--port".to_string());
forwarded.push(port.to_string());
}
if let Some(workers) = args.workers {
forwarded.push("--workers".to_string());
forwarded.push(workers.to_string());
}
for origin in &args.cors_origin {
forwarded.push("--cors-origin".to_string());
forwarded.push(origin.clone());
}
if let Some(token) = args.auth_token.as_ref() {
forwarded.push("--auth-token".to_string());
forwarded.push(token.clone());
}
if args.insecure_no_auth {
forwarded.push("--insecure".to_string());
}
if args.qr {
forwarded.push("--qr".to_string());
}
forwarded
}
fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
vec![
"serve".to_string(),
"--web".to_string(),
"--port".to_string(),
args.port.to_string(),
]
}
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 raw = store
.config
.get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
.map(ToOwned::to_owned)
.or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
let Some(raw) = raw 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).map_err(|_| {
anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
})?;
serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
anyhow!(
"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
)
})
}
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()
}
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
#[derive(Debug)]
#[cfg(unix)]
#[cfg(not(unix))]
#[cfg(windows)]
#[cfg(windows)]
#[cfg(windows)]
unsafe impl Send for ServerChildJob {}
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);
}
run_tui_in_process(cli, resolved_runtime, passthrough)
}
fn run_dispatcher_resume_picker(
cli: &Cli,
resolved_runtime: &ResolvedRuntimeOptions,
) -> Result<()> {
let argv = tui_argv(cli, vec!["sessions".to_string()]);
apply_tui_env(cli, resolved_runtime, &argv);
let code = codewhale_tui::run(argv);
if code != std::process::ExitCode::SUCCESS {
std::process::exit(if code == std::process::ExitCode::SUCCESS {
0
} else {
1
})
}
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.");
}
run_tui_in_process(
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 run_tui_in_process(
cli: &Cli,
resolved_runtime: &ResolvedRuntimeOptions,
passthrough: Vec<String>,
) -> Result<()> {
let argv = tui_argv(cli, passthrough.clone());
apply_tui_env(cli, resolved_runtime, &passthrough);
let code = codewhale_tui::run(argv);
std::process::exit(if code == std::process::ExitCode::SUCCESS {
0
} else {
1
})
}
fn run_tui_server_in_process(
cli: &Cli,
resolved_runtime: &ResolvedRuntimeOptions,
passthrough: Vec<String>,
) -> Result<()> {
let argv = tui_argv(cli, passthrough.clone());
apply_tui_env(cli, resolved_runtime, &passthrough);
let code = codewhale_tui::run(argv);
std::process::exit(if code == std::process::ExitCode::SUCCESS {
0
} else {
1
})
}
fn tui_argv(cli: &Cli, passthrough: Vec<String>) -> Vec<String> {
let mut args = Vec::new();
args.push("codewhale".to_string());
if let Some(config) = cli.config.as_deref() {
args.push("--config".to_string());
args.push(config.display().to_string());
}
if let Some(profile) = cli.profile.as_ref() {
args.push("--profile".to_string());
args.push(profile.clone());
}
if let Some(workspace) = cli.workspace.as_deref() {
args.push("--workspace".to_string());
args.push(workspace.display().to_string());
}
if cli.mouse_capture {
args.push("--mouse-capture".to_string());
}
if cli.no_mouse_capture {
args.push("--no-mouse-capture".to_string());
}
if cli.skip_onboarding {
args.push("--skip-onboarding".to_string());
}
if cli.no_project_config {
args.push("--no-project-config".to_string());
}
args.extend(passthrough);
args
}
fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) {
let mut verbosity = if cli.profile.is_some() {
cli.verbosity.clone()
} else {
resolved_runtime.verbosity.clone()
};
if verbosity.is_none()
&& passthrough
.iter()
.any(|arg| matches!(arg.as_str(), "exec" | "eval"))
{
verbosity = Some("concise".to_string());
}
let uses_raw_tui_provider = cli
.provider
.as_deref()
.is_some_and(|provider| builtin_provider_arg(provider).is_none());
let keyring_bridge_provider = resolved_runtime.provider;
let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
let keyring_bridge_source = resolved_runtime.api_key_source;
if let Some(provider) = cli.provider.as_deref() {
let provider = builtin_provider_arg(provider)
.map(ProviderKind::from)
.map_or_else(
|| provider.to_string(),
|provider| provider.as_str().to_string(),
);
unsafe {
std::env::set_var("CODEWHALE_PROVIDER", &provider);
std::env::set_var("DEEPSEEK_PROVIDER", provider);
}
}
if !(uses_raw_tui_provider
|| (cli.profile.is_some()
&& matches!(resolved_runtime.provider_source, ProviderSource::Config)))
&& matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
&& let Some(api_key) = keyring_bridge_api_key
{
unsafe {
std::env::set_var("DEEPSEEK_API_KEY", api_key);
for var in provider_env_vars(keyring_bridge_provider) {
if *var != "DEEPSEEK_API_KEY" {
std::env::set_var(var, api_key);
}
}
std::env::set_var(
"DEEPSEEK_API_KEY_SOURCE",
RuntimeApiKeySource::Keyring.as_env_value(),
);
}
}
if let Some(model) = cli.model.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_MODEL", model);
std::env::set_var("DEEPSEEK_MODEL", model);
}
}
if let Some(output_mode) = cli.output_mode.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_OUTPUT_MODE", output_mode);
std::env::set_var("DEEPSEEK_OUTPUT_MODE", output_mode);
}
}
if let Some(v) = verbosity.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_VERBOSITY", v);
std::env::set_var("DEEPSEEK_VERBOSITY", v);
}
}
if let Some(log_level) = cli.log_level.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_LOG_LEVEL", log_level);
std::env::set_var("DEEPSEEK_LOG_LEVEL", log_level);
}
}
let telemetry = resolved_runtime.telemetry.to_string();
unsafe {
std::env::set_var("CODEWHALE_TELEMETRY", &telemetry);
std::env::set_var("DEEPSEEK_TELEMETRY", &telemetry);
}
let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
unsafe {
std::env::set_var(
codewhale_config::TELEMETRY_FLOOR_ENV,
if floor { "1" } else { "0" },
);
}
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
std::env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
}
}
if let Some(policy) = cli.approval_policy.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_APPROVAL_POLICY", policy);
std::env::set_var("DEEPSEEK_APPROVAL_POLICY", policy);
}
}
if let Some(mode) = cli.sandbox_mode.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_SANDBOX_MODE", mode);
std::env::set_var("DEEPSEEK_SANDBOX_MODE", mode);
}
}
if cli.yolo {
unsafe {
std::env::set_var("CODEWHALE_YOLO", "true");
std::env::set_var("DEEPSEEK_YOLO", "true");
}
}
if let Some(api_key) = cli.api_key.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_CLI_API_KEY", api_key);
}
if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
unsafe {
std::env::set_var("DEEPSEEK_API_KEY", api_key);
for var in provider_env_vars(resolved_runtime.provider) {
if *var != "DEEPSEEK_API_KEY" {
std::env::set_var(var, api_key);
}
}
}
}
unsafe {
std::env::set_var("DEEPSEEK_API_KEY_SOURCE", "cli");
}
}
if let Some(base_url) = cli.base_url.as_ref() {
unsafe {
std::env::set_var("CODEWHALE_BASE_URL", base_url);
std::env::set_var("DEEPSEEK_BASE_URL", base_url);
}
}
}
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 codewhale_config::{ModelSource, ProviderSource};
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()
}
pub(crate) 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())
}
pub(crate) struct ScopedEnvVar {
name: &'static str,
previous: Option<OsString>,
}
impl ScopedEnvVar {
pub(crate) fn set(name: &'static str, value: &str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self { name, previous }
}
pub(crate) fn remove(name: &'static str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
Self { name, previous }
}
}
impl Drop for ScopedEnvVar {
fn drop(&mut self) {
unsafe {
if let Some(previous) = self.previous.take() {
std::env::set_var(self.name, previous.clone());
} else {
std::env::remove_var(self.name);
}
}
}
}
#[derive(Default)]
struct RecordingKeyringStore {
gets: Mutex<Vec<String>>,
values: Mutex<std::collections::BTreeMap<String, String>>,
}
impl RecordingKeyringStore {
fn set_value(&self, key: &str, value: &str) {
self.values
.lock()
.expect("recording values lock")
.insert(key.to_string(), value.to_string());
}
fn queried(&self) -> Vec<String> {
self.gets.lock().expect("recording gets lock").clone()
}
}
impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
fn get(
&self,
key: &str,
) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
self.gets
.lock()
.expect("recording gets lock")
.push(key.to_string());
Ok(self
.values
.lock()
.expect("recording values lock")
.get(key)
.cloned())
}
fn set(
&self,
key: &str,
value: &str,
) -> std::result::Result<(), codewhale_secrets::SecretsError> {
self.set_value(key, value);
Ok(())
}
fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
self.values
.lock()
.expect("recording values lock")
.remove(key);
Ok(())
}
fn backend_name(&self) -> &'static str {
"recording"
}
}
fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
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();
let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
(dir, bin)
}
fn resolved_runtime_for_test(
provider: ProviderKind,
provider_source: ProviderSource,
) -> ResolvedRuntimeOptions {
ResolvedRuntimeOptions {
provider,
provider_source,
model: "test-model".to_string(),
model_source: ModelSource::ProviderDefault,
api_key: None,
api_key_source: None,
base_url: "http://localhost:8000/v1".to_string(),
auth_mode: None,
insecure_skip_tls_verify: false,
output_mode: None,
log_level: None,
telemetry: false,
telemetry_explicit_off: false,
telemetry_endpoint: None,
approval_policy: None,
sandbox_mode: None,
yolo: None,
verbosity: None,
http_headers: std::collections::BTreeMap::new(),
}
}
#[test]
fn clap_command_definition_is_consistent() {
Cli::command().debug_assert();
}
#[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();
assert_eq!(
err.to_string(),
"failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
);
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 malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
let secret = "sentinel";
let raw =
format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
let diagnostic = format!("{error:#}");
assert!(!diagnostic.contains(secret), "{diagnostic}");
assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
}
#[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"
));
let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
assert!(matches!(
cli.command,
Some(Commands::Model(ModelArgs {
command: ModelCommand::Set { ref model }
})) if model == "pro"
));
}
#[test]
fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
assert_eq!(
model_command_provider_hint(None, Some(ProviderKind::Zai)),
Some(ProviderKind::Zai)
);
assert_eq!(
model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
Some(ProviderKind::Minimax)
);
assert_eq!(model_command_provider_hint(None, None), None);
let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
assert_eq!(cli.provider.as_deref(), Some("zai"));
assert!(matches!(
cli.command,
Some(Commands::Model(ModelArgs {
command: ModelCommand::List { provider: None }
}))
));
}
#[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 {
host: Some(ref host),
port: Some(9999),
stdio: false,
http: false,
mobile: 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 app_server_transports_are_mutually_exclusive() {
assert!(matches!(
parse_ok(&["deepseek", "app-server", "--http"]).command,
Some(Commands::AppServer(AppServerArgs {
http: true,
mobile: false,
stdio: false,
..
}))
));
assert!(matches!(
parse_ok(&["deepseek", "app-server", "--mobile"]).command,
Some(Commands::AppServer(AppServerArgs {
mobile: true,
http: false,
stdio: false,
..
}))
));
for argv in [
["deepseek", "app-server", "--http", "--mobile"].as_slice(),
["deepseek", "app-server", "--http", "--stdio"].as_slice(),
["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
] {
let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
}
}
#[test]
fn app_server_qr_requires_mobile() {
let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
.expect_err("--qr without --mobile must fail");
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
assert!(matches!(
parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
Some(Commands::AppServer(AppServerArgs {
mobile: true,
qr: true,
..
}))
));
}
#[test]
fn app_server_serve_passthrough_maps_flags_to_serve() {
let args = AppServerArgs {
http: true,
mobile: false,
stdio: false,
qr: false,
host: Some("127.0.0.1".to_string()),
port: Some(9000),
workers: Some(4),
config: None,
auth_token: Some("tok".to_string()),
insecure_no_auth: true,
cors_origin: vec!["http://localhost:5173".to_string()],
};
let argv = app_server_serve_passthrough(&args);
let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
assert_eq!(
as_str,
vec![
"serve",
"--http",
"--host",
"127.0.0.1",
"--port",
"9000",
"--workers",
"4",
"--cors-origin",
"http://localhost:5173",
"--auth-token",
"tok",
"--insecure",
]
);
}
#[test]
fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
let args = AppServerArgs {
http: false,
mobile: true,
stdio: false,
qr: true,
host: None,
port: None,
workers: None,
config: None,
auth_token: None,
insecure_no_auth: false,
cors_origin: vec![],
};
let argv = app_server_serve_passthrough(&args);
let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
}
#[test]
fn web_command_is_typed_and_delegates_without_auth_material() {
let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
let args = match cli.command {
Some(Commands::Web(args)) => args,
other => panic!("expected web command, got {other:?}"),
};
assert_eq!(args.port, 9091);
let forwarded = web_serve_passthrough(&args);
assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
assert!(!forwarded.iter().any(|arg| arg.contains("token")));
}
#[test]
fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
let cli = parse_ok(&["codewhale", "web"]);
assert!(matches!(
cli.command,
Some(Commands::Web(WebArgs { port: 7878 }))
));
let help = help_for(&["codewhale", "web", "--help"]);
assert!(help.contains("--port"));
assert!(help.contains("one-time loopback bootstrap"));
assert!(!help.contains("--auth-token"));
}
#[test]
fn serve_help_documents_forwarded_runtime_modes() {
let help = help_for(&["codewhale", "serve", "--help"]);
for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
assert!(
help.contains(flag),
"serve help should document forwarded flag {flag}; help was:\n{help}"
);
}
assert!(help.contains("compatibility"));
}
#[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"]
));
let cli = parse_ok(&["codewhale", "fleet", "init"]);
assert!(cli.prompt.is_empty());
assert!(matches!(
cli.command,
Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
));
let cli = parse_ok(&[
"codewhale",
"fleet",
"run",
"tasks.json",
"--max-workers",
"2",
]);
assert!(cli.prompt.is_empty());
assert!(matches!(
cli.command,
Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
if args == &["run", "tasks.json", "--max-workers", "2"]
));
let cli = parse_ok(&[
"codewhale",
"workflow",
"run",
"stopship",
"--fleet",
"stopship",
"--runtime",
"tmux",
"--issue",
"4375",
]);
assert!(matches!(
cli.command,
Some(Commands::Workflow(WorkflowArgs {
command: WorkflowCommand::Run {
ref workflow,
ref fleet,
ref runtime,
ref issue,
..
}
})) if workflow == "stopship"
&& fleet.as_deref() == Some("stopship")
&& runtime == "tmux"
&& issue.as_deref() == Some("4375")
));
}
#[test]
fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
assert_eq!(
top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
.expect("built-in Exec provider"),
Some(ProviderKind::Openrouter)
);
for (provider, command) in [
("qianfan", vec!["exec", "Reply OK"]),
("lm-studio", vec!["exec", "Reply OK"]),
("lm-studio", vec!["fleet", "status"]),
] {
let argv = std::iter::once("codewhale")
.chain(["--provider", provider])
.chain(command.iter().copied())
.collect::<Vec<_>>();
let cli = parse_ok(&argv);
assert_eq!(cli.provider.as_deref(), Some(provider));
assert_eq!(
top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
.expect("raw TUI provider"),
None,
"{argv:?} should defer the raw provider id to the TUI"
);
}
}
#[test]
fn opencode_go_provider_aliases_parse_as_builtin() {
for alias in ["opencode-go", "opencode_go", "opencodego"] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeGo));
}
}
#[test]
fn ollama_cloud_provider_aliases_parse_as_builtin() {
for alias in ["ollama-cloud", "ollama_cloud"] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OllamaCloud));
}
}
#[test]
fn antigravity_provider_aliases_parse_as_builtin() {
for alias in ["antigravity", "agy"] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::Antigravity));
}
}
#[test]
fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
for alias in [
"minimax-anthropic",
"minimax_anthropic",
"mini-max-anthropic",
"mini_max_anthropic",
] {
assert_eq!(
builtin_provider_arg(alias),
Some(ProviderArg::MinimaxAnthropic),
"{alias}"
);
}
let cli = parse_ok(&[
"codewhale",
"--provider",
"minimax-anthropic",
"exec",
"Reply OK",
]);
assert_eq!(
top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
.expect("legacy dual-wire provider"),
Some(ProviderKind::MinimaxAnthropic)
);
}
#[test]
fn opencode_zen_provider_aliases_parse_as_builtin() {
for alias in [
"opencode-zen",
"opencode_zen",
"opencodezen",
"zen",
"opencode",
] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen));
}
}
#[test]
fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
.expect_err("model registry commands still require a built-in provider");
assert!(
err.to_string()
.contains("configured custom providers are accepted only by exec and fleet")
);
let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
.expect_err("auth keeps enum-only provider validation");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
let err = Cli::try_parse_from([
"codewhale",
"--provider",
"../../lm-studio",
"exec",
"Reply OK",
])
.expect_err("provider ids must stay simple tokens");
assert!(
err.to_string()
.contains("provider must be a simple identifier")
);
}
#[test]
fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
let cli = parse_ok(&[
"codewhale",
"lane-log-proxy",
"--log-path",
"/tmp/lane.ndjson",
"--receipt-path",
"/tmp/lane.exit.json",
"--receipt-tmp-path",
"/tmp/lane.exit.json.tmp",
"--environment-path",
"/tmp/lane.env.json",
"--lane-id",
"lane-proof",
"--",
"/bin/echo",
"--child-flag",
"hello",
]);
let (proxy, command) = split_lane_log_proxy_command(cli.command);
assert!(command.is_none());
let proxy = proxy.expect("proxy args");
assert_eq!(proxy.lane_id, "lane-proof");
assert_eq!(
proxy.command,
["/bin/echo", "--child-flag", "hello"].map(str::to_string)
);
let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
let (proxy, command) = split_lane_log_proxy_command(cli.command);
assert!(proxy.is_none());
assert!(matches!(
command,
Some(Commands::Lane(LaneArgs {
command: LaneCommand::List { json: true }
}))
));
}
#[test]
fn cli_lane_subcommands_cover_the_shared_control_contract() {
use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
let argv = [
"codewhale".to_string(),
"lane".to_string(),
descriptor.verb.to_string(),
];
let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
if descriptor.target.requires_identity() {
argv.push("lane-a1b2c3d4");
}
let cli = parse_ok(&argv);
let Some(Commands::Lane(args)) = cli.command else {
panic!("`{}` must parse as a lane subcommand", descriptor.verb);
};
let parsed = match args.command {
LaneCommand::List { .. } => ControlOperation::LaneList,
LaneCommand::Status { .. } => ControlOperation::LaneStatus,
LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
ControlOperation::LaneInterrupt
}
LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
LaneCommand::Resume { .. } => ControlOperation::LaneResume,
other => panic!(
"unexpected lane subcommand for {}: {other:?}",
descriptor.verb
),
};
assert_eq!(
parsed, descriptor.operation,
"`codewhale lane {}` must map to {}",
descriptor.verb, descriptor.id
);
assert!(
descriptor.offers(ControlSurface::Cli),
"{} must be declared on the CLI surface",
descriptor.id
);
}
}
#[test]
fn lane_stop_and_interrupt_resolve_to_one_verb() {
use codewhale_lane::{ControlDomain, ControlOperation};
for spelling in ["stop", "interrupt", "cancel", "kill"] {
assert_eq!(
ControlOperation::parse_verb(ControlDomain::Lane, spelling),
Some(ControlOperation::LaneInterrupt),
"{spelling}"
);
}
let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
assert!(matches!(
stop.command,
Some(Commands::Lane(LaneArgs {
command: LaneCommand::Stop { .. }
}))
));
}
#[test]
fn short_workflow_names_do_not_resolve_version_pinned_files() {
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
assert!(candidates.iter().all(|path| {
!path
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
}));
assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
let explicit =
resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
.expect("explicit workflow path");
assert!(explicit.ends_with("workflows/stopship.workflow.js"));
}
#[test]
fn workflow_run_resolves_stopship_alias_and_payload() {
let _lock = env_lock();
let (_dir, _tui) = install_fake_tui_binary();
let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let cli = parse_ok(&[
"codewhale",
"--profile",
"workflow-profile",
"--model",
"explicit-workflow-model",
"--api-key",
"explicit-profile-key",
"--workspace",
workspace.to_str().expect("workspace UTF-8"),
]);
let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
let source = resolve_workflow_source_path("stopship", None, &workspace)
.expect("stopship workflow source");
assert!(source.ends_with("workflows/stopship.workflow.js"));
let process = workflow_exec_command(WorkflowExecSpec {
cli: &cli,
resolved_runtime: &resolved,
config_path: &workspace.join("config.toml"),
source_root: &workspace,
source_path: &source,
workflow: "stopship",
fleet: Some("stopship"),
issue: Some("4375"),
goal: Some("fix stopship"),
token_budget: Some(25_000),
verify: true,
})
.expect("command");
let current_executable = std::env::current_exe().expect("current executable");
assert_eq!(
process.command.first().map(String::as_str),
current_executable.to_str(),
"workflow lanes must launch the exact runtime that built their process spec"
);
let joined = process.command.join("\n");
assert!(joined.contains("workflow-tool"));
assert!(joined.contains("explicit-workflow-command"));
assert!(joined.contains("--input-json"));
assert!(!process.command.iter().any(|arg| arg == "exec"));
assert!(!process.command.iter().any(|arg| arg == "--workspace"));
assert!(
process
.command
.windows(2)
.any(|pair| pair == ["--profile", "workflow-profile"])
);
assert!(!joined.contains("Run the CodeWhale"));
assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
assert!(joined.contains("\"fleet\":\"stopship\""));
assert!(joined.contains("\"issue\":\"4375\""));
assert!(joined.contains("\"token_budget\":25000"));
assert!(joined.contains("\"verify\":true"));
assert!(
process.environment.iter().any(|(key, value)| {
key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
})
);
assert!(
!process
.environment
.iter()
.any(|(key, _)| key == "DEEPSEEK_PROVIDER")
);
assert!(
!process
.environment
.iter()
.any(|(key, _)| key == "DEEPSEEK_BASE_URL")
);
assert!(
!process
.environment
.iter()
.any(|(key, _)| key == "DEEPSEEK_API_KEY")
);
assert!(process.environment.iter().any(|(key, value)| {
key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
}));
assert!(
!process
.command
.iter()
.any(|argument| argument.contains("explicit-profile-key"))
);
assert!(
process
.environment
.iter()
.all(|(_, value)| value != "test-model")
);
}
#[test]
fn exec_keeps_global_looking_flags_as_passthrough_args() {
let cli = parse_ok(&[
"codewhale",
"exec",
"--provider",
"definitely-not-a-provider",
"Reply OK",
]);
let Some(Commands::Exec(args)) = cli.command else {
panic!("expected exec command");
};
assert_eq!(
args.args,
vec![
"--provider".to_string(),
"definitely-not-a-provider".to_string(),
"Reply OK".to_string(),
]
);
}
#[test]
fn exec_rejects_provider_after_subcommand() {
let args = vec![
"--provider".to_string(),
"definitely-not-a-provider".to_string(),
"Reply OK".to_string(),
];
let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
assert!(
err.to_string()
.contains("--provider must be placed before `exec`")
);
}
#[test]
fn exec_rejects_equals_form_provider_after_subcommand() {
let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
assert!(
err.to_string()
.contains("--provider must be placed before `exec`")
);
}
#[test]
fn exec_allows_documented_forwarded_flags() {
let args = vec![
"--auto".to_string(),
"--output-format".to_string(),
"stream-json".to_string(),
"fix tests".to_string(),
];
reject_exec_global_flags(&args).expect("documented exec flags should pass");
}
#[test]
fn exec_allows_literal_prompt_flags_after_separator() {
let args = vec![
"--".to_string(),
"--provider".to_string(),
"is literal prompt text".to_string(),
];
reject_exec_global_flags(&args).expect("separator should stop global flag validation");
}
#[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_uses_isolated_file_store_and_preserves_tui_defaults() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let codewhale_home = dir.path().join("codewhale-home");
let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
let path = codewhale_home.join("config.toml");
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
let secrets = Secrets::auto_detect();
run_login_command_with_secrets(
&mut store,
LoginArgs {
provider: Some(ProviderArg::Deepseek),
api_key: Some("sk-test".to_string()),
},
&secrets,
)
.expect("login should persist credential");
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
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("sk-test"), "{saved}");
assert!(
!saved
.lines()
.any(|line| line.trim_start().starts_with("api_key ="))
);
assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
assert_eq!(
secrets.get("deepseek").expect("read secret").as_deref(),
Some("sk-test")
);
}
#[test]
fn login_with_repo_scoped_ambient_config_writes_user_global_metadata() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git marker");
let repo_config_dir = repo.join(".codewhale");
std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
let repo_config = repo_config_dir.join("config.toml");
std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
let codewhale_home = dir.path().join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
let mut store = ConfigStore::load(None).expect("ambient store should load");
let secrets = Secrets::auto_detect();
run_login_command_with_secrets(
&mut store,
LoginArgs {
provider: Some(ProviderArg::Deepseek),
api_key: Some("sk-repo-scoped".to_string()),
},
&secrets,
)
.expect("login should persist credential");
assert_eq!(
secrets.get("deepseek").expect("read secret").as_deref(),
Some("sk-repo-scoped")
);
let global_config = codewhale_home.join("config.toml");
let global = std::fs::read_to_string(&global_config).expect("user-global config");
assert!(
global.contains("auth_mode = \"api_key\""),
"user-global config must carry the auth marker: {global}"
);
assert!(
global.contains("provider = \"deepseek\""),
"user-global config must carry the provider binding: {global}"
);
assert!(!global.contains("sk-repo-scoped"), "{global}");
let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
assert_eq!(
repo_after, "approval_policy = \"never\"\n",
"workspace config must stay untouched by credential metadata: {repo_after}"
);
}
#[test]
fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git marker");
let repo_config_dir = repo.join(".codewhale");
std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
let repo_config = repo_config_dir.join("config.toml");
std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
let codewhale_home = dir.path().join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
let mut store = ConfigStore::load(None).expect("ambient store should load");
let secrets = Secrets::auto_detect();
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Openrouter,
api_key: Some("sk-or-repo-scoped".to_string()),
api_key_stdin: false,
},
&secrets,
)
.expect("auth set should persist credential");
assert_eq!(
secrets.get("openrouter").expect("read secret").as_deref(),
Some("sk-or-repo-scoped")
);
let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
.expect("user-global config");
assert!(
global.contains("auth_mode = \"api_key\""),
"user-global config must carry the auth markers: {global}"
);
assert!(
global.contains("openrouter"),
"user-global config must name the provider table: {global}"
);
assert!(!global.contains("sk-or-repo-scoped"), "{global}");
let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
assert_eq!(
repo_after, "approval_policy = \"never\"\n",
"workspace config must stay untouched by credential metadata: {repo_after}"
);
}
#[test]
fn parses_auth_subcommand_matrix() {
let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::XaiDevice
}))
));
let cli = parse_ok(&[
"deepseek",
"auth",
"external-consent",
"--provider",
"openai-codex",
"--mode",
"read-only",
"--path",
"/tmp/codex-auth.json",
"--yes",
]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::ExternalConsent {
provider: ProviderArg::OpenaiCodex,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(_),
yes: true,
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::ExternalRevoke {
provider: ProviderArg::Xai,
}
}))
));
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)
}
}))
));
for (provider, expected) in [
("anthropic", ProviderArg::Anthropic),
("openmodel", ProviderArg::Openmodel),
("open-model", ProviderArg::Openmodel),
("zai", ProviderArg::Zai),
("stepfun", ProviderArg::Stepfun),
("minimax", ProviderArg::Minimax),
("minimax-anthropic", ProviderArg::MinimaxAnthropic),
("minimax_anthropic", ProviderArg::MinimaxAnthropic),
("deepinfra", ProviderArg::Deepinfra),
("deep-infra", ProviderArg::Deepinfra),
("siliconflow-cn", ProviderArg::SiliconflowCn),
("siliconflow-CN", ProviderArg::SiliconflowCn),
("siliconflow_china", ProviderArg::SiliconflowCn),
] {
let cli = parse_ok(&[
"deepseek",
"auth",
"set",
"--provider",
provider,
"--api-key-stdin",
]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider,
api_key: None,
api_key_stdin: true,
}
})) if provider == expected
));
}
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_help_describes_runtime_effective_diagnostics() {
let get = help_for(&["codewhale", "auth", "get", "--help"]);
assert!(get.contains("effective credential route"), "{get}");
assert!(get.contains("structural OAuth/repair state"), "{get}");
let status = help_for(&["codewhale", "auth", "status", "--help"]);
assert!(
status.contains("runtime-effective credential route state"),
"{status}"
);
let list = help_for(&["codewhale", "auth", "list", "--help"]);
assert!(list.contains("runtime-effective auth state"), "{list}");
}
#[test]
fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
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!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
let saved = std::fs::read_to_string(&path).unwrap_or_default();
assert!(!saved.contains("sk-keyring"), "{saved}");
assert!(
!saved
.lines()
.any(|line| line.trim_start().starts_with("api_key ="))
);
assert_eq!(
inner.get("deepseek").unwrap().as_deref(),
Some("sk-keyring")
);
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
use codewhale_secrets::{KeyringStore, SecretsError};
use std::sync::Arc;
struct FailingStore;
impl KeyringStore for FailingStore {
fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
Err(SecretsError::Keyring("test write failure".to_string()))
}
fn delete(&self, _key: &str) -> Result<(), SecretsError> {
Ok(())
}
fn backend_name(&self) -> &'static str {
"failing test store"
}
}
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
let secrets = Secrets::new(Arc::new(FailingStore));
let error = run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Openrouter,
api_key: Some("fallback-test-credential".to_string()),
api_key_stdin: false,
},
&secrets,
)
.expect_err("secret-store failure must not downgrade to plaintext");
let message = format!("{error:#}");
assert!(message.contains("Secret storage write failed"), "{message}");
assert!(message.contains("Refusing"), "{message}");
assert!(
message.contains(&codewhale_config::quote_os_path(store.path())),
"{message}"
);
assert!(store.config.providers.openrouter.api_key.is_none());
assert!(!path.exists(), "plaintext config must stay untouched");
}
#[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!(store.config.providers.arcee.api_key.is_none());
assert_eq!(
store.config.providers.arcee.auth_mode.as_deref(),
Some("api_key")
);
let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
assert!(reloaded.config.providers.arcee.api_key.is_none());
assert_eq!(
reloaded.config.providers.arcee.auth_mode.as_deref(),
Some("api_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();
assert_eq!(probed[0], "deepseek");
assert!(probed.len() > 1, "list should probe all providers");
assert!(
ProviderKind::ALL
.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");
assert!(output.contains("deepseek"));
assert!(output.contains("arcee"));
assert!(output.contains("openrouter"));
assert!(output.contains("huggingface"));
assert!(output.contains("ollama"));
assert!(output.contains("deepseek") && output.contains("*"));
assert!(output.contains("config"));
assert!(!output.contains("sk-arcee-test1234"));
assert!(!output.contains("sk-or-test5678"));
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
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: missing"));
assert!(output.contains("lookup order: env -> consent-gated exact Codex CLI file"));
assert!(output.contains("external credentials: disabled"));
assert!(output.contains("scope_valid=false"));
assert!(output.contains("disabled; no external-credential probing, reading"));
assert!(output.contains("file not probed"));
assert!(!output.contains("secret-token"));
store.config.providers.openai_codex.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
auth_path.clone(),
));
let output =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(
output.contains("active source: external read-only consent (availability not probed)")
);
assert!(output.contains("external credentials: read_only"));
assert!(output.contains("provider=openai-codex"));
assert!(output.contains("source=codex_cli"));
assert!(output.contains(&format!(
"path={}",
codewhale_config::quote_os_path(&auth_path)
)));
assert!(output.contains(&format!(
"consent_version={}",
codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
)));
assert!(output.contains("file not probed"));
assert!(!output.contains("secret-token"));
let ambient_path = dir.path().join("new-ambient-auth.json");
let ambient_path_str = ambient_path.to_string_lossy().into_owned();
let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
let changed =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(changed.contains("state=active"), "{changed}");
assert!(changed.contains("ambient_path_changed=true"), "{changed}");
assert!(changed.contains("consent remains pinned"), "{changed}");
assert!(
changed.contains(&codewhale_config::quote_os_path(&auth_path)),
"{changed}"
);
assert!(!changed.contains(&ambient_path_str), "{changed}");
}
#[test]
fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
use std::sync::Arc;
let _lock = env_lock();
let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("grok-auth.json");
let external_raw = "external owner bytes must not be read";
std::fs::write(&external_path, external_raw).expect("external auth trap");
let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
store.config.provider = ProviderKind::Xai;
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.oauth_credential_generation =
Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
store.config.providers.xai.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
external_path.clone(),
));
let keyring = Arc::new(RecordingKeyringStore::default());
let secrets = Secrets::new(keyring.clone());
let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
assert!(
scoped.contains(
"credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
),
"{scoped}"
);
assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
assert!(
scoped.contains(
"xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
),
"{scoped}"
);
assert!(
!scoped.contains("active source: Codewhale-owned OAuth"),
"a valid pointer is configured/unprobed, not an active credential: {scoped}"
);
assert!(
!scoped.contains("fallback"),
"an owned generation must never advertise Grok CLI fallback: {scoped}"
);
let all = auth_status_all_providers(&store, &secrets).join("\n");
let xai_row = all
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI status row");
assert!(
xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
"{xai_row}"
);
let list = auth_list_lines(&store, &secrets).join("\n");
let xai_list_row = list
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI list row");
assert!(
xai_list_row.ends_with("owned-oauth-configured"),
"{xai_list_row}"
);
let get = auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::Xai,
&CliRuntimeOverrides::default(),
);
assert!(
get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
"{get}"
);
assert!(!get.starts_with("xai: set"), "{get}");
assert!(!get.contains("fallback"), "{get}");
assert!(
!keyring.queried().iter().any(|slot| slot == "xai"),
"owned OAuth diagnostics must not query the xAI API-key store: {:?}",
keyring.queried()
);
assert_eq!(
std::fs::read_to_string(external_path).expect("external trap unchanged"),
external_raw
);
store.config.providers.xai.auth_mode = None;
store.config.auth_mode = Some("oauth".to_string());
assert_eq!(
xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
XaiAuthDiagnosticRoute::ApiKey,
"a root auth mode must not select the xAI OAuth runtime route"
);
}
#[test]
fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
use std::sync::Arc;
let _lock = env_lock();
let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("grok-auth.json");
let external_raw = "external owner bytes must remain unread";
std::fs::write(&external_path, external_raw).expect("external auth trap");
let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
store.config.provider = ProviderKind::Xai;
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
store.config.providers.xai.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
external_path.clone(),
));
let keyring = Arc::new(RecordingKeyringStore::default());
let secrets = Secrets::new(keyring.clone());
let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
assert!(
scoped.contains("credential route: xAI OAuth needs repair"),
"{scoped}"
);
assert!(
scoped.contains("API-key fallback: config (last4: ...1234)"),
"{scoped}"
);
assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
assert!(
scoped.contains("repair: run `codewhale auth xai-device`"),
"{scoped}"
);
assert!(
!scoped.contains("external read-only consent (availability not probed)"),
"invalid owned pointers must not activate Grok CLI consent: {scoped}"
);
let all = auth_status_all_providers(&store, &secrets).join("\n");
let xai_row = all
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI status row");
assert!(xai_row.contains("needs repair"), "{xai_row}");
assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
let list = auth_list_lines(&store, &secrets).join("\n");
let xai_list_row = list
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI list row");
assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
let get = auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::Xai,
&CliRuntimeOverrides::default(),
);
assert!(get.contains("xai: needs repair"), "{get}");
assert!(get.contains("API-key fallback: config-file"), "{get}");
assert!(
!keyring.queried().iter().any(|slot| slot == "xai"),
"an invalid owned pointer must not query the xAI API-key store: {:?}",
keyring.queried()
);
assert_eq!(
std::fs::read_to_string(external_path).expect("external trap unchanged"),
external_raw
);
}
#[test]
fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
use std::sync::Arc;
let _lock = env_lock();
let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("grok-auth.json");
let external_raw = "external owner bytes must remain unprobed";
std::fs::write(&external_path, external_raw).expect("external auth trap");
let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
store.config.provider = ProviderKind::Xai;
store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.oauth_credential_generation =
Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
store.config.providers.xai.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
external_path.clone(),
));
let keyring = Arc::new(RecordingKeyringStore::default());
keyring.set_value("xai", "fake-store-key-2222");
let secrets = Secrets::new(keyring.clone());
let runtime_overrides = CliRuntimeOverrides {
base_url: Some("https://gateway.example.test/v1".to_string()),
..CliRuntimeOverrides::default()
};
let scoped = auth_status_lines_for_provider_with_runtime(
&store,
&secrets,
ProviderKind::Xai,
&runtime_overrides,
)
.join("\n");
assert!(
scoped.contains("route: https://gateway.example.test/v1"),
"{scoped}"
);
assert!(scoped.contains("credential route: missing"), "{scoped}");
assert!(
scoped.contains("custom xAI endpoint; API-key-only"),
"{scoped}"
);
assert!(
scoped.contains("not eligible for this custom xAI endpoint"),
"{scoped}"
);
assert!(
scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
"{scoped}"
);
for redacted_tail in ["...1111", "...2222", "...3333"] {
assert!(
!scoped.contains(redacted_tail),
"custom CLI route must not advertise an inherited credential: {scoped}"
);
}
let all =
auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
let xai_row = all
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI status row");
assert!(xai_row.contains("unset"), "{xai_row}");
assert!(
!xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
"xAI summary must show runtime-effective sources only: {xai_row}"
);
let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
let xai_list_row = list
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI list row");
assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
let get =
auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
assert_eq!(get, "xai: not set");
assert!(
!keyring.queried().iter().any(|slot| slot == "xai"),
"a global custom endpoint must not query xAI keyring state: {:?}",
keyring.queried()
);
assert_eq!(
std::fs::read_to_string(external_path).expect("external trap unchanged"),
external_raw
);
}
#[test]
fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
use std::sync::Arc;
let _lock = env_lock();
let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("grok-auth.json");
let external_raw = "external owner bytes must remain unprobed";
std::fs::write(&external_path, external_raw).expect("external auth trap");
let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
store.config.provider = ProviderKind::Xai;
store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.oauth_credential_generation =
Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
store.config.providers.xai.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
external_path.clone(),
));
let keyring = Arc::new(RecordingKeyringStore::default());
keyring.set_value("xai", "fake-store-key-5555");
let secrets = Secrets::new(keyring.clone());
let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
assert!(
scoped.contains("route: https://env-gateway.example.test/v1"),
"{scoped}"
);
assert!(scoped.contains("credential route: missing"), "{scoped}");
assert!(
scoped.contains("custom xAI endpoint; API-key-only"),
"{scoped}"
);
for redacted_tail in ["...4444", "...5555", "...6666"] {
assert!(
!scoped.contains(redacted_tail),
"custom env route must not advertise an inherited credential: {scoped}"
);
}
let all = auth_status_all_providers(&store, &secrets).join("\n");
let xai_row = all
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI status row");
assert!(xai_row.contains("unset"), "{xai_row}");
let list = auth_list_lines(&store, &secrets).join("\n");
let xai_list_row = list
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI list row");
assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
assert_eq!(
auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::Xai,
&CliRuntimeOverrides::default(),
),
"xai: not set"
);
assert!(
!keyring.queried().iter().any(|slot| slot == "xai"),
"an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
keyring.queried()
);
assert_eq!(
std::fs::read_to_string(external_path).expect("external trap unchanged"),
external_raw
);
}
#[test]
fn xai_config_bound_custom_endpoint_uses_its_route_key() {
use std::sync::Arc;
let _lock = env_lock();
let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
store.config.provider = ProviderKind::Xai;
store.config.providers.xai.base_url =
Some("https://bound-gateway.example.test/v1".to_string());
store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
let keyring = Arc::new(RecordingKeyringStore::default());
keyring.set_value("xai", "fake-store-key-8888");
let secrets = Secrets::new(keyring.clone());
let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
assert!(
scoped.contains("credential route: config (last4: ...7777)"),
"{scoped}"
);
assert!(
scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
"{scoped}"
);
assert_eq!(
auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::Xai,
&CliRuntimeOverrides::default(),
),
"xai: set (source: config-file)"
);
assert!(
!keyring.queried().iter().any(|slot| slot == "xai"),
"an endpoint-bound config key should resolve before the xAI keyring: {:?}",
keyring.queried()
);
}
#[test]
fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
use std::sync::Arc;
let _lock = env_lock();
let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("grok-auth.json");
let external_raw = "external owner bytes remain unprobed";
std::fs::write(&external_path, external_raw).expect("external auth trap");
let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
store.config.provider = ProviderKind::Xai;
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
external_path.clone(),
));
let keyring = Arc::new(RecordingKeyringStore::default());
let secrets = Secrets::new(keyring.clone());
let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
assert!(
scoped.contains("credential route: external read-only consent configured/unprobed"),
"{scoped}"
);
assert!(
scoped.contains("external credentials: read_only"),
"{scoped}"
);
assert!(
scoped.contains(
"lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
),
"{scoped}"
);
let all = auth_status_all_providers(&store, &secrets).join("\n");
let xai_row = all
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI status row");
assert!(
xai_row.contains("external consent configured/unprobed"),
"{xai_row}"
);
let list = auth_list_lines(&store, &secrets).join("\n");
let xai_list_row = list
.lines()
.find(|line| line.starts_with("xai"))
.expect("xAI list row");
assert!(
xai_list_row.ends_with("external-consent-configured"),
"{xai_list_row}"
);
let get = auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::Xai,
&CliRuntimeOverrides::default(),
);
assert!(
get.contains("source: external read-only consent; availability unprobed"),
"{get}"
);
assert!(
!keyring.queried().iter().any(|slot| slot == "xai"),
"external-consent diagnostics must not query the xAI API-key store: {:?}",
keyring.queried()
);
assert_eq!(
std::fs::read_to_string(external_path).expect("external trap unchanged"),
external_raw
);
}
#[test]
fn auth_list_uses_persisted_consent_without_probing_codex_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;
store.config.providers.openai_codex.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
auth_path,
));
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
let output = auth_list_lines(&store, &secrets).join("\n");
let row = output
.lines()
.find(|line| line.starts_with("openai-codex"))
.unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
assert!(row.ends_with("external-consent"), "{row}");
assert!(!output.contains("secret-token"));
}
#[test]
fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical temp root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("grok-auth.json");
let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
std::fs::write(&external_path, external_raw).expect("external auth trap");
let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
let secrets = no_keyring_secrets();
let preview = external_consent_preview_lines(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
&external_path,
)
.join("\n");
assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
assert!(
preview.contains(&format!(
"exact resolved path: {}",
codewhale_config::quote_os_path(&external_path)
)),
"{preview}"
);
assert!(preview.contains("no refresh, identity-provider or discovery requests"));
assert!(preview.contains("normal requests to the explicitly selected provider"));
assert!(preview.contains("managed: unavailable"));
let mut prompt = Vec::new();
confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
.expect("exact yes confirms");
assert!(
String::from_utf8(prompt)
.unwrap()
.contains("exact read-only")
);
let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
.expect_err("confirmation is deliberate and case-sensitive");
assert!(cancelled.to_string().contains("cancelled"));
let unconfirmed = run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Xai,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: false,
},
&secrets,
)
.expect_err("non-interactive consent requires --yes");
assert!(unconfirmed.to_string().contains("requires explicit --yes"));
assert!(store.config.providers.xai.external_credentials.is_none());
assert!(
!config_path.exists(),
"unconfirmed consent must not persist"
);
run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Xai,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: true,
},
&secrets,
)
.expect("read-only consent should persist");
let consent = store
.config
.providers
.xai
.external_credentials
.as_ref()
.expect("persisted consent");
assert_eq!(
consent.access,
codewhale_config::ExternalCredentialAccess::ReadOnly
);
assert_eq!(consent.provider, ProviderKind::Xai.as_str());
assert_eq!(
consent.source,
codewhale_config::ExternalCredentialSource::GrokCli
);
assert_eq!(consent.path, external_path);
assert_eq!(
consent.consent_version,
codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
);
assert_eq!(
store.config.providers.xai.auth_mode.as_deref(),
Some("oauth")
);
assert_eq!(
std::fs::read_to_string(&consent.path).expect("external file unchanged"),
external_raw
);
let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
let reloaded_consent = reloaded
.config
.providers
.xai
.external_credentials
.as_ref()
.expect("reloaded exact consent");
assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
assert_eq!(
reloaded_consent.source,
codewhale_config::ExternalCredentialSource::GrokCli
);
assert_eq!(reloaded_consent.path, external_path);
assert_eq!(
reloaded_consent.consent_version,
codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
);
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Xai,
api_key: Some("xai-codewhale-owned-key".to_string()),
api_key_stdin: false,
},
&secrets,
)
.expect("Codewhale-owned API key should supersede external consent");
assert!(store.config.providers.xai.external_credentials.is_none());
assert_eq!(
std::fs::read_to_string(&external_path).expect("external file still unchanged"),
external_raw
);
run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Xai,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: true,
},
&secrets,
)
.expect("consent can be granted again");
run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalRevoke {
provider: ProviderArg::Xai,
},
&secrets,
)
.expect("revoke should persist");
assert!(store.config.providers.xai.external_credentials.is_none());
assert_eq!(
std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
external_raw
);
}
#[test]
fn unsupported_managed_and_kimi_external_consent_fail_closed() {
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let external_path = dir.path().join("external-auth.json");
std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
let secrets = no_keyring_secrets();
let managed = run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::OpenaiCodex,
mode: ExternalCredentialModeArg::Managed,
path: Some(external_path.clone()),
yes: true,
},
&secrets,
)
.expect_err("managed access must fail without a preservation adapter");
assert!(
managed
.to_string()
.contains("schema-safe preservation adapter")
);
let kimi = run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Moonshot,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: true,
},
&secrets,
)
.expect_err("Kimi must remain API-key-only");
assert!(kimi.to_string().contains("API-key-only"));
assert!(
kimi.to_string()
.contains("https://platform.kimi.ai/console/api-keys")
);
assert!(
store
.config
.providers
.openai_codex
.external_credentials
.is_none()
);
assert!(
store
.config
.providers
.moonshot
.external_credentials
.is_none()
);
assert_eq!(
std::fs::read_to_string(external_path).expect("external fixture unchanged"),
"must remain unchanged"
);
assert!(
!config_path.exists(),
"rejected consent must not write config"
);
}
#[test]
fn api_key_config_failure_restores_absent_and_existing_secret_state() {
let _lock = env_lock();
for prior in [None, Some("prior-xai-key")] {
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical temp root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let config_path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
dir.path().join("external.json"),
));
std::fs::create_dir(&config_path).expect("turn config target into a directory");
let secrets = no_keyring_secrets();
if let Some(prior) = prior {
secrets.set("xai", prior).expect("seed prior secret");
}
let error = run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Xai,
api_key: Some("new-xai-key".to_string()),
api_key_stdin: false,
},
&secrets,
)
.expect_err("config write must fail");
assert!(error.to_string().contains("config"), "{error:#}");
assert_eq!(
secrets.get("xai").expect("restored secret"),
prior.map(str::to_string)
);
assert_eq!(
store.config.providers.xai.auth_mode.as_deref(),
Some("oauth")
);
assert!(store.config.providers.xai.external_credentials.is_some());
assert!(store.config.providers.xai.api_key.is_none());
assert!(config_path.is_dir());
}
}
#[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"));
for sentinel in [codewhale_config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
store.config.providers.arcee.api_key = Some(sentinel.to_string());
assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let _lock = env_lock();
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::Keyring));
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
assert!(
!path.exists(),
"dispatch must not create config from a stored key"
);
let resolved_again = resolve_runtime_for_dispatch_with_secrets(
&mut store,
&CliRuntimeOverrides::default(),
&secrets,
);
assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
assert_eq!(
resolved_again.api_key_source,
Some(RuntimeApiKeySource::Keyring)
);
assert!(
!path.exists(),
"repeat dispatch must remain credential-file free"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn logout_removes_plaintext_provider_keys() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical temp root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let path = home.join("config.toml");
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.config.providers.xai.auth_mode = Some("oauth".to_string());
let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
store.save().unwrap();
let credentials = home.join("credentials");
codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
owned.write(generation, b"xai-generation", false)?;
owned.write(
codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
b"legacy-xai",
false,
)?;
Ok(())
})
.expect("seed Codewhale-owned xAI credentials");
std::fs::write(credentials.join("other-provider.json"), "preserve").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());
assert!(store.config.providers.xai.auth_mode.is_none());
assert!(
store
.config
.providers
.xai
.oauth_credential_generation
.is_none()
);
assert!(!credentials.join(generation).exists());
assert!(!credentials.join("xai-auth.json").exists());
assert!(credentials.join("other-provider.json").exists());
let _ = std::fs::remove_file(path);
}
#[test]
fn logout_clears_keyring_credentials_for_all_providers() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical temp root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let path = home.join("config.toml");
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.provider = ProviderKind::Deepseek;
let secrets = no_keyring_secrets();
secrets
.set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
.expect("seed deepseek key");
secrets
.set(provider_slot(ProviderKind::Fireworks), "fw-stale")
.expect("seed fireworks key");
run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
assert!(
provider_keyring_api_key(&secrets, provider).is_none(),
"keyring credential for {provider:?} survived logout"
);
}
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()));
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 backup_path = path.with_file_name(format!(
"{}.bak",
path.file_name().unwrap_or_default().to_string_lossy()
));
let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
assert!(
!backup.contains("sk-deep"),
"plaintext leaked in backup: {backup}"
);
assert!(
!backup.contains("or-key"),
"plaintext leaked in backup: {backup}"
);
assert!(
!backup.contains("nv-key"),
"plaintext leaked in backup: {backup}"
);
let resolved = resolve_runtime_for_dispatch_with_secrets(
&mut store,
&CliRuntimeOverrides::default(),
&secrets,
);
assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
assert!(
!after_dispatch
.lines()
.any(|line| line.trim_start().starts_with("api_key ="))
);
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",
"--verbosity",
"concise",
"--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-mouse-capture",
"--skip-onboarding",
"model",
"resolve",
"deepseek-v4-pro",
]);
assert_eq!(cli.provider.as_deref(), Some("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.verbosity.as_deref(), Some("concise"));
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_mouse_capture);
assert!(!cli.mouse_capture);
assert!(cli.skip_onboarding);
}
#[test]
fn cli_provider_helpers_follow_config_metadata() {
let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
.iter()
.map(|provider| provider.kind())
.collect();
assert_eq!(registry_kinds.len(), 47);
assert_eq!(ProviderKind::ALL.len(), 42);
for kind in ProviderKind::ALL {
assert!(
registry_kinds.contains(&kind),
"catalog kind {kind:?} must remain in the full registry"
);
}
for provider in registry_kinds {
assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
assert_eq!(
provider_slot(provider),
provider.secret_store_slot(),
"{provider:?} slot must match ProviderKind::secret_store_slot"
);
if provider == ProviderKind::SiliconflowCN {
assert_eq!(
provider_slot(provider),
provider_slot(ProviderKind::Siliconflow)
);
} else if matches!(
provider,
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic
) {
assert_eq!(
provider_slot(provider),
"modelstudio-token-plan",
"{provider:?} must share the Model Studio family slot"
);
} else {
assert_eq!(provider_slot(provider), provider.provider().id());
}
}
}
#[test]
fn the_telemetry_flag_documents_itself_in_help() {
let help = Cli::command().render_long_help().to_string();
let telemetry_line = help
.lines()
.position(|line| line.contains("--telemetry"))
.map(|index| help.lines().skip(index).take(3).collect::<String>())
.expect("--telemetry must appear in --help");
assert!(
telemetry_line.contains("telemetry"),
"expected a help string beside --telemetry, got: {telemetry_line}"
);
assert!(
telemetry_line.contains("default on"),
"the help string must disclose the default: {telemetry_line}"
);
assert!(
telemetry_line.contains("CODEWHALE_TELEMETRY=0 always")
&& telemetry_line.contains("wins"),
"the help string must document the always-winning opt-out: {telemetry_line}"
);
}
#[test]
fn root_help_describes_product_actions_not_internal_tui_layers() {
let help = Cli::command().render_long_help().to_string();
assert!(
!help.contains("TUI"),
"root help must describe what Codewhale does, not its internal UI/runtime layers:\n{help}"
);
}
#[test]
fn only_one_function_may_locate_and_spawn_the_tui() {
let source = include_str!("lib.rs");
let a = format!("{}{}", "locate_sibling", "_tui_binary");
let b = format!("{}{}", "tui_spawn", "_error");
let c = format!("{}{}", "build_tui", "_command");
let d = format!("{}{}", "Command::new", "(&tui)");
assert!(
!source.contains(&a),
"single binary must not contain sibling TUI discovery"
);
assert!(
!source.contains(&b),
"single binary must not contain tui spawn error"
);
assert!(
!source.contains(&c),
"single binary must not contain build_tui dispatch"
);
assert!(
!source.contains(&d),
"single binary must not contain Command new tui"
);
}
#[test]
fn parses_no_project_config_before_subcommand() {
let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
assert!(cli.no_project_config);
match cli.command {
Some(Commands::Exec(args)) => {
assert_eq!(args.args, vec!["list the files".to_string()]);
}
other => panic!("expected exec subcommand, got {other:?}"),
}
}
#[test]
fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
assert!(!cli.no_project_config);
match cli.command {
Some(Commands::Exec(args)) => {
assert!(args.args.iter().any(|a| a == "--no-project-config"));
}
other => panic!("expected exec subcommand, got {other:?}"),
}
}
#[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 parses_rc_as_the_account_owned_interactive_handoff() {
let cli = parse_ok(&["codewhale", "rc"]);
let Some(Commands::Rc(args)) = cli.command else {
panic!("rc should parse as the remote-control TUI handoff");
};
assert!(args.args.is_empty());
}
#[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}`"
);
}
}
}
#[test]
fn cli_telemetry_start_fails_closed_on_a_corrupt_setup_state() {
let dir = tempfile::TempDir::new().expect("tempdir");
let setup_path = dir.path().join("setup_state.json");
std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
let setup = telemetry::load_setup_state_for_decision_at(&setup_path);
assert!(setup.is_none(), "corrupt privacy state must not default on");
let resolved =
ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
assert!(
resolve_cli_telemetry_consent(&resolved, None, Surface::Cli, setup).is_none(),
"CLI startup must not obtain permission from an unreadable privacy record"
);
}
}