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#[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#[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#[derive(Args, Debug, Clone, Default)]
138pub struct InputOptions {
139 #[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 #[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 #[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 #[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 #[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 #[arg(
206 long = "explain",
207 help = "Show the recipe title, description, and parameters"
208 )]
209 pub explain: bool,
210
211 #[arg(
213 long = "render-recipe",
214 help = "Print the rendered recipe instead of running it."
215 )]
216 pub render_recipe: bool,
217}
218
219#[derive(Args, Debug, Clone)]
221pub struct OutputOptions {
222 #[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 #[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#[derive(Args, Debug, Clone, Default)]
252pub struct ModelOptions {
253 #[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 #[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#[derive(Args, Debug, Clone, Default)]
274pub struct RunBehavior {
275 #[arg(
277 short = 's',
278 long = "interactive",
279 help = "Continue in interactive mode after processing initial input"
280 )]
281 pub interactive: bool,
282
283 #[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 #[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 #[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 #[command(flatten)]
478 identifier: Option<Identifier>,
479
480 #[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 #[command(about = "List sessions created by a specific schedule")]
521 Sessions {
522 #[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 #[arg(long = "schedule-id", alias = "id", help = "ID of the schedule to run")]
532 schedule_id: String,
533 },
534 #[command(about = "[Deprecated] Check status of scheduler services")]
536 ServicesStatus {},
537 #[command(about = "[Deprecated] Stop scheduler services")]
539 ServicesStop {},
540 #[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 #[command(about = "Validate a recipe")]
603 Validate {
604 #[arg(help = "recipe name to get recipe file or full path to the recipe file to validate")]
606 recipe_name: String,
607 },
608
609 #[command(about = "Generate a deeplink for a recipe")]
611 Deeplink {
612 #[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 #[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 #[command(about = "Open a recipe in Aster Desktop")]
629 Open {
630 #[arg(help = "recipe name or full path to the recipe file")]
632 recipe_name: String,
633 #[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 #[command(about = "List available recipes")]
645 List {
646 #[arg(
648 long = "format",
649 value_name = "FORMAT",
650 help = "Output format (text, json)",
651 default_value = "text"
652 )]
653 format: String,
654
655 #[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 #[command(about = "Configure aster settings")]
669 Configure {},
670
671 #[command(about = "Display aster information")]
673 Info {
674 #[arg(short, long, help = "Show verbose information including config.yaml")]
676 verbose: bool,
677 },
678
679 #[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 #[command(about = "Run aster as an ACP agent server on stdio")]
688 Acp {
689 #[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 #[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 #[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 #[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 #[command(about = "Open the last project directory", visible_alias = "p")]
738 Project {},
739
740 #[command(about = "List recent project directories", visible_alias = "ps")]
742 Projects,
743
744 #[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 #[command(about = "Recipe utilities for validation and deeplinking")]
771 Recipe {
772 #[command(subcommand)]
773 command: RecipeCommand,
774 },
775
776 #[command(about = "Manage scheduled jobs", visible_alias = "sched")]
778 Schedule {
779 #[command(subcommand)]
780 command: SchedulerCommand,
781 },
782
783 #[command(about = "Update the aster CLI version")]
785 Update {
786 #[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 #[arg(short, long, help = "Enforce to re-configure aster during update")]
797 reconfigure: bool,
798 },
799
800 #[command(about = "Evaluate system configuration across a range of practical tasks")]
802 Bench {
803 #[command(subcommand)]
804 cmd: BenchCommand,
805 },
806
807 #[command(about = "Experimental: Start a web server with a chat interface")]
809 Web {
810 #[arg(
812 short,
813 long,
814 default_value = "3000",
815 help = "Port to run the web server on"
816 )]
817 port: u16,
818
819 #[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 #[arg(long, help = "Open browser automatically when server starts")]
829 open: bool,
830
831 #[arg(long, help = "Authentication token to secure the web interface")]
833 auth_token: Option<String>,
834 },
835
836 #[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 #[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 #[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 #[arg(value_enum)]
876 shell: Shell,
877
878 #[arg(short, long, help = "Name for the terminal session")]
879 name: Option<String>,
880
881 #[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 #[command(about = "Log a shell command to the session", hide = true)]
892 Log {
893 command: String,
895 },
896
897 #[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 #[arg(required = true, num_args = 1..)]
909 prompt: Vec<String>,
910 },
911
912 #[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, ¶ms)?;
1369 Ok(())
1370 }
1371 RecipeCommand::Open {
1372 recipe_name,
1373 params,
1374 } => handle_open(&recipe_name, ¶ms),
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}