9804c92c21
Implement `deepseek metrics` as a dispatcher-handled subcommand (no TUI binary roundtrip) that reads ~/.deepseek/audit.log, session JSON files, and tasks runtime JSONL event streams, then prints a human-readable usage rollup aggregated by tool name, compaction events, sub-agent spawns, and capacity-controller interventions. Flags: --json (machine-readable) and --since DURATION (e.g. 7d, 24h, 30m, now-2h, 2h30m). Empty/missing audit log exits 0 with an empty rollup; malformed lines are skipped silently via tracing::trace!. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1326 lines
42 KiB
Rust
1326 lines
42 KiB
Rust
mod metrics;
|
|
|
|
use std::io::{self, Read};
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use std::process::{Command, ExitCode};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
|
|
use clap_complete::{Shell, generate};
|
|
use deepseek_agent::ModelRegistry;
|
|
use deepseek_app_server::{
|
|
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
|
|
};
|
|
use deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};
|
|
use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
|
|
use deepseek_mcp::{McpServerDefinition, run_stdio_server};
|
|
use deepseek_state::{StateStore, ThreadListFilters};
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
enum ProviderArg {
|
|
Deepseek,
|
|
NvidiaNim,
|
|
Openai,
|
|
}
|
|
|
|
impl From<ProviderArg> for ProviderKind {
|
|
fn from(value: ProviderArg) -> Self {
|
|
match value {
|
|
ProviderArg::Deepseek => ProviderKind::Deepseek,
|
|
ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
|
|
ProviderArg::Openai => ProviderKind::Openai,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(
|
|
name = "deepseek",
|
|
version,
|
|
bin_name = "deepseek",
|
|
override_usage = "deepseek [OPTIONS] [PROMPT]\n deepseek [OPTIONS] <COMMAND> [ARGS]"
|
|
)]
|
|
struct Cli {
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
#[arg(long)]
|
|
profile: Option<String>,
|
|
#[arg(
|
|
long,
|
|
value_enum,
|
|
help = "Advanced provider selector for non-TUI registry/config commands"
|
|
)]
|
|
provider: Option<ProviderArg>,
|
|
#[arg(long)]
|
|
model: Option<String>,
|
|
#[arg(long = "output-mode")]
|
|
output_mode: Option<String>,
|
|
#[arg(long = "log-level")]
|
|
log_level: Option<String>,
|
|
#[arg(long)]
|
|
telemetry: Option<bool>,
|
|
#[arg(long)]
|
|
approval_policy: Option<String>,
|
|
#[arg(long)]
|
|
sandbox_mode: Option<String>,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
#[arg(long)]
|
|
base_url: Option<String>,
|
|
#[arg(long = "no-alt-screen")]
|
|
no_alt_screen: bool,
|
|
#[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
|
|
mouse_capture: bool,
|
|
#[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
|
|
no_mouse_capture: bool,
|
|
#[arg(value_name = "PROMPT")]
|
|
prompt: Option<String>,
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum Commands {
|
|
/// Run interactive/non-interactive flows via the TUI binary.
|
|
Run(RunArgs),
|
|
/// Run DeepSeek TUI diagnostics.
|
|
Doctor(TuiPassthroughArgs),
|
|
/// List live DeepSeek API models via the TUI binary.
|
|
Models(TuiPassthroughArgs),
|
|
/// List saved TUI sessions.
|
|
Sessions(TuiPassthroughArgs),
|
|
/// Resume a saved TUI session.
|
|
Resume(TuiPassthroughArgs),
|
|
/// Fork a saved TUI session.
|
|
Fork(TuiPassthroughArgs),
|
|
/// Create a default AGENTS.md in the current directory.
|
|
Init(TuiPassthroughArgs),
|
|
/// Bootstrap MCP config and/or skills directories.
|
|
Setup(TuiPassthroughArgs),
|
|
/// Run the DeepSeek TUI non-interactive agent command.
|
|
Exec(TuiPassthroughArgs),
|
|
/// Run a DeepSeek-powered code review over a git diff.
|
|
Review(TuiPassthroughArgs),
|
|
/// Apply a patch file or stdin to the working tree.
|
|
Apply(TuiPassthroughArgs),
|
|
/// Run the offline TUI evaluation harness.
|
|
Eval(TuiPassthroughArgs),
|
|
/// Manage TUI MCP servers.
|
|
Mcp(TuiPassthroughArgs),
|
|
/// Inspect TUI feature flags.
|
|
Features(TuiPassthroughArgs),
|
|
/// Run a local TUI server.
|
|
Serve(TuiPassthroughArgs),
|
|
/// Generate shell completions for the TUI binary.
|
|
Completions(TuiPassthroughArgs),
|
|
/// Save a DeepSeek API key to the shared config.
|
|
Login(LoginArgs),
|
|
/// Remove saved authentication state.
|
|
Logout,
|
|
/// Manage authentication credentials and provider mode.
|
|
Auth(AuthArgs),
|
|
/// Run MCP server mode over stdio.
|
|
McpServer,
|
|
/// Read/write/list config values.
|
|
Config(ConfigArgs),
|
|
/// Resolve or list available models across providers.
|
|
Model(ModelArgs),
|
|
/// Manage thread/session metadata and resume/fork flows.
|
|
Thread(ThreadArgs),
|
|
/// Evaluate sandbox/approval policy decisions.
|
|
Sandbox(SandboxArgs),
|
|
/// Run the app-server transport.
|
|
AppServer(AppServerArgs),
|
|
/// Generate shell completions.
|
|
Completion {
|
|
#[arg(value_enum)]
|
|
shell: Shell,
|
|
},
|
|
/// Print a usage rollup from the audit log and session store.
|
|
Metrics(MetricsArgs),
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct MetricsArgs {
|
|
/// Emit machine-readable JSON.
|
|
#[arg(long)]
|
|
json: bool,
|
|
/// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
|
|
#[arg(long, value_name = "DURATION")]
|
|
since: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct RunArgs {
|
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
struct TuiPassthroughArgs {
|
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct LoginArgs {
|
|
#[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]
|
|
provider: ProviderArg,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
#[arg(long, default_value_t = false, hide = true)]
|
|
chatgpt: bool,
|
|
#[arg(long, default_value_t = false, hide = true)]
|
|
device_code: bool,
|
|
#[arg(long, hide = true)]
|
|
token: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct AuthArgs {
|
|
#[command(subcommand)]
|
|
command: AuthCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum AuthCommand {
|
|
Status,
|
|
Set {
|
|
#[arg(long, value_enum)]
|
|
provider: ProviderArg,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
},
|
|
Clear {
|
|
#[arg(long, value_enum)]
|
|
provider: ProviderArg,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct ConfigArgs {
|
|
#[command(subcommand)]
|
|
command: ConfigCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ConfigCommand {
|
|
Get { key: String },
|
|
Set { key: String, value: String },
|
|
Unset { key: String },
|
|
List,
|
|
Path,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct ModelArgs {
|
|
#[command(subcommand)]
|
|
command: ModelCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ModelCommand {
|
|
List {
|
|
#[arg(long, value_enum)]
|
|
provider: Option<ProviderArg>,
|
|
},
|
|
Resolve {
|
|
model: Option<String>,
|
|
#[arg(long, value_enum)]
|
|
provider: Option<ProviderArg>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct ThreadArgs {
|
|
#[command(subcommand)]
|
|
command: ThreadCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum ThreadCommand {
|
|
List {
|
|
#[arg(long, default_value_t = false)]
|
|
all: bool,
|
|
#[arg(long)]
|
|
limit: Option<usize>,
|
|
},
|
|
Read {
|
|
thread_id: String,
|
|
},
|
|
Resume {
|
|
thread_id: String,
|
|
},
|
|
Fork {
|
|
thread_id: String,
|
|
},
|
|
Archive {
|
|
thread_id: String,
|
|
},
|
|
Unarchive {
|
|
thread_id: String,
|
|
},
|
|
SetName {
|
|
thread_id: String,
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct SandboxArgs {
|
|
#[command(subcommand)]
|
|
command: SandboxCommand,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum SandboxCommand {
|
|
Check {
|
|
command: String,
|
|
#[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
|
|
ask: ApprovalModeArg,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
enum ApprovalModeArg {
|
|
UnlessTrusted,
|
|
OnFailure,
|
|
OnRequest,
|
|
Never,
|
|
}
|
|
|
|
impl From<ApprovalModeArg> for AskForApproval {
|
|
fn from(value: ApprovalModeArg) -> Self {
|
|
match value {
|
|
ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
|
|
ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
|
|
ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
|
|
ApprovalModeArg::Never => AskForApproval::Never,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
struct AppServerArgs {
|
|
#[arg(long, default_value = "127.0.0.1")]
|
|
host: String,
|
|
#[arg(long, default_value_t = 8787)]
|
|
port: u16,
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
#[arg(long, default_value_t = false)]
|
|
stdio: bool,
|
|
}
|
|
|
|
const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
|
|
|
|
fn main() -> ExitCode {
|
|
match run() {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(err) => {
|
|
eprintln!("error: {err}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<()> {
|
|
let mut cli = Cli::parse();
|
|
|
|
let mut store = ConfigStore::load(cli.config.clone())?;
|
|
let runtime_overrides = CliRuntimeOverrides {
|
|
provider: cli.provider.map(Into::into),
|
|
model: cli.model.clone(),
|
|
api_key: cli.api_key.clone(),
|
|
base_url: cli.base_url.clone(),
|
|
auth_mode: None,
|
|
output_mode: cli.output_mode.clone(),
|
|
log_level: cli.log_level.clone(),
|
|
telemetry: cli.telemetry,
|
|
approval_policy: cli.approval_policy.clone(),
|
|
sandbox_mode: cli.sandbox_mode.clone(),
|
|
};
|
|
let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);
|
|
|
|
let command = cli.command.take();
|
|
|
|
match command {
|
|
Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),
|
|
Some(Commands::Doctor(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
|
|
}
|
|
Some(Commands::Models(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
|
|
}
|
|
Some(Commands::Sessions(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
|
|
}
|
|
Some(Commands::Resume(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("resume", args))
|
|
}
|
|
Some(Commands::Fork(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
|
|
}
|
|
Some(Commands::Init(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
|
|
}
|
|
Some(Commands::Setup(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
|
|
}
|
|
Some(Commands::Exec(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
|
|
}
|
|
Some(Commands::Review(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
|
|
}
|
|
Some(Commands::Apply(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
|
|
}
|
|
Some(Commands::Eval(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
|
|
}
|
|
Some(Commands::Mcp(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
|
|
}
|
|
Some(Commands::Features(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
|
|
}
|
|
Some(Commands::Serve(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
|
|
}
|
|
Some(Commands::Completions(args)) => {
|
|
delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
|
|
}
|
|
Some(Commands::Login(args)) => run_login_command(&mut store, args),
|
|
Some(Commands::Logout) => run_logout_command(&mut store),
|
|
Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
|
|
Some(Commands::McpServer) => run_mcp_server_command(&mut store),
|
|
Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
|
|
Some(Commands::Model(args)) => run_model_command(args.command),
|
|
Some(Commands::Thread(args)) => run_thread_command(args.command),
|
|
Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
|
|
Some(Commands::AppServer(args)) => run_app_server_command(args),
|
|
Some(Commands::Completion { shell }) => {
|
|
let mut cmd = Cli::command();
|
|
generate(shell, &mut cmd, "deepseek", &mut io::stdout());
|
|
Ok(())
|
|
}
|
|
Some(Commands::Metrics(args)) => run_metrics_command(args),
|
|
None => {
|
|
let mut forwarded = Vec::new();
|
|
if let Some(prompt) = cli.prompt.clone() {
|
|
forwarded.push("--prompt".to_string());
|
|
forwarded.push(prompt);
|
|
}
|
|
delegate_to_tui(&cli, &resolved_runtime, forwarded)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
|
|
let mut forwarded = Vec::with_capacity(args.args.len() + 1);
|
|
forwarded.push(command.to_string());
|
|
forwarded.extend(args.args);
|
|
forwarded
|
|
}
|
|
|
|
fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
|
|
let provider: ProviderKind = args.provider.into();
|
|
store.config.provider = provider;
|
|
|
|
if args.chatgpt {
|
|
let token = match args.token {
|
|
Some(token) => token,
|
|
None => read_api_key_from_stdin()?,
|
|
};
|
|
store.config.auth_mode = Some("chatgpt".to_string());
|
|
store.config.chatgpt_access_token = Some(token);
|
|
store.config.device_code_session = None;
|
|
store.save()?;
|
|
println!("logged in using chatgpt token mode ({})", provider.as_str());
|
|
return Ok(());
|
|
}
|
|
|
|
if args.device_code {
|
|
let token = match args.token {
|
|
Some(token) => token,
|
|
None => read_api_key_from_stdin()?,
|
|
};
|
|
store.config.auth_mode = Some("device_code".to_string());
|
|
store.config.device_code_session = Some(token);
|
|
store.config.chatgpt_access_token = None;
|
|
store.save()?;
|
|
println!(
|
|
"logged in using device code session mode ({})",
|
|
provider.as_str()
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let api_key = match args.api_key {
|
|
Some(v) => v,
|
|
None => read_api_key_from_stdin()?,
|
|
};
|
|
store.config.auth_mode = Some("api_key".to_string());
|
|
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
|
|
if provider == ProviderKind::Deepseek {
|
|
store.config.api_key = store.config.providers.deepseek.api_key.clone();
|
|
if store.config.default_text_model.is_none() {
|
|
store.config.default_text_model = Some(
|
|
store
|
|
.config
|
|
.providers
|
|
.deepseek
|
|
.model
|
|
.clone()
|
|
.unwrap_or_else(|| "deepseek-v4-pro".to_string()),
|
|
);
|
|
}
|
|
}
|
|
store.save()?;
|
|
if provider == ProviderKind::Deepseek {
|
|
println!(
|
|
"logged in using API key mode (deepseek). This also updates the shared deepseek-tui config."
|
|
);
|
|
} else {
|
|
println!("logged in using API key mode ({})", provider.as_str());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
|
|
store.config.api_key = None;
|
|
store.config.providers.deepseek.api_key = None;
|
|
store.config.providers.nvidia_nim.api_key = None;
|
|
store.config.providers.openai.api_key = None;
|
|
store.config.auth_mode = None;
|
|
store.config.chatgpt_access_token = None;
|
|
store.config.device_code_session = None;
|
|
store.save()?;
|
|
println!("logged out");
|
|
Ok(())
|
|
}
|
|
|
|
fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
|
|
match command {
|
|
AuthCommand::Status => {
|
|
let deepseek_env = std::env::var("DEEPSEEK_API_KEY")
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty())
|
|
.is_some();
|
|
let openai_env = std::env::var("OPENAI_API_KEY")
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty())
|
|
.is_some();
|
|
let nvidia_env = std::env::var("NVIDIA_API_KEY")
|
|
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty())
|
|
.is_some();
|
|
let deepseek_file = store
|
|
.config
|
|
.providers
|
|
.deepseek
|
|
.api_key
|
|
.as_ref()
|
|
.or(store.config.api_key.as_ref())
|
|
.is_some_and(|v| !v.trim().is_empty());
|
|
let openai_file = store
|
|
.config
|
|
.providers
|
|
.openai
|
|
.api_key
|
|
.as_ref()
|
|
.is_some_and(|v| !v.trim().is_empty());
|
|
let nvidia_file = store
|
|
.config
|
|
.providers
|
|
.nvidia_nim
|
|
.api_key
|
|
.as_ref()
|
|
.is_some_and(|v| !v.trim().is_empty());
|
|
|
|
println!("provider: {}", store.config.provider.as_str());
|
|
println!(
|
|
"deepseek auth: env={}, config={}",
|
|
deepseek_env, deepseek_file
|
|
);
|
|
println!(
|
|
"nvidia-nim auth: env={}, config={}",
|
|
nvidia_env, nvidia_file
|
|
);
|
|
println!("openai auth: env={}, config={}", openai_env, openai_file);
|
|
Ok(())
|
|
}
|
|
AuthCommand::Set { provider, api_key } => {
|
|
let provider: ProviderKind = provider.into();
|
|
let api_key = match api_key {
|
|
Some(v) => v,
|
|
None => read_api_key_from_stdin()?,
|
|
};
|
|
store.config.provider = provider;
|
|
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
|
|
if provider == ProviderKind::Deepseek {
|
|
store.config.api_key = store.config.providers.deepseek.api_key.clone();
|
|
}
|
|
store.save()?;
|
|
println!("saved API key for {}", provider.as_str());
|
|
Ok(())
|
|
}
|
|
AuthCommand::Clear { provider } => {
|
|
let provider: ProviderKind = provider.into();
|
|
store.config.providers.for_provider_mut(provider).api_key = None;
|
|
if provider == ProviderKind::Deepseek {
|
|
store.config.api_key = None;
|
|
}
|
|
store.save()?;
|
|
println!("cleared API key for {}", provider.as_str());
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
|
|
match command {
|
|
ConfigCommand::Get { key } => {
|
|
if let Some(value) = store.config.get_value(&key) {
|
|
println!("{value}");
|
|
return Ok(());
|
|
}
|
|
bail!("key not found: {key}");
|
|
}
|
|
ConfigCommand::Set { key, value } => {
|
|
store.config.set_value(&key, &value)?;
|
|
store.save()?;
|
|
println!("set {key}");
|
|
Ok(())
|
|
}
|
|
ConfigCommand::Unset { key } => {
|
|
store.config.unset_value(&key)?;
|
|
store.save()?;
|
|
println!("unset {key}");
|
|
Ok(())
|
|
}
|
|
ConfigCommand::List => {
|
|
for (key, value) in store.config.list_values() {
|
|
println!("{key} = {value}");
|
|
}
|
|
Ok(())
|
|
}
|
|
ConfigCommand::Path => {
|
|
println!("{}", store.path().display());
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_model_command(command: ModelCommand) -> Result<()> {
|
|
let registry = ModelRegistry::default();
|
|
match command {
|
|
ModelCommand::List { provider } => {
|
|
let filter = provider.map(ProviderKind::from);
|
|
for model in registry.list().into_iter().filter(|m| match filter {
|
|
Some(p) => m.provider == p,
|
|
None => true,
|
|
}) {
|
|
println!("{} ({})", model.id, model.provider.as_str());
|
|
}
|
|
Ok(())
|
|
}
|
|
ModelCommand::Resolve { model, provider } => {
|
|
let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
|
|
println!("requested: {}", resolved.requested.unwrap_or_default());
|
|
println!("resolved: {}", resolved.resolved.id);
|
|
println!("provider: {}", resolved.resolved.provider.as_str());
|
|
println!("used_fallback: {}", resolved.used_fallback);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_thread_command(command: ThreadCommand) -> Result<()> {
|
|
let state = StateStore::open(None)?;
|
|
match command {
|
|
ThreadCommand::List { all, limit } => {
|
|
let threads = state.list_threads(ThreadListFilters {
|
|
include_archived: all,
|
|
limit,
|
|
})?;
|
|
for thread in threads {
|
|
println!(
|
|
"{} | {} | {} | {}",
|
|
thread.id,
|
|
thread
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "(unnamed)".to_string()),
|
|
thread.model_provider,
|
|
thread.cwd.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
ThreadCommand::Read { thread_id } => {
|
|
let thread = state.get_thread(&thread_id)?;
|
|
println!("{}", serde_json::to_string_pretty(&thread)?);
|
|
Ok(())
|
|
}
|
|
ThreadCommand::Resume { thread_id } => {
|
|
let args = vec!["resume".to_string(), thread_id];
|
|
delegate_simple_tui(args)
|
|
}
|
|
ThreadCommand::Fork { thread_id } => {
|
|
let args = vec!["fork".to_string(), thread_id];
|
|
delegate_simple_tui(args)
|
|
}
|
|
ThreadCommand::Archive { thread_id } => {
|
|
state.mark_archived(&thread_id)?;
|
|
println!("archived {thread_id}");
|
|
Ok(())
|
|
}
|
|
ThreadCommand::Unarchive { thread_id } => {
|
|
state.mark_unarchived(&thread_id)?;
|
|
println!("unarchived {thread_id}");
|
|
Ok(())
|
|
}
|
|
ThreadCommand::SetName { thread_id, name } => {
|
|
let mut thread = state
|
|
.get_thread(&thread_id)?
|
|
.with_context(|| format!("thread not found: {thread_id}"))?;
|
|
thread.name = Some(name);
|
|
thread.updated_at = chrono::Utc::now().timestamp();
|
|
state.upsert_thread(&thread)?;
|
|
println!("renamed {thread_id}");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
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(),
|
|
ask_for_approval: ask.into(),
|
|
sandbox_mode: Some("workspace-write"),
|
|
})?;
|
|
println!("{}", serde_json::to_string_pretty(&decision)?);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_app_server_command(args: AppServerArgs) -> Result<()> {
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.context("failed to create tokio runtime")?;
|
|
if args.stdio {
|
|
return runtime.block_on(run_app_server_stdio(args.config));
|
|
}
|
|
let listen: SocketAddr = format!("{}:{}", args.host, args.port)
|
|
.parse()
|
|
.with_context(|| {
|
|
format!(
|
|
"invalid app-server listen address {}:{}",
|
|
args.host, args.port
|
|
)
|
|
})?;
|
|
runtime.block_on(run_app_server(AppServerOptions {
|
|
listen,
|
|
config_path: args.config,
|
|
}))
|
|
}
|
|
|
|
fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
|
|
let persisted = load_mcp_server_definitions(store);
|
|
let updated = run_stdio_server(persisted)?;
|
|
persist_mcp_server_definitions(store, &updated)
|
|
}
|
|
|
|
fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
|
|
let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
match parse_mcp_server_definitions(&raw) {
|
|
Ok(definitions) => definitions,
|
|
Err(err) => {
|
|
eprintln!(
|
|
"warning: failed to parse persisted MCP server definitions ({}): {}",
|
|
MCP_SERVER_DEFINITIONS_KEY, err
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
|
|
if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
|
|
return Ok(parsed);
|
|
}
|
|
|
|
let unwrapped: String = serde_json::from_str(raw)
|
|
.with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
|
|
serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
|
|
format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
|
|
})
|
|
}
|
|
|
|
fn persist_mcp_server_definitions(
|
|
store: &mut ConfigStore,
|
|
definitions: &[McpServerDefinition],
|
|
) -> Result<()> {
|
|
let encoded =
|
|
serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
|
|
store
|
|
.config
|
|
.set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
|
|
store.save()
|
|
}
|
|
|
|
fn delegate_to_tui(
|
|
cli: &Cli,
|
|
resolved_runtime: &ResolvedRuntimeOptions,
|
|
passthrough: Vec<String>,
|
|
) -> Result<()> {
|
|
let current = std::env::current_exe().context("failed to locate current executable path")?;
|
|
let tui = current.with_file_name("deepseek-tui");
|
|
if !tui.exists() {
|
|
bail!(
|
|
"deepseek-tui binary not found at {}. Build workspace default members to install it.",
|
|
tui.display()
|
|
);
|
|
}
|
|
|
|
let mut cmd = Command::new(tui);
|
|
if let Some(config) = cli.config.as_ref() {
|
|
cmd.arg("--config").arg(config);
|
|
}
|
|
if let Some(profile) = cli.profile.as_ref() {
|
|
cmd.arg("--profile").arg(profile);
|
|
}
|
|
if cli.no_alt_screen {
|
|
cmd.arg("--no-alt-screen");
|
|
}
|
|
if cli.mouse_capture {
|
|
cmd.arg("--mouse-capture");
|
|
}
|
|
if cli.no_mouse_capture {
|
|
cmd.arg("--no-mouse-capture");
|
|
}
|
|
cmd.args(passthrough);
|
|
|
|
if !matches!(
|
|
resolved_runtime.provider,
|
|
ProviderKind::Deepseek | ProviderKind::NvidiaNim
|
|
) {
|
|
bail!(
|
|
"The interactive TUI supports DeepSeek and NVIDIA NIM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
|
|
resolved_runtime.provider.as_str()
|
|
);
|
|
}
|
|
|
|
cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
|
|
cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
|
|
cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
|
|
if let Some(api_key) = resolved_runtime.api_key.as_ref() {
|
|
cmd.env("DEEPSEEK_API_KEY", api_key);
|
|
}
|
|
|
|
if let Some(model) = cli.model.as_ref() {
|
|
cmd.env("DEEPSEEK_MODEL", model);
|
|
}
|
|
if let Some(output_mode) = cli.output_mode.as_ref() {
|
|
cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
|
|
}
|
|
if let Some(log_level) = cli.log_level.as_ref() {
|
|
cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
|
|
}
|
|
if let Some(telemetry) = cli.telemetry {
|
|
cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
|
|
}
|
|
if let Some(policy) = cli.approval_policy.as_ref() {
|
|
cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
|
|
}
|
|
if let Some(mode) = cli.sandbox_mode.as_ref() {
|
|
cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
|
|
}
|
|
if let Some(api_key) = cli.api_key.as_ref() {
|
|
cmd.env("DEEPSEEK_API_KEY", api_key);
|
|
}
|
|
if let Some(base_url) = cli.base_url.as_ref() {
|
|
cmd.env("DEEPSEEK_BASE_URL", base_url);
|
|
}
|
|
|
|
let status = cmd.status().context("failed to spawn deepseek-tui")?;
|
|
match status.code() {
|
|
Some(code) => std::process::exit(code),
|
|
None => bail!("deepseek-tui terminated by signal"),
|
|
}
|
|
}
|
|
|
|
fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
|
|
let current = std::env::current_exe().context("failed to locate current executable path")?;
|
|
let tui = current.with_file_name("deepseek-tui");
|
|
if !tui.exists() {
|
|
bail!(
|
|
"deepseek-tui binary not found at {}. Build workspace default members to install it.",
|
|
tui.display()
|
|
);
|
|
}
|
|
let status = Command::new(tui).args(args).status()?;
|
|
match status.code() {
|
|
Some(code) => std::process::exit(code),
|
|
None => bail!("deepseek-tui terminated by signal"),
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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()
|
|
}
|
|
|
|
#[test]
|
|
fn clap_command_definition_is_consistent() {
|
|
Cli::command().debug_assert();
|
|
}
|
|
|
|
#[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_model_command_matrix() {
|
|
let cli = parse_ok(&["deepseek", "model", "list"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::List { provider: None }
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::List {
|
|
provider: Some(ProviderArg::Openai)
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::Resolve {
|
|
model: Some(ref model),
|
|
provider: None
|
|
}
|
|
})) if model == "deepseek-v4-flash"
|
|
));
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"model",
|
|
"resolve",
|
|
"--provider",
|
|
"deepseek",
|
|
"deepseek-v4-pro",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Model(ModelArgs {
|
|
command: ModelCommand::Resolve {
|
|
model: Some(ref model),
|
|
provider: Some(ProviderArg::Deepseek)
|
|
}
|
|
})) if model == "deepseek-v4-pro"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_thread_command_matrix() {
|
|
let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::List {
|
|
all: true,
|
|
limit: Some(50)
|
|
}
|
|
}))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Read { ref thread_id }
|
|
})) if thread_id == "thread-1"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Resume { ref thread_id }
|
|
})) if thread_id == "thread-2"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Fork { ref thread_id }
|
|
})) if thread_id == "thread-3"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Archive { ref thread_id }
|
|
})) if thread_id == "thread-4"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::Unarchive { ref thread_id }
|
|
})) if thread_id == "thread-5"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Thread(ThreadArgs {
|
|
command: ThreadCommand::SetName {
|
|
ref thread_id,
|
|
ref name
|
|
}
|
|
})) if thread_id == "thread-6" && name == "My Thread"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_sandbox_app_server_and_completion_matrix() {
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"sandbox",
|
|
"check",
|
|
"echo hello",
|
|
"--ask",
|
|
"on-failure",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Sandbox(SandboxArgs {
|
|
command: SandboxCommand::Check {
|
|
ref command,
|
|
ask: ApprovalModeArg::OnFailure
|
|
}
|
|
})) if command == "echo hello"
|
|
));
|
|
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"app-server",
|
|
"--host",
|
|
"0.0.0.0",
|
|
"--port",
|
|
"9999",
|
|
]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::AppServer(AppServerArgs {
|
|
ref host,
|
|
port: 9999,
|
|
stdio: false,
|
|
..
|
|
})) if host == "0.0.0.0"
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "completion", "bash"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Completion { shell: Shell::Bash })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_direct_tui_command_aliases() {
|
|
let cli = parse_ok(&["deepseek", "doctor"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "models", "--json"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "resume", "abc123"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
|
|
));
|
|
|
|
let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
|
|
assert!(matches!(
|
|
cli.command,
|
|
Some(Commands::Setup(TuiPassthroughArgs { ref args }))
|
|
if args == &["--skills", "--local"]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn deepseek_login_writes_tui_compatible_config() {
|
|
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"deepseek-cli-login-test-{}-{nanos}.toml",
|
|
std::process::id()
|
|
));
|
|
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
|
|
|
run_login_command(
|
|
&mut store,
|
|
LoginArgs {
|
|
provider: ProviderArg::Deepseek,
|
|
api_key: Some("sk-test".to_string()),
|
|
chatgpt: false,
|
|
device_code: false,
|
|
token: None,
|
|
},
|
|
)
|
|
.expect("login should write config");
|
|
|
|
assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
|
|
assert_eq!(
|
|
store.config.default_text_model.as_deref(),
|
|
Some("deepseek-v4-pro")
|
|
);
|
|
let saved = std::fs::read_to_string(&path).expect("config should be written");
|
|
assert!(saved.contains("api_key = \"sk-test\""));
|
|
assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_global_override_flags() {
|
|
let cli = parse_ok(&[
|
|
"deepseek",
|
|
"--provider",
|
|
"openai",
|
|
"--config",
|
|
"/tmp/deepseek.toml",
|
|
"--profile",
|
|
"work",
|
|
"--model",
|
|
"gpt-4.1",
|
|
"--output-mode",
|
|
"json",
|
|
"--log-level",
|
|
"debug",
|
|
"--telemetry",
|
|
"true",
|
|
"--approval-policy",
|
|
"on-request",
|
|
"--sandbox-mode",
|
|
"workspace-write",
|
|
"--base-url",
|
|
"https://api.openai.com/v1",
|
|
"--api-key",
|
|
"sk-test",
|
|
"--no-alt-screen",
|
|
"--no-mouse-capture",
|
|
"model",
|
|
"resolve",
|
|
"gpt-4.1",
|
|
]);
|
|
|
|
assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
|
|
assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
|
|
assert_eq!(cli.profile.as_deref(), Some("work"));
|
|
assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
|
|
assert_eq!(cli.output_mode.as_deref(), Some("json"));
|
|
assert_eq!(cli.log_level.as_deref(), Some("debug"));
|
|
assert_eq!(cli.telemetry, Some(true));
|
|
assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
|
|
assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
|
|
assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
|
|
assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
|
|
assert!(cli.no_alt_screen);
|
|
assert!(cli.no_mouse_capture);
|
|
assert!(!cli.mouse_capture);
|
|
}
|
|
|
|
#[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",
|
|
"--no-alt-screen",
|
|
"--mouse-capture",
|
|
"--no-mouse-capture",
|
|
] {
|
|
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",
|
|
],
|
|
),
|
|
("sandbox", vec!["check"]),
|
|
(
|
|
"app-server",
|
|
vec!["--host", "--port", "--config", "--stdio"],
|
|
),
|
|
("completion", vec!["<SHELL>", "bash"]),
|
|
("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}`"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|