1#![warn(clippy::all, clippy::pedantic)]
35#![allow(
37 clippy::assigning_clones,
38 clippy::bool_to_int_with_if,
39 clippy::case_sensitive_file_extension_comparisons,
40 clippy::cast_possible_wrap,
41 clippy::doc_markdown,
42 clippy::field_reassign_with_default,
43 clippy::float_cmp,
44 clippy::implicit_clone,
45 clippy::items_after_statements,
46 clippy::map_unwrap_or,
47 clippy::manual_let_else,
48 clippy::missing_errors_doc,
49 clippy::missing_panics_doc,
50 clippy::large_futures,
51 clippy::module_name_repetitions,
52 clippy::needless_pass_by_value,
53 clippy::needless_raw_string_hashes,
54 clippy::redundant_closure_for_method_calls,
55 clippy::similar_names,
56 clippy::single_match_else,
57 clippy::struct_field_names,
58 clippy::too_many_lines,
59 clippy::uninlined_format_args,
60 clippy::unused_self,
61 clippy::cast_precision_loss,
62 clippy::unnecessary_cast,
63 clippy::unnecessary_lazy_evaluations,
64 clippy::unnecessary_literal_bound,
65 clippy::unnecessary_map_or,
66 clippy::unnecessary_wraps,
67 dead_code
68)]
69
70use anyhow::{Context, Result, bail};
71use clap::Parser;
72use tracing::info;
73use tracing_subscriber::{EnvFilter, fmt};
74
75use vw_agent::channels::ChannelCommands;
76use vw_agent::cron;
77use vw_agent::integrations;
78use vw_agent::provider::provider;
79use vw_agent::{
80 channels, config, daemon, doctor, gateway, memory, observability, security, service, skills,
81};
82use vw_shared::task::{self, SubTask, Task, TaskExecutorBackend, TaskStatus};
83
84use config::Config;
85use config::schema::ChannelsConfigExt;
86use config::schema::ConfigExt;
87
88#[path = "cli.rs"]
89mod cli;
90#[cfg(test)]
91#[path = "cli_tests.rs"]
92mod cli_tests;
93mod handlers;
94
95pub(crate) mod session {
96 pub(crate) use vw_shared::session::ui_types;
97}
98
99pub(crate) mod app {
100 pub(crate) mod agent {
101 pub(crate) use vw_agent::{
102 approval, channels, config, id, memory, observability, project, providers, runtime,
103 security, shell, skills, tools,
104 };
105
106 pub(crate) mod session {
107 pub(crate) use vw_agent::session::{processor, session, title};
108 }
109
110 #[allow(clippy::module_inception)]
111 pub(crate) mod agent {
112 pub(crate) mod loop_ {
113 pub(crate) use vw_agent::agent::loop_::{context, core, instructions, progress};
114
115 pub(crate) mod cli {
116 pub(crate) use crate::cli::legacy_runtime::{
117 logo_text_lines, render_execution_indicator,
118 };
119 pub(crate) use crate::cli::legacy_runtime::{theme, transcript, tui_utils};
120 }
121 }
122 }
123 }
124}
125
126use cli::{Cli, Commands, ConfigCommands, DoctorCommands, TaskCommands};
127
128fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
153 let t: f64 = s.parse().map_err(|e| format!("{e}"))?;
154 if !(0.0..=2.0).contains(&t) {
155 return Err("temperature must be between 0.0 and 2.0".to_string());
156 }
157 Ok(t)
158}
159
160fn resolve_project_dir(project_dir: &str) -> Result<String> {
189 let trimmed = project_dir.trim();
190 if trimmed.is_empty() {
191 bail!("--project-dir cannot be empty");
192 }
193 let path = std::fs::canonicalize(trimmed)
194 .with_context(|| format!("Failed to resolve project directory: {trimmed}"))?;
195 if !path.is_dir() {
196 bail!("project directory is not a folder: {}", path.display());
197 }
198 Ok(path.to_string_lossy().to_string())
199}
200
201fn first_non_empty_line(content: &str) -> String {
221 content.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default().to_string()
222}
223
224fn print_task_json(task: &Task) -> Result<()> {
245 println!("{}", serde_json::to_string_pretty(task)?);
246 Ok(())
247}
248
249fn print_tasks_json(tasks: &[Task]) -> Result<()> {
270 println!("{}", serde_json::to_string_pretty(tasks)?);
271 Ok(())
272}
273
274#[allow(clippy::too_many_lines)]
318pub async fn run() -> Result<()> {
319 if let Err(e) = rustls::crypto::ring::default_provider().install_default() {
323 eprintln!("Warning: Failed to install default crypto provider: {e:?}");
324 }
325
326 let cli = Cli::parse();
328
329 if let Some(config_dir) = &cli.config_dir {
331 if config_dir.trim().is_empty() {
332 bail!("--config-dir cannot be empty");
333 }
334 unsafe {
337 std::env::set_var("VIBEWINDOW_CONFIG_DIR", config_dir);
338 }
339 }
340
341 if let Commands::Completions { shell } = &cli.command {
345 let mut stdout = std::io::stdout().lock();
346 cli::write_shell_completion(*shell, &mut stdout)?;
347 return Ok(());
348 }
349
350 let interactive_agent_mode = matches!(&cli.command, Commands::Agent { message: None, .. });
352
353 let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
357 if interactive_agent_mode {
358 let subscriber = fmt::Subscriber::builder()
360 .with_timer(tracing_subscriber::fmt::time::ChronoLocal::rfc_3339())
361 .with_env_filter(env_filter)
362 .with_writer(std::io::sink)
363 .finish();
364 tracing::subscriber::set_global_default(subscriber)
365 .expect("setting default subscriber failed");
366 } else {
367 let subscriber = fmt::Subscriber::builder()
369 .with_timer(tracing_subscriber::fmt::time::ChronoLocal::rfc_3339())
370 .with_env_filter(env_filter)
371 .finish();
372 tracing::subscriber::set_global_default(subscriber)
373 .expect("setting default subscriber failed");
374 }
375
376 let mut config = Box::pin(Config::load_or_init()).await?;
378 config.apply_env_overrides();
379
380 observability::runtime_trace::init_from_config(&config.observability, &config.workspace_dir);
382
383 if config.security.otp.enabled {
385 let config_dir =
386 config.config_path.parent().context("Config path must have a parent directory")?;
387 let store = security::SecretStore::new(config_dir, config.secrets.encrypt);
388 let (_validator, enrollment_uri) =
389 security::OtpValidator::from_config(&config.security.otp, config_dir, &store)?;
390 if let Some(uri) = enrollment_uri {
392 println!("Initialized OTP secret for VibeWindow.");
393 println!("Enrollment URI: {uri}");
394 }
395 }
396
397 match cli.command {
399 Commands::Completions { .. } => unreachable!(),
400
401 Commands::Agent {
403 message,
404 tui_mode,
405 provider,
406 model,
407 temperature,
408 peripheral,
409 autonomy_level,
410 max_actions_per_hour,
411 max_tool_iterations,
412 max_history_messages,
413 compact_context,
414 memory_backend,
415 } => {
416 if let Some(level) = autonomy_level {
418 config.autonomy.level = level;
419 }
420 if let Some(n) = max_actions_per_hour {
422 config.autonomy.max_actions_per_hour = n;
423 }
424 if let Some(n) = max_tool_iterations {
426 config.agent.max_tool_iterations = n;
427 }
428 if let Some(n) = max_history_messages {
430 config.agent.max_history_messages = n;
431 }
432 if compact_context {
434 config.agent.compact_context = true;
435 }
436 if let Some(ref backend) = memory_backend {
438 config.memory.backend = backend.clone();
439 }
440 Box::pin(crate::cli::run(
442 config,
443 message,
444 provider,
445 model,
446 temperature,
447 peripheral,
448 interactive_agent_mode,
449 tui_mode,
450 ))
451 .await
452 .map(|_| ())
453 }
454
455 Commands::Gateway { port, host, new_pairing } => {
457 if new_pairing {
459 let mut persisted_config = Box::pin(Config::load_or_init()).await?;
461 persisted_config.gateway.paired_tokens.clear();
462 persisted_config.save().await?;
463 config.gateway.paired_tokens.clear();
464 info!("🔐 Cleared paired tokens — a fresh pairing code will be generated");
465 }
466 let port = port.unwrap_or(config.gateway.port);
468 let host = host.unwrap_or_else(|| config.gateway.host.clone());
470 if port == 0 {
472 info!("🚀 Starting VibeWindow Gateway on {host} (random port)");
473 } else {
474 info!("🚀 Starting VibeWindow Gateway on {host}:{port}");
475 }
476 gateway::run_gateway(&host, port, config).await
478 }
479
480 Commands::Daemon { port, host } => {
482 let port = port.unwrap_or(config.gateway.port);
484 let host = host.unwrap_or_else(|| config.gateway.host.clone());
485 if port == 0 {
487 info!("💡 Starting VibeWindow Daemon on {host} (random port)");
488 } else {
489 info!("💡 Starting VibeWindow Daemon on {host}:{port}");
490 }
491 daemon::run(config, host, port).await
493 }
494
495 Commands::Status => {
497 println!("🦀 VibeWindow Status");
498 println!();
499 println!("Version: {}", env!("CARGO_PKG_VERSION"));
500 println!("Workspace: {}", config.workspace_dir.display());
501 println!("Config: {}", config.config_path.display());
502 println!();
503 println!(
504 "🤖 Provider: {}",
505 config.default_provider.as_deref().unwrap_or("openrouter")
506 );
507 println!(
508 " Model: {}",
509 config.default_model.as_deref().unwrap_or("(default)")
510 );
511 println!("📊 Observability: {}", config.observability.backend);
512 println!(
513 "🧾 Trace storage: {} ({})",
514 config.observability.runtime_trace_mode, config.observability.runtime_trace_path
515 );
516 println!("🛡️ Autonomy: {:?}", config.autonomy.level);
517 println!("⚙️ Runtime: {}", config.runtime.kind);
518 let effective_memory_backend = memory::effective_memory_backend_name(
520 &config.memory.backend,
521 Some(&config.storage.provider.config),
522 );
523 println!(
524 "💓 Heartbeat: {}",
525 if config.heartbeat.enabled {
526 format!("every {}min", config.heartbeat.interval_minutes)
527 } else {
528 "disabled".into()
529 }
530 );
531 println!(
532 "💡 Memory: {} (auto-save: {})",
533 effective_memory_backend,
534 if config.memory.auto_save { "on" } else { "off" }
535 );
536
537 println!();
538 println!("Security:");
539 println!(" Workspace only: {}", config.autonomy.workspace_only);
540 println!(
541 " Allowed roots: {}",
542 if config.autonomy.allowed_roots.is_empty() {
543 "(none)".to_string()
544 } else {
545 config.autonomy.allowed_roots.join(", ")
546 }
547 );
548 println!(" Allowed commands: {}", config.autonomy.allowed_commands.join(", "));
549 println!(" Max actions/hour: {}", config.autonomy.max_actions_per_hour);
550 println!(
551 " Max cost/day: ${:.2}",
552 f64::from(config.autonomy.max_cost_per_day_cents) / 100.0
553 );
554 println!(" OTP enabled: {}", config.security.otp.enabled);
555 println!(" E-stop enabled: {}", config.security.estop.enabled);
556 println!();
557 println!("Channels:");
558 println!(" CLI: ✅ always");
559 for (channel, configured) in config.channels_config.channels() {
561 println!(
562 " {:9} {}",
563 channel.name(),
564 if configured { "✅ configured" } else { "❌ not configured" }
565 );
566 }
567
568 Ok(())
569 }
570
571 Commands::Estop { estop_command, level, domains, tools } => {
579 handlers::estop::handle_estop_command(&config, estop_command, level, domains, tools)
580 }
581
582 Commands::Security { security_command } => {
584 handlers::security::handle_security_command(&config, security_command).await
585 }
586
587 Commands::Cron { cron_command } => cron::handle_command(cron_command, &config),
589
590 Commands::Providers => {
592 let provider_map = provider::list().await;
594 let mut providers = provider_map.values().collect::<Vec<_>>();
595 providers.sort_by(|a, b| a.id.cmp(&b.id));
597 let current = config
599 .default_provider
600 .as_deref()
601 .unwrap_or("openrouter")
602 .trim()
603 .to_ascii_lowercase();
604 println!("Supported providers ({} total):\n", providers.len());
605 println!(" ID (use in config) DESCRIPTION");
606 println!(" ─────────────────── ───────────");
607 for p in &providers {
609 let is_active = p.id.eq_ignore_ascii_case(¤t);
610 let marker = if is_active { " (active)" } else { "" };
611 println!(" {:<19} {}{}", p.id, p.name, marker);
612 }
613 println!("\n custom:<URL> Any OpenAI-compatible endpoint");
615 println!(" anthropic-custom:<URL> Any Anthropic-compatible endpoint");
616 Ok(())
617 }
618
619 Commands::Service { service_command, service_init } => {
621 let init_system = service_init.parse()?;
623 service::handle_command(&service_command, &config, init_system)
624 }
625
626 Commands::Doctor { doctor_command } => match doctor_command {
628 Some(DoctorCommands::Models { provider, use_cache }) => {
630 doctor::run_models(&config, provider.as_deref(), use_cache).await
631 }
632 Some(DoctorCommands::Traces { id, event, contains, limit }) => doctor::run_traces(
634 &config,
635 id.as_deref(),
636 event.as_deref(),
637 contains.as_deref(),
638 limit,
639 ),
640 None => doctor::run(&config),
642 },
643
644 Commands::Channel { channel_command } => match channel_command {
646 ChannelCommands::Start => Box::pin(channels::start_channels(config)).await,
648 ChannelCommands::Doctor => channels::doctor_channels(config).await,
650 other => channels::handle_command(other, &config).await,
652 },
653
654 Commands::Integrations { integration_command } => {
656 integrations::handle_command(integration_command, &config)
657 }
658
659 Commands::Skills { skill_command } => skills::handle_command(skill_command, &config),
661
662 Commands::Task { project_dir, task_command } => {
664 let project_path = resolve_project_dir(&project_dir)?;
666 match task_command {
667 TaskCommands::Create {
669 priority,
670 prompt,
671 description,
672 assignee,
673 model,
674 executor,
675 subtasks,
676 } => {
677 let prompt = prompt.map(|value| value.trim().to_string());
679 let description = description.map(|value| value.trim().to_string());
680 let cleaned_subtasks = subtasks
681 .into_iter()
682 .map(|s| s.trim().to_string())
683 .filter(|s| !s.is_empty())
684 .collect::<Vec<_>>();
685
686 let mut task_seed = first_non_empty_line(prompt.as_deref().unwrap_or_default());
688 if task_seed.is_empty() {
689 task_seed =
690 first_non_empty_line(description.as_deref().unwrap_or_default());
691 }
692 if task_seed.is_empty() {
693 task_seed = cleaned_subtasks.first().cloned().unwrap_or_default();
694 }
695 if task_seed.is_empty() {
697 bail!(
698 "task content is empty; provide at least one of --prompt, --description, or --subtask"
699 );
700 }
701
702 let mut task = Task::new(priority);
704
705 if let Some(description) = description {
706 task.description = description;
707 }
708 if let Some(assignee) = assignee {
709 let assignee = assignee.trim();
710 if !assignee.is_empty() {
711 task.assignee = assignee.to_string();
712 }
713 }
714 if let Some(model) = model {
715 let model = model.trim();
716 if !model.is_empty() {
717 task.model = model.to_string();
718 }
719 }
720 if let Some(executor) = executor {
722 let parsed = TaskExecutorBackend::from_id(executor.trim())
723 .with_context(|| {
724 format!(
725 "Invalid --executor value: {} (supported: internal, opencode, claude, codex)",
726 executor
727 )
728 })?;
729 task.executor = parsed;
730 }
731 task.subtasks = cleaned_subtasks.into_iter().map(SubTask::new).collect();
733
734 match prompt {
736 Some(prompt) if !prompt.is_empty() => {
737 task.prompt = prompt;
738 }
739 _ => {
740 task.prompt = first_non_empty_line(&task.description);
741 }
742 }
743 if task.prompt.is_empty()
745 && let Some(subtask) = task.subtasks.first()
746 {
747 task.prompt = subtask.content.clone();
748 }
749
750 let created = task::create_task(&project_path, task).with_context(|| {
752 format!("Failed to create task in {}", project_path.as_str())
753 })?;
754 print_task_json(&created)
755 }
756 TaskCommands::Read { id, status, include_archived, include_deleted, limit } => {
758 if let Some(task_id) = id {
760 let task =
761 task::load_task(&project_path, task_id.trim()).with_context(|| {
762 format!(
763 "Task not found in {} with id {}",
764 project_path.as_str(),
765 task_id
766 )
767 })?;
768 return print_task_json(&task);
769 }
770
771 let status_filter = status
773 .as_deref()
774 .map(str::trim)
775 .filter(|value| !value.is_empty())
776 .map(|value| {
777 TaskStatus::parse_key(value)
778 .with_context(|| format!("Invalid --status value: {value}"))
779 })
780 .transpose()?;
781
782 let mut tasks = task::load_all_tasks(&project_path);
784 tasks.retain(|task| {
785 let status_ok = status_filter.is_none_or(|s| task.status == s);
786 let archived_ok = include_archived || !task.archived;
787 let deleted_ok = include_deleted || !task.deleted;
788 status_ok && archived_ok && deleted_ok
789 });
790 tasks.sort_by(|a, b| {
792 b.created_at_ms
793 .cmp(&a.created_at_ms)
794 .then_with(|| a.priority.cmp(&b.priority))
795 });
796 if tasks.len() > limit {
798 tasks.truncate(limit);
799 }
800 print_tasks_json(&tasks)
801 }
802 }
803 }
804
805 Commands::Memory { memory_command } => {
812 memory::cli::handle_command(memory_command, &config).await
813 }
814
815 Commands::Config { config_command } => match config_command {
820 ConfigCommands::Schema => {
822 let schema = schemars::schema_for!(config::Config);
823 println!(
824 "{}",
825 serde_json::to_string_pretty(&schema).expect("failed to serialize JSON Schema")
826 );
827 Ok(())
828 }
829 },
830 }
831}
832#[cfg(test)]
833#[path = "tests.rs"]
834mod tests;