Skip to main content

aster_cli/
cli.rs

1use anyhow::Result;
2use aster::config::{Config, ExtensionConfig};
3use aster_mcp::mcp_server_runner::{serve, McpCommand};
4use aster_mcp::{
5    AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer,
6};
7use clap::{Args, CommandFactory, Parser, Subcommand};
8use clap_complete::{generate, Shell as ClapShell};
9
10use crate::commands::acp::run_acp_agent;
11use crate::commands::bench::agent_generator;
12use crate::commands::configure::handle_configure;
13use crate::commands::info::handle_info;
14use crate::commands::project::{handle_project_default, handle_projects_interactive};
15use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate};
16use crate::commands::term::{
17    handle_term_info, handle_term_init, handle_term_log, handle_term_run, Shell,
18};
19
20use crate::commands::schedule::{
21    handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove,
22    handle_schedule_run_now, handle_schedule_services_status, handle_schedule_services_stop,
23    handle_schedule_sessions,
24};
25use crate::commands::session::{handle_session_list, handle_session_remove};
26use crate::recipes::extract_from_cli::extract_recipe_info_from_cli;
27use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml};
28use crate::session::{build_session, SessionBuilderConfig, SessionSettings};
29use aster::session::session_manager::SessionType;
30use aster::session::SessionManager;
31use aster_bench::bench_config::BenchRunConfig;
32use aster_bench::runners::bench_runner::BenchRunner;
33use aster_bench::runners::eval_runner::EvalRunner;
34use aster_bench::runners::metric_aggregator::MetricAggregator;
35use aster_bench::runners::model_runner::ModelRunner;
36use std::io::Read;
37use std::path::PathBuf;
38use tracing::warn;
39
40#[derive(Parser)]
41#[command(author, version, display_name = "", about, long_about = None)]
42struct Cli {
43    #[command(subcommand)]
44    command: Option<Command>,
45}
46
47#[derive(Args, Debug, Clone)]
48#[group(required = false, multiple = false)]
49pub struct Identifier {
50    #[arg(
51        short = 'n',
52        long,
53        value_name = "NAME",
54        help = "Name for the chat session (e.g., 'project-x')",
55        long_help = "Specify a name for your chat session. When used with --resume, will resume this specific session if it exists."
56    )]
57    pub name: Option<String>,
58
59    #[arg(
60        long = "session-id",
61        alias = "id",
62        value_name = "SESSION_ID",
63        help = "Session ID (e.g., '20250921_143022')",
64        long_help = "Specify a session ID directly. When used with --resume, will resume this specific session if it exists."
65    )]
66    pub session_id: Option<String>,
67
68    #[arg(
69        long,
70        value_name = "PATH",
71        help = "Legacy: Path for the chat session",
72        long_help = "Legacy parameter for backward compatibility. Extracts session ID from the file path (e.g., '/path/to/20250325_200615.
73jsonl' -> '20250325_200615')."
74    )]
75    pub path: Option<PathBuf>,
76}
77
78/// Session behavior options shared between Session and Run commands
79#[derive(Args, Debug, Clone, Default)]
80pub struct SessionOptions {
81    #[arg(
82        long,
83        help = "Enable debug output mode with full content and no truncation",
84        long_help = "When enabled, shows complete tool responses without truncation and full paths."
85    )]
86    pub debug: bool,
87
88    #[arg(
89        long = "max-tool-repetitions",
90        value_name = "NUMBER",
91        help = "Maximum number of consecutive identical tool calls allowed",
92        long_help = "Set a limit on how many times the same tool can be called consecutively with identical parameters. Helps prevent infinite loops."
93    )]
94    pub max_tool_repetitions: Option<u32>,
95
96    #[arg(
97        long = "max-turns",
98        value_name = "NUMBER",
99        help = "Maximum number of turns allowed without user input (default: 1000)",
100        long_help = "Set a limit on how many turns (iterations) the agent can take without asking for user input to continue."
101    )]
102    pub max_turns: Option<u32>,
103}
104
105/// Extension configuration options shared between Session and Run commands
106#[derive(Args, Debug, Clone, Default)]
107pub struct ExtensionOptions {
108    #[arg(
109        long = "with-extension",
110        value_name = "COMMAND",
111        help = "Add stdio extensions (can be specified multiple times)",
112        long_help = "Add stdio extensions from full commands with environment variables. Can be specified multiple times. Format: 'ENV1=val1 ENV2=val2 command args...'",
113        action = clap::ArgAction::Append
114    )]
115    pub extensions: Vec<String>,
116
117    #[arg(
118        long = "with-streamable-http-extension",
119        value_name = "URL",
120        help = "Add streamable HTTP extensions (can be specified multiple times)",
121        long_help = "Add streamable HTTP extensions from a URL. Can be specified multiple times. Format: 'url...'",
122        action = clap::ArgAction::Append
123    )]
124    pub streamable_http_extensions: Vec<String>,
125
126    #[arg(
127        long = "with-builtin",
128        value_name = "NAME",
129        help = "Add builtin extensions by name (e.g., 'developer' or multiple: 'developer,github')",
130        long_help = "Add one or more builtin extensions that are bundled with aster by specifying their names, comma-separated",
131        value_delimiter = ','
132    )]
133    pub builtins: Vec<String>,
134}
135
136/// Input source and recipe options for the run command
137#[derive(Args, Debug, Clone, Default)]
138pub struct InputOptions {
139    /// Path to instruction file containing commands
140    #[arg(
141        short,
142        long,
143        value_name = "FILE",
144        help = "Path to instruction file containing commands. Use - for stdin.",
145        conflicts_with = "input_text",
146        conflicts_with = "recipe"
147    )]
148    pub instructions: Option<String>,
149
150    /// Input text containing commands
151    #[arg(
152        short = 't',
153        long = "text",
154        value_name = "TEXT",
155        help = "Input text to provide to aster directly",
156        long_help = "Input text containing commands for aster. Use this in lieu of the instructions argument.",
157        conflicts_with = "instructions",
158        conflicts_with = "recipe"
159    )]
160    pub input_text: Option<String>,
161
162    /// Recipe name or full path to the recipe file
163    #[arg(
164        short = None,
165        long = "recipe",
166        value_name = "RECIPE_NAME or FULL_PATH_TO_RECIPE_FILE",
167        help = "Recipe name to get recipe file or the full path of the recipe file (use --explain to see recipe details)",
168        long_help = "Recipe name to get recipe file or the full path of the recipe file that defines a custom agent configuration. Use --explain to see the recipe's title, description, and parameters.",
169        conflicts_with = "instructions",
170        conflicts_with = "input_text"
171    )]
172    pub recipe: Option<String>,
173
174    /// Additional system prompt to customize agent behavior
175    #[arg(
176        long = "system",
177        value_name = "TEXT",
178        help = "Additional system prompt to customize agent behavior",
179        long_help = "Provide additional system instructions to customize the agent's behavior",
180        conflicts_with = "recipe"
181    )]
182    pub system: Option<String>,
183
184    #[arg(
185        long,
186        value_name = "KEY=VALUE",
187        help = "Dynamic parameters (e.g., --params username=alice --params channel_name=aster-channel)",
188        long_help = "Key-value parameters to pass to the recipe file. Can be specified multiple times.",
189        action = clap::ArgAction::Append,
190        value_parser = parse_key_val,
191    )]
192    pub params: Vec<(String, String)>,
193
194    /// Additional sub-recipe file paths
195    #[arg(
196        long = "sub-recipe",
197        value_name = "RECIPE",
198        help = "Sub-recipe name or file path (can be specified multiple times)",
199        long_help = "Specify sub-recipes to include alongside the main recipe. Can be:\n  - Recipe names from GitHub (if ASTER_RECIPE_GITHUB_REPO is configured)\n  - Local file paths to YAML files\nCan be specified multiple times to include multiple sub-recipes.",
200        action = clap::ArgAction::Append
201    )]
202    pub additional_sub_recipes: Vec<String>,
203
204    /// Show the recipe title, description, and parameters
205    #[arg(
206        long = "explain",
207        help = "Show the recipe title, description, and parameters"
208    )]
209    pub explain: bool,
210
211    /// Print the rendered recipe instead of running it
212    #[arg(
213        long = "render-recipe",
214        help = "Print the rendered recipe instead of running it."
215    )]
216    pub render_recipe: bool,
217}
218
219/// Output configuration options for the run command
220#[derive(Args, Debug, Clone)]
221pub struct OutputOptions {
222    /// Quiet mode - suppress non-response output
223    #[arg(
224        short = 'q',
225        long = "quiet",
226        help = "Quiet mode. Suppress non-response output, printing only the model response to stdout"
227    )]
228    pub quiet: bool,
229
230    /// Output format (text, json, stream-json)
231    #[arg(
232        long = "output-format",
233        value_name = "FORMAT",
234        help = "Output format (text, json, stream-json)",
235        default_value = "text",
236        value_parser = clap::builder::PossibleValuesParser::new(["text", "json", "stream-json"])
237    )]
238    pub output_format: String,
239}
240
241impl Default for OutputOptions {
242    fn default() -> Self {
243        Self {
244            quiet: false,
245            output_format: "text".to_string(),
246        }
247    }
248}
249
250/// Model/provider override options for the run command
251#[derive(Args, Debug, Clone, Default)]
252pub struct ModelOptions {
253    /// Provider to use for this run (overrides environment variable)
254    #[arg(
255        long = "provider",
256        value_name = "PROVIDER",
257        help = "Specify the LLM provider to use (e.g., 'openai', 'anthropic')",
258        long_help = "Override the ASTER_PROVIDER environment variable for this run. Available providers include openai, anthropic, ollama, databricks, gemini-cli, claude-code, and others."
259    )]
260    pub provider: Option<String>,
261
262    /// Model to use for this run (overrides environment variable)
263    #[arg(
264        long = "model",
265        value_name = "MODEL",
266        help = "Specify the model to use (e.g., 'gpt-4o', 'claude-sonnet-4-20250514')",
267        long_help = "Override the ASTER_MODEL environment variable for this run. The model must be supported by the specified provider."
268    )]
269    pub model: Option<String>,
270}
271
272/// Run execution behavior options
273#[derive(Args, Debug, Clone, Default)]
274pub struct RunBehavior {
275    /// Continue in interactive mode after processing input
276    #[arg(
277        short = 's',
278        long = "interactive",
279        help = "Continue in interactive mode after processing initial input"
280    )]
281    pub interactive: bool,
282
283    /// Run without storing a session file
284    #[arg(
285        long = "no-session",
286        help = "Run without storing a session file",
287        long_help = "Execute commands without creating or using a session file. Useful for automated runs.",
288        conflicts_with_all = ["resume", "name", "path"]
289    )]
290    pub no_session: bool,
291
292    /// Resume a previous run
293    #[arg(
294        short,
295        long,
296        action = clap::ArgAction::SetTrue,
297        help = "Resume from a previous run",
298        long_help = "Continue from a previous run, maintaining the execution state and context."
299    )]
300    pub resume: bool,
301
302    /// Scheduled job ID (used internally for scheduled executions)
303    #[arg(
304        long = "scheduled-job-id",
305        value_name = "ID",
306        help = "ID of the scheduled job that triggered this execution (internal use)",
307        long_help = "Internal parameter used when this run command is executed by a scheduled job. This associates the session with the schedule for tracking purposes.",
308        hide = true
309    )]
310    pub scheduled_job_id: Option<String>,
311}
312
313async fn get_or_create_session_id(
314    identifier: Option<Identifier>,
315    resume: bool,
316    no_session: bool,
317) -> Result<Option<String>> {
318    if no_session {
319        return Ok(None);
320    }
321
322    let Some(id) = identifier else {
323        return if resume {
324            let sessions = SessionManager::list_sessions().await?;
325            let session_id = sessions
326                .first()
327                .map(|s| s.id.clone())
328                .ok_or_else(|| anyhow::anyhow!("No session found to resume"))?;
329            Ok(Some(session_id))
330        } else {
331            let session = SessionManager::create_session(
332                std::env::current_dir()?,
333                "CLI Session".to_string(),
334                SessionType::User,
335            )
336            .await?;
337            Ok(Some(session.id))
338        };
339    };
340
341    if let Some(session_id) = id.session_id {
342        Ok(Some(session_id))
343    } else if let Some(name) = id.name {
344        if resume {
345            let sessions = SessionManager::list_sessions().await?;
346            let session_id = sessions
347                .into_iter()
348                .find(|s| s.name == name || s.id == name)
349                .map(|s| s.id)
350                .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?;
351            Ok(Some(session_id))
352        } else {
353            let session = SessionManager::create_session(
354                std::env::current_dir()?,
355                name.clone(),
356                SessionType::User,
357            )
358            .await?;
359
360            SessionManager::update_session(&session.id)
361                .user_provided_name(name)
362                .apply()
363                .await?;
364
365            Ok(Some(session.id))
366        }
367    } else if let Some(path) = id.path {
368        let session_id = path
369            .file_stem()
370            .and_then(|s| s.to_str())
371            .map(|s| s.to_string())
372            .ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?;
373        Ok(Some(session_id))
374    } else {
375        let session = SessionManager::create_session(
376            std::env::current_dir()?,
377            "CLI Session".to_string(),
378            SessionType::User,
379        )
380        .await?;
381        Ok(Some(session.id))
382    }
383}
384
385async fn lookup_session_id(identifier: Identifier) -> Result<String> {
386    if let Some(session_id) = identifier.session_id {
387        Ok(session_id)
388    } else if let Some(name) = identifier.name {
389        let sessions = SessionManager::list_sessions().await?;
390        sessions
391            .into_iter()
392            .find(|s| s.name == name || s.id == name)
393            .map(|s| s.id)
394            .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))
395    } else if let Some(path) = identifier.path {
396        path.file_stem()
397            .and_then(|s| s.to_str())
398            .map(|s| s.to_string())
399            .ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))
400    } else {
401        Err(anyhow::anyhow!("No identifier provided"))
402    }
403}
404
405fn parse_key_val(s: &str) -> Result<(String, String), String> {
406    match s.split_once('=') {
407        Some((key, value)) => Ok((key.to_string(), value.to_string())),
408        None => Err(format!("invalid KEY=VALUE: {}", s)),
409    }
410}
411
412#[derive(Subcommand)]
413enum SessionCommand {
414    #[command(about = "List all available sessions")]
415    List {
416        #[arg(
417            short,
418            long,
419            help = "Output format (text, json)",
420            default_value = "text"
421        )]
422        format: String,
423
424        #[arg(
425            long = "ascending",
426            help = "Sort by date in ascending order (oldest first)",
427            long_help = "Sort sessions by date in ascending order (oldest first). Default is descending order (newest first)."
428        )]
429        ascending: bool,
430
431        #[arg(
432            short = 'w',
433            short_alias = 'p',
434            long = "working_dir",
435            help = "Filter sessions by working directory"
436        )]
437        working_dir: Option<PathBuf>,
438
439        #[arg(short = 'l', long = "limit", help = "Limit the number of results")]
440        limit: Option<usize>,
441    },
442    #[command(about = "Remove sessions. Runs interactively if no ID, name, or regex is provided.")]
443    Remove {
444        #[command(flatten)]
445        identifier: Option<Identifier>,
446        #[arg(
447            short = 'r',
448            long,
449            help = "Regex for removing matched sessions (optional)"
450        )]
451        regex: Option<String>,
452    },
453    #[command(about = "Export a session")]
454    Export {
455        #[command(flatten)]
456        identifier: Option<Identifier>,
457
458        #[arg(
459            short,
460            long,
461            help = "Output file path (default: stdout)",
462            long_help = "Path to save the exported Markdown. If not provided, output will be sent to stdout"
463        )]
464        output: Option<PathBuf>,
465
466        #[arg(
467            long = "format",
468            value_name = "FORMAT",
469            help = "Output format (markdown, json, yaml)",
470            default_value = "markdown"
471        )]
472        format: String,
473    },
474    #[command(name = "diagnostics")]
475    Diagnostics {
476        /// Session identifier for generating diagnostics
477        #[command(flatten)]
478        identifier: Option<Identifier>,
479
480        /// Output path for the diagnostics zip file (optional, defaults to current directory)
481        #[arg(short = 'o', long)]
482        output: Option<PathBuf>,
483    },
484}
485
486#[derive(Subcommand, Debug)]
487enum SchedulerCommand {
488    #[command(about = "Add a new scheduled job")]
489    Add {
490        #[arg(
491            long = "schedule-id",
492            alias = "id",
493            help = "Unique ID for the recurring scheduled job"
494        )]
495        schedule_id: String,
496        #[arg(
497            long,
498            help = "Cron expression for the schedule",
499            long_help = "Cron expression for when to run the job. Examples:\n  '0 * * * *'     - Every hour at minute 0\n  '0 */2 * * *'   - Every 2 hours\n  '@hourly'       - Every hour (shorthand)\n  '0 9 * * *'     - Every day at 9:00 AM\n  '0 9 * * 1'     - Every Monday at 9:00 AM\n  '0 0 1 * *'     - First day of every month at midnight"
500        )]
501        cron: String,
502        #[arg(
503            long,
504            help = "Recipe source (path to file, or base64 encoded recipe string)"
505        )]
506        recipe_source: String,
507    },
508    #[command(about = "List all scheduled jobs")]
509    List {},
510    #[command(about = "Remove a scheduled job by ID")]
511    Remove {
512        #[arg(
513            long = "schedule-id",
514            alias = "id",
515            help = "ID of the scheduled job to remove (removes the recurring schedule)"
516        )]
517        schedule_id: String,
518    },
519    /// List sessions created by a specific schedule
520    #[command(about = "List sessions created by a specific schedule")]
521    Sessions {
522        /// ID of the schedule
523        #[arg(long = "schedule-id", alias = "id", help = "ID of the schedule")]
524        schedule_id: String,
525        #[arg(short = 'l', long, help = "Maximum number of sessions to return")]
526        limit: Option<usize>,
527    },
528    #[command(about = "Run a scheduled job immediately")]
529    RunNow {
530        /// ID of the schedule to run
531        #[arg(long = "schedule-id", alias = "id", help = "ID of the schedule to run")]
532        schedule_id: String,
533    },
534    /// Check status of scheduler services (deprecated - no external services needed)
535    #[command(about = "[Deprecated] Check status of scheduler services")]
536    ServicesStatus {},
537    /// Stop scheduler services (deprecated - no external services needed)
538    #[command(about = "[Deprecated] Stop scheduler services")]
539    ServicesStop {},
540    /// Show cron expression examples and help
541    #[command(about = "Show cron expression examples and help")]
542    CronHelp {},
543}
544
545#[derive(Subcommand)]
546pub enum BenchCommand {
547    #[command(name = "init-config", about = "Create a new starter-config")]
548    InitConfig {
549        #[arg(short, long, help = "filename with extension for generated config")]
550        name: String,
551    },
552
553    #[command(about = "Run all benchmarks from a config")]
554    Run {
555        #[arg(
556            short,
557            long,
558            help = "A config file generated by the config-init command"
559        )]
560        config: PathBuf,
561    },
562
563    #[command(about = "List all available selectors")]
564    Selectors {
565        #[arg(
566            short,
567            long,
568            help = "A config file generated by the config-init command"
569        )]
570        config: Option<PathBuf>,
571    },
572
573    #[command(name = "eval-model", about = "Run an eval of model")]
574    EvalModel {
575        #[arg(short, long, help = "A serialized config file for the model only.")]
576        config: String,
577    },
578
579    #[command(name = "exec-eval", about = "run a single eval")]
580    ExecEval {
581        #[arg(short, long, help = "A serialized config file for the eval only.")]
582        config: String,
583    },
584
585    #[command(
586        name = "generate-leaderboard",
587        about = "Generate a leaderboard CSV from benchmark results"
588    )]
589    GenerateLeaderboard {
590        #[arg(
591            short,
592            long,
593            help = "Path to the benchmark directory containing model evaluation results"
594        )]
595        benchmark_dir: PathBuf,
596    },
597}
598
599#[derive(Subcommand)]
600enum RecipeCommand {
601    /// Validate a recipe file
602    #[command(about = "Validate a recipe")]
603    Validate {
604        /// Recipe name to get recipe file to validate
605        #[arg(help = "recipe name to get recipe file or full path to the recipe file to validate")]
606        recipe_name: String,
607    },
608
609    /// Generate a deeplink for a recipe file
610    #[command(about = "Generate a deeplink for a recipe")]
611    Deeplink {
612        /// Recipe name to get recipe file to generate deeplink
613        #[arg(
614            help = "recipe name to get recipe file or full path to the recipe file to generate deeplink"
615        )]
616        recipe_name: String,
617        /// Recipe parameters in key=value format (can be specified multiple times)
618        #[arg(
619            short = 'p',
620            long = "param",
621            value_name = "KEY=VALUE",
622            help = "Recipe parameter in key=value format (can be specified multiple times)"
623        )]
624        params: Vec<String>,
625    },
626
627    /// Open a recipe in Aster Desktop
628    #[command(about = "Open a recipe in Aster Desktop")]
629    Open {
630        /// Recipe name to get recipe file to open
631        #[arg(help = "recipe name or full path to the recipe file")]
632        recipe_name: String,
633        /// Recipe parameters in key=value format (can be specified multiple times)
634        #[arg(
635            short = 'p',
636            long = "param",
637            value_name = "KEY=VALUE",
638            help = "Recipe parameter in key=value format (can be specified multiple times)"
639        )]
640        params: Vec<String>,
641    },
642
643    /// List available recipes
644    #[command(about = "List available recipes")]
645    List {
646        /// Output format (text, json)
647        #[arg(
648            long = "format",
649            value_name = "FORMAT",
650            help = "Output format (text, json)",
651            default_value = "text"
652        )]
653        format: String,
654
655        /// Show verbose information including recipe descriptions
656        #[arg(
657            short,
658            long,
659            help = "Show verbose information including recipe descriptions"
660        )]
661        verbose: bool,
662    },
663}
664
665#[derive(Subcommand)]
666enum Command {
667    /// Configure aster settings
668    #[command(about = "Configure aster settings")]
669    Configure {},
670
671    /// Display aster configuration information
672    #[command(about = "Display aster information")]
673    Info {
674        /// Show verbose information including current configuration
675        #[arg(short, long, help = "Show verbose information including config.yaml")]
676        verbose: bool,
677    },
678
679    /// Manage system prompts and behaviors
680    #[command(about = "Run one of the mcp servers bundled with aster")]
681    Mcp {
682        #[arg(value_parser = clap::value_parser!(McpCommand))]
683        server: McpCommand,
684    },
685
686    /// Run aster as an ACP (Agent Client Protocol) agent
687    #[command(about = "Run aster as an ACP agent server on stdio")]
688    Acp {
689        /// Add builtin extensions by name
690        #[arg(
691            long = "with-builtin",
692            value_name = "NAME",
693            help = "Add builtin extensions by name (e.g., 'developer' or multiple: 'developer,github')",
694            long_help = "Add one or more builtin extensions that are bundled with aster by specifying their names, comma-separated",
695            value_delimiter = ','
696        )]
697        builtins: Vec<String>,
698    },
699
700    /// Start or resume interactive chat sessions
701    #[command(
702        about = "Start or resume interactive chat sessions",
703        visible_alias = "s"
704    )]
705    Session {
706        #[command(subcommand)]
707        command: Option<SessionCommand>,
708
709        #[command(flatten)]
710        identifier: Option<Identifier>,
711
712        /// Resume a previous session
713        #[arg(
714            short,
715            long,
716            help = "Resume a previous session (last used or specified by --name/--session-id)",
717            long_help = "Continue from a previous session. If --name or --session-id is provided, resumes that specific session. Otherwise resumes the most recently used session."
718        )]
719        resume: bool,
720
721        /// Show message history when resuming
722        #[arg(
723            long,
724            help = "Show previous messages when resuming a session",
725            requires = "resume"
726        )]
727        history: bool,
728
729        #[command(flatten)]
730        session_opts: SessionOptions,
731
732        #[command(flatten)]
733        extension_opts: ExtensionOptions,
734    },
735
736    /// Open the last project directory
737    #[command(about = "Open the last project directory", visible_alias = "p")]
738    Project {},
739
740    /// List recent project directories
741    #[command(about = "List recent project directories", visible_alias = "ps")]
742    Projects,
743
744    /// Execute commands from an instruction file
745    #[command(about = "Execute commands from an instruction file or stdin")]
746    Run {
747        #[command(flatten)]
748        input_opts: InputOptions,
749
750        #[command(flatten)]
751        identifier: Option<Identifier>,
752
753        #[command(flatten)]
754        run_behavior: RunBehavior,
755
756        #[command(flatten)]
757        session_opts: SessionOptions,
758
759        #[command(flatten)]
760        extension_opts: ExtensionOptions,
761
762        #[command(flatten)]
763        output_opts: OutputOptions,
764
765        #[command(flatten)]
766        model_opts: ModelOptions,
767    },
768
769    /// Recipe utilities for validation and deeplinking
770    #[command(about = "Recipe utilities for validation and deeplinking")]
771    Recipe {
772        #[command(subcommand)]
773        command: RecipeCommand,
774    },
775
776    /// Manage scheduled jobs
777    #[command(about = "Manage scheduled jobs", visible_alias = "sched")]
778    Schedule {
779        #[command(subcommand)]
780        command: SchedulerCommand,
781    },
782
783    /// Update the aster CLI version
784    #[command(about = "Update the aster CLI version")]
785    Update {
786        /// Update to canary version
787        #[arg(
788            short,
789            long,
790            help = "Update to canary version",
791            long_help = "Update to the latest canary version of the aster CLI, otherwise updates to the latest stable version."
792        )]
793        canary: bool,
794
795        /// Enforce to re-configure aster during update
796        #[arg(short, long, help = "Enforce to re-configure aster during update")]
797        reconfigure: bool,
798    },
799
800    /// Evaluate system configuration across a range of practical tasks
801    #[command(about = "Evaluate system configuration across a range of practical tasks")]
802    Bench {
803        #[command(subcommand)]
804        cmd: BenchCommand,
805    },
806
807    /// Start a web server with a chat interface
808    #[command(about = "Experimental: Start a web server with a chat interface")]
809    Web {
810        /// Port to run the web server on
811        #[arg(
812            short,
813            long,
814            default_value = "3000",
815            help = "Port to run the web server on"
816        )]
817        port: u16,
818
819        /// Host to bind the web server to
820        #[arg(
821            long,
822            default_value = "127.0.0.1",
823            help = "Host to bind the web server to"
824        )]
825        host: String,
826
827        /// Open browser automatically
828        #[arg(long, help = "Open browser automatically when server starts")]
829        open: bool,
830
831        /// Authentication token for both Basic Auth (password) and Bearer token
832        #[arg(long, help = "Authentication token to secure the web interface")]
833        auth_token: Option<String>,
834    },
835
836    /// Terminal-integrated session (one session per terminal)
837    #[command(
838        about = "Terminal-integrated aster session",
839        long_about = "Runs a aster session tied to your terminal window.\n\
840                      Each terminal maintains its own persistent session that resumes automatically.\n\n\
841                      Setup:\n  \
842                        eval \"$(aster term init zsh)\"  # Add to ~/.zshrc\n\n\
843                      Usage:\n  \
844                        aster term run \"list files in this directory\"\n  \
845                        @aster \"create a python script\"  # using alias\n  \
846                        @g \"quick question\"  # short alias"
847    )]
848    Term {
849        #[command(subcommand)]
850        command: TermCommand,
851    },
852    /// Generate completions for various shells
853    #[command(about = "Generate the autocompletion script for the specified shell")]
854    Completion {
855        #[arg(value_enum)]
856        shell: ClapShell,
857    },
858}
859
860#[derive(Subcommand)]
861enum TermCommand {
862    /// Print shell initialization script
863    #[command(
864        about = "Print shell initialization script",
865        long_about = "Prints shell configuration to set up terminal-integrated sessions.\n\
866                      Each terminal gets a persistent aster session that automatically resumes.\n\n\
867                      Setup:\n  \
868                        echo 'eval \"$(aster term init zsh)\"' >> ~/.zshrc\n  \
869                        source ~/.zshrc\n\n\
870                      With --default (anything typed that isn't a command goes to aster):\n  \
871                        echo 'eval \"$(aster term init zsh --default)\"' >> ~/.zshrc"
872    )]
873    Init {
874        /// Shell type (bash, zsh, fish, powershell)
875        #[arg(value_enum)]
876        shell: Shell,
877
878        #[arg(short, long, help = "Name for the terminal session")]
879        name: Option<String>,
880
881        /// Make aster the default handler for unknown commands
882        #[arg(
883            long = "default",
884            help = "Make aster the default handler for unknown commands",
885            long_help = "When enabled, anything you type that isn't a valid command will be sent to aster. Only supported for zsh and bash."
886        )]
887        default: bool,
888    },
889
890    /// Log a shell command (called by shell hook)
891    #[command(about = "Log a shell command to the session", hide = true)]
892    Log {
893        /// The command that was executed
894        command: String,
895    },
896
897    /// Run a prompt in the terminal session
898    #[command(
899        about = "Run a prompt in the terminal session",
900        long_about = "Run a prompt in the terminal-integrated session.\n\n\
901                      Examples:\n  \
902                        aster term run list files in this directory\n  \
903                        @aster list files  # using alias\n  \
904                        @g why did that fail  # short alias"
905    )]
906    Run {
907        /// The prompt to send to aster (multiple words allowed without quotes)
908        #[arg(required = true, num_args = 1..)]
909        prompt: Vec<String>,
910    },
911
912    /// Print session info for prompt integration
913    #[command(
914        about = "Print session info for prompt integration",
915        long_about = "Prints compact session info (token usage, model) for shell prompt integration.\n\
916                      Example output: ●○○○○ sonnet"
917    )]
918    Info,
919}
920
921#[derive(clap::ValueEnum, Clone, Debug)]
922enum CliProviderVariant {
923    OpenAi,
924    Databricks,
925    Ollama,
926}
927
928#[derive(Debug)]
929pub struct InputConfig {
930    pub contents: Option<String>,
931    pub extensions_override: Option<Vec<ExtensionConfig>>,
932    pub additional_system_prompt: Option<String>,
933}
934
935#[derive(Debug)]
936pub struct RecipeInfo {
937    pub session_settings: Option<SessionSettings>,
938    pub sub_recipes: Option<Vec<aster::recipe::SubRecipe>>,
939    pub final_output_response: Option<aster::recipe::Response>,
940    pub retry_config: Option<aster::agents::types::RetryConfig>,
941}
942
943fn get_command_name(command: &Option<Command>) -> &'static str {
944    match command {
945        Some(Command::Configure {}) => "configure",
946        Some(Command::Info { .. }) => "info",
947        Some(Command::Mcp { .. }) => "mcp",
948        Some(Command::Acp { .. }) => "acp",
949        Some(Command::Session { .. }) => "session",
950        Some(Command::Project {}) => "project",
951        Some(Command::Projects) => "projects",
952        Some(Command::Run { .. }) => "run",
953        Some(Command::Schedule { .. }) => "schedule",
954        Some(Command::Update { .. }) => "update",
955        Some(Command::Bench { .. }) => "bench",
956        Some(Command::Recipe { .. }) => "recipe",
957        Some(Command::Web { .. }) => "web",
958        Some(Command::Term { .. }) => "term",
959        Some(Command::Completion { .. }) => "completion",
960        None => "default_session",
961    }
962}
963
964async fn handle_mcp_command(server: McpCommand) -> Result<()> {
965    let name = server.name();
966    crate::logging::setup_logging(Some(&format!("mcp-{name}")), None)?;
967    match server {
968        McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?,
969        McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?,
970        McpCommand::Memory => serve(MemoryServer::new()).await?,
971        McpCommand::Tutorial => serve(TutorialServer::new()).await?,
972        McpCommand::Developer => serve(DeveloperServer::new()).await?,
973    }
974    Ok(())
975}
976
977async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
978    match command {
979        SessionCommand::List {
980            format,
981            ascending,
982            working_dir,
983            limit,
984        } => {
985            handle_session_list(format, ascending, working_dir, limit).await?;
986        }
987        SessionCommand::Remove { identifier, regex } => {
988            let (session_id, name) = if let Some(id) = identifier {
989                (id.session_id, id.name)
990            } else {
991                (None, None)
992            };
993            handle_session_remove(session_id, name, regex).await?;
994        }
995        SessionCommand::Export {
996            identifier,
997            output,
998            format,
999        } => {
1000            let session_identifier = if let Some(id) = identifier {
1001                lookup_session_id(id).await?
1002            } else {
1003                match crate::commands::session::prompt_interactive_session_selection().await {
1004                    Ok(id) => id,
1005                    Err(e) => {
1006                        eprintln!("Error: {}", e);
1007                        return Ok(());
1008                    }
1009                }
1010            };
1011            crate::commands::session::handle_session_export(session_identifier, output, format)
1012                .await?;
1013        }
1014        SessionCommand::Diagnostics { identifier, output } => {
1015            let session_id = if let Some(id) = identifier {
1016                lookup_session_id(id).await?
1017            } else {
1018                match crate::commands::session::prompt_interactive_session_selection().await {
1019                    Ok(id) => id,
1020                    Err(e) => {
1021                        eprintln!("Error: {}", e);
1022                        return Ok(());
1023                    }
1024                }
1025            };
1026            crate::commands::session::handle_diagnostics(&session_id, output).await?;
1027        }
1028    }
1029    Ok(())
1030}
1031
1032async fn handle_interactive_session(
1033    identifier: Option<Identifier>,
1034    resume: bool,
1035    history: bool,
1036    session_opts: SessionOptions,
1037    extension_opts: ExtensionOptions,
1038) -> Result<()> {
1039    let session_start = std::time::Instant::now();
1040    let session_type = if resume { "resumed" } else { "new" };
1041
1042    tracing::info!(
1043        counter.aster.session_starts = 1,
1044        session_type,
1045        interactive = true,
1046        "Session started"
1047    );
1048
1049    if let Some(Identifier {
1050        session_id: Some(_),
1051        ..
1052    }) = &identifier
1053    {
1054        if !resume {
1055            eprintln!("Error: --session-id can only be used with --resume flag");
1056            std::process::exit(1);
1057        }
1058    }
1059
1060    let session_id = get_or_create_session_id(identifier, resume, false).await?;
1061
1062    let mut session: crate::CliSession = build_session(SessionBuilderConfig {
1063        session_id,
1064        resume,
1065        no_session: false,
1066        extensions: extension_opts.extensions,
1067        streamable_http_extensions: extension_opts.streamable_http_extensions,
1068        builtins: extension_opts.builtins,
1069        extensions_override: None,
1070        additional_system_prompt: None,
1071        settings: None,
1072        provider: None,
1073        model: None,
1074        debug: session_opts.debug,
1075        max_tool_repetitions: session_opts.max_tool_repetitions,
1076        max_turns: session_opts.max_turns,
1077        scheduled_job_id: None,
1078        interactive: true,
1079        quiet: false,
1080        sub_recipes: None,
1081        final_output_response: None,
1082        retry_config: None,
1083        output_format: "text".to_string(),
1084    })
1085    .await;
1086
1087    if resume && history {
1088        session.render_message_history();
1089    }
1090
1091    let result = session.interactive(None).await;
1092    log_session_completion(&session, session_start, session_type, result.is_ok()).await;
1093    result
1094}
1095
1096async fn log_session_completion(
1097    session: &crate::CliSession,
1098    session_start: std::time::Instant,
1099    session_type: &str,
1100    success: bool,
1101) {
1102    let session_duration = session_start.elapsed();
1103    let exit_type = if success { "normal" } else { "error" };
1104
1105    let (total_tokens, message_count) = session
1106        .get_session()
1107        .await
1108        .map(|m| (m.total_tokens.unwrap_or(0), m.message_count))
1109        .unwrap_or((0, 0));
1110
1111    tracing::info!(
1112        counter.aster.session_completions = 1,
1113        session_type,
1114        exit_type,
1115        duration_ms = session_duration.as_millis() as u64,
1116        total_tokens,
1117        message_count,
1118        "Session completed"
1119    );
1120
1121    tracing::info!(
1122        counter.aster.session_duration_ms = session_duration.as_millis() as u64,
1123        session_type,
1124        "Session duration"
1125    );
1126
1127    if total_tokens > 0 {
1128        tracing::info!(
1129            counter.aster.session_tokens = total_tokens,
1130            session_type,
1131            "Session tokens"
1132        );
1133    }
1134}
1135
1136fn parse_run_input(
1137    input_opts: &InputOptions,
1138    quiet: bool,
1139) -> Result<Option<(InputConfig, Option<RecipeInfo>)>> {
1140    match (
1141        &input_opts.instructions,
1142        &input_opts.input_text,
1143        &input_opts.recipe,
1144    ) {
1145        (Some(file), _, _) if file == "-" => {
1146            let mut contents = String::new();
1147            std::io::stdin()
1148                .read_to_string(&mut contents)
1149                .expect("Failed to read from stdin");
1150            Ok(Some((
1151                InputConfig {
1152                    contents: Some(contents),
1153                    extensions_override: None,
1154                    additional_system_prompt: input_opts.system.clone(),
1155                },
1156                None,
1157            )))
1158        }
1159        (Some(file), _, _) => {
1160            let contents = std::fs::read_to_string(file).unwrap_or_else(|err| {
1161                eprintln!(
1162                    "Instruction file not found — did you mean to use aster run --text?\n{}",
1163                    err
1164                );
1165                std::process::exit(1);
1166            });
1167            Ok(Some((
1168                InputConfig {
1169                    contents: Some(contents),
1170                    extensions_override: None,
1171                    additional_system_prompt: None,
1172                },
1173                None,
1174            )))
1175        }
1176        (_, Some(text), _) => Ok(Some((
1177            InputConfig {
1178                contents: Some(text.clone()),
1179                extensions_override: None,
1180                additional_system_prompt: input_opts.system.clone(),
1181            },
1182            None,
1183        ))),
1184        (_, _, Some(recipe_name)) => {
1185            let recipe_display_name = std::path::Path::new(recipe_name)
1186                .file_name()
1187                .and_then(|name| name.to_str())
1188                .unwrap_or(recipe_name);
1189
1190            let recipe_version = crate::recipes::search_recipe::load_recipe_file(recipe_name)
1191                .ok()
1192                .and_then(|rf| {
1193                    aster::recipe::template_recipe::parse_recipe_content(
1194                        &rf.content,
1195                        Some(rf.parent_dir.display().to_string()),
1196                    )
1197                    .ok()
1198                    .map(|(r, _)| r.version)
1199                })
1200                .unwrap_or_else(|| "unknown".to_string());
1201
1202            if input_opts.explain {
1203                explain_recipe(recipe_name, input_opts.params.clone())?;
1204                return Ok(None);
1205            }
1206            if input_opts.render_recipe {
1207                if let Err(err) = render_recipe_as_yaml(recipe_name, input_opts.params.clone()) {
1208                    eprintln!("{}: {}", console::style("Error").red().bold(), err);
1209                    std::process::exit(1);
1210                }
1211                return Ok(None);
1212            }
1213
1214            tracing::info!(
1215                counter.aster.recipe_runs = 1,
1216                recipe_name = %recipe_display_name,
1217                recipe_version = %recipe_version,
1218                session_type = "recipe",
1219                interface = "cli",
1220                "Recipe execution started"
1221            );
1222
1223            let (input_config, recipe_info) = extract_recipe_info_from_cli(
1224                recipe_name.clone(),
1225                input_opts.params.clone(),
1226                input_opts.additional_sub_recipes.clone(),
1227                quiet,
1228            )?;
1229            Ok(Some((input_config, Some(recipe_info))))
1230        }
1231        (None, None, None) => {
1232            eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin.");
1233            std::process::exit(1);
1234        }
1235    }
1236}
1237
1238async fn handle_run_command(
1239    input_opts: InputOptions,
1240    identifier: Option<Identifier>,
1241    run_behavior: RunBehavior,
1242    session_opts: SessionOptions,
1243    extension_opts: ExtensionOptions,
1244    output_opts: OutputOptions,
1245    model_opts: ModelOptions,
1246) -> Result<()> {
1247    let parsed = parse_run_input(&input_opts, output_opts.quiet)?;
1248
1249    let Some((input_config, recipe_info)) = parsed else {
1250        return Ok(());
1251    };
1252
1253    if let Some(Identifier {
1254        session_id: Some(_),
1255        ..
1256    }) = &identifier
1257    {
1258        if !run_behavior.resume {
1259            eprintln!("Error: --session-id can only be used with --resume flag");
1260            std::process::exit(1);
1261        }
1262    }
1263
1264    let session_id =
1265        get_or_create_session_id(identifier, run_behavior.resume, run_behavior.no_session).await?;
1266
1267    let mut session = build_session(SessionBuilderConfig {
1268        session_id,
1269        resume: run_behavior.resume,
1270        no_session: run_behavior.no_session,
1271        extensions: extension_opts.extensions,
1272        streamable_http_extensions: extension_opts.streamable_http_extensions,
1273        builtins: extension_opts.builtins,
1274        extensions_override: input_config.extensions_override,
1275        additional_system_prompt: input_config.additional_system_prompt,
1276        settings: recipe_info
1277            .as_ref()
1278            .and_then(|r| r.session_settings.clone()),
1279        provider: model_opts.provider,
1280        model: model_opts.model,
1281        debug: session_opts.debug,
1282        max_tool_repetitions: session_opts.max_tool_repetitions,
1283        max_turns: session_opts.max_turns,
1284        scheduled_job_id: run_behavior.scheduled_job_id,
1285        interactive: run_behavior.interactive,
1286        quiet: output_opts.quiet,
1287        sub_recipes: recipe_info.as_ref().and_then(|r| r.sub_recipes.clone()),
1288        final_output_response: recipe_info
1289            .as_ref()
1290            .and_then(|r| r.final_output_response.clone()),
1291        retry_config: recipe_info.as_ref().and_then(|r| r.retry_config.clone()),
1292        output_format: output_opts.output_format,
1293    })
1294    .await;
1295
1296    if run_behavior.interactive {
1297        session.interactive(input_config.contents).await
1298    } else if let Some(contents) = input_config.contents {
1299        let session_start = std::time::Instant::now();
1300        let session_type = if recipe_info.is_some() {
1301            "recipe"
1302        } else {
1303            "run"
1304        };
1305
1306        tracing::info!(
1307            counter.aster.session_starts = 1,
1308            session_type,
1309            interactive = false,
1310            "Headless session started"
1311        );
1312
1313        let result = session.headless(contents).await;
1314        log_session_completion(&session, session_start, session_type, result.is_ok()).await;
1315        result
1316    } else {
1317        Err(anyhow::anyhow!(
1318            "no text provided for prompt in headless mode"
1319        ))
1320    }
1321}
1322
1323async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> {
1324    match command {
1325        SchedulerCommand::Add {
1326            schedule_id,
1327            cron,
1328            recipe_source,
1329        } => handle_schedule_add(schedule_id, cron, recipe_source).await,
1330        SchedulerCommand::List {} => handle_schedule_list().await,
1331        SchedulerCommand::Remove { schedule_id } => handle_schedule_remove(schedule_id).await,
1332        SchedulerCommand::Sessions { schedule_id, limit } => {
1333            handle_schedule_sessions(schedule_id, limit).await
1334        }
1335        SchedulerCommand::RunNow { schedule_id } => handle_schedule_run_now(schedule_id).await,
1336        SchedulerCommand::ServicesStatus {} => handle_schedule_services_status().await,
1337        SchedulerCommand::ServicesStop {} => handle_schedule_services_stop().await,
1338        SchedulerCommand::CronHelp {} => handle_schedule_cron_help().await,
1339    }
1340}
1341
1342async fn handle_bench_command(cmd: BenchCommand) -> Result<()> {
1343    match cmd {
1344        BenchCommand::Selectors { config } => BenchRunner::list_selectors(config)?,
1345        BenchCommand::InitConfig { name } => {
1346            let mut config = BenchRunConfig::default();
1347            let cwd = std::env::current_dir()?;
1348            config.output_dir = Some(cwd);
1349            config.save(name);
1350        }
1351        BenchCommand::Run { config } => BenchRunner::new(config)?.run()?,
1352        BenchCommand::EvalModel { config } => ModelRunner::from(config)?.run()?,
1353        BenchCommand::ExecEval { config } => EvalRunner::from(config)?.run(agent_generator).await?,
1354        BenchCommand::GenerateLeaderboard { benchmark_dir } => {
1355            MetricAggregator::generate_csv_from_benchmark_dir(&benchmark_dir)?
1356        }
1357    }
1358    Ok(())
1359}
1360
1361fn handle_recipe_subcommand(command: RecipeCommand) -> Result<()> {
1362    match command {
1363        RecipeCommand::Validate { recipe_name } => handle_validate(&recipe_name),
1364        RecipeCommand::Deeplink {
1365            recipe_name,
1366            params,
1367        } => {
1368            handle_deeplink(&recipe_name, &params)?;
1369            Ok(())
1370        }
1371        RecipeCommand::Open {
1372            recipe_name,
1373            params,
1374        } => handle_open(&recipe_name, &params),
1375        RecipeCommand::List { format, verbose } => handle_list(&format, verbose),
1376    }
1377}
1378
1379async fn handle_term_subcommand(command: TermCommand) -> Result<()> {
1380    match command {
1381        TermCommand::Init {
1382            shell,
1383            name,
1384            default,
1385        } => handle_term_init(shell, name, default).await,
1386        TermCommand::Log { command } => handle_term_log(command).await,
1387        TermCommand::Run { prompt } => handle_term_run(prompt).await,
1388        TermCommand::Info => handle_term_info().await,
1389    }
1390}
1391
1392async fn handle_default_session() -> Result<()> {
1393    if !Config::global().exists() {
1394        return handle_configure().await;
1395    }
1396
1397    let session_id = get_or_create_session_id(None, false, false).await?;
1398
1399    let mut session = build_session(SessionBuilderConfig {
1400        session_id,
1401        resume: false,
1402        no_session: false,
1403        extensions: Vec::new(),
1404        streamable_http_extensions: Vec::new(),
1405        builtins: Vec::new(),
1406        extensions_override: None,
1407        additional_system_prompt: None,
1408        settings: None::<SessionSettings>,
1409        provider: None,
1410        model: None,
1411        debug: false,
1412        max_tool_repetitions: None,
1413        max_turns: None,
1414        scheduled_job_id: None,
1415        interactive: true,
1416        quiet: false,
1417        sub_recipes: None,
1418        final_output_response: None,
1419        retry_config: None,
1420        output_format: "text".to_string(),
1421    })
1422    .await;
1423    session.interactive(None).await
1424}
1425
1426pub async fn cli() -> anyhow::Result<()> {
1427    let cli = Cli::parse();
1428
1429    if let Err(e) = crate::project_tracker::update_project_tracker(None, None) {
1430        warn!("Warning: Failed to update project tracker: {}", e);
1431    }
1432
1433    let command_name = get_command_name(&cli.command);
1434    tracing::info!(
1435        counter.aster.cli_commands = 1,
1436        command = command_name,
1437        "CLI command executed"
1438    );
1439
1440    match cli.command {
1441        Some(Command::Completion { shell }) => {
1442            let mut cmd = Cli::command();
1443            let bin_name = cmd.get_name().to_string();
1444            generate(shell, &mut cmd, bin_name, &mut std::io::stdout());
1445            Ok(())
1446        }
1447        Some(Command::Configure {}) => handle_configure().await,
1448        Some(Command::Info { verbose }) => handle_info(verbose),
1449        Some(Command::Mcp { server }) => handle_mcp_command(server).await,
1450        Some(Command::Acp { builtins }) => run_acp_agent(builtins).await,
1451        Some(Command::Session {
1452            command: Some(cmd), ..
1453        }) => handle_session_subcommand(cmd).await,
1454        Some(Command::Session {
1455            command: None,
1456            identifier,
1457            resume,
1458            history,
1459            session_opts,
1460            extension_opts,
1461        }) => {
1462            handle_interactive_session(identifier, resume, history, session_opts, extension_opts)
1463                .await
1464        }
1465        Some(Command::Project {}) => {
1466            handle_project_default()?;
1467            Ok(())
1468        }
1469        Some(Command::Projects) => {
1470            handle_projects_interactive()?;
1471            Ok(())
1472        }
1473        Some(Command::Run {
1474            input_opts,
1475            identifier,
1476            run_behavior,
1477            session_opts,
1478            extension_opts,
1479            output_opts,
1480            model_opts,
1481        }) => {
1482            handle_run_command(
1483                input_opts,
1484                identifier,
1485                run_behavior,
1486                session_opts,
1487                extension_opts,
1488                output_opts,
1489                model_opts,
1490            )
1491            .await
1492        }
1493        Some(Command::Schedule { command }) => handle_schedule_command(command).await,
1494        Some(Command::Update {
1495            canary,
1496            reconfigure,
1497        }) => {
1498            crate::commands::update::update(canary, reconfigure)?;
1499            Ok(())
1500        }
1501        Some(Command::Bench { cmd }) => handle_bench_command(cmd).await,
1502        Some(Command::Recipe { command }) => handle_recipe_subcommand(command),
1503        Some(Command::Web {
1504            port,
1505            host,
1506            open,
1507            auth_token,
1508        }) => crate::commands::web::handle_web(port, host, open, auth_token).await,
1509        Some(Command::Term { command }) => handle_term_subcommand(command).await,
1510        None => handle_default_session().await,
1511    }
1512}