diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 9eeb164b..84ffa477 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -12,6 +12,7 @@ pub mod backend_auth; pub mod backend_runtime; pub mod runtime_command; pub mod spawn; +pub mod target; pub mod ticket_role; mod worker_client; @@ -26,6 +27,11 @@ pub use backend_runtime::{ BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_workers, }; pub use runtime_command::WorkerRuntimeCommand; +pub use target::{ + BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName, + WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume, + WorkerSpawn, +}; pub use spawn::{ SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions, diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs new file mode 100644 index 00000000..e8e97025 --- /dev/null +++ b/crates/client/src/target.rs @@ -0,0 +1,302 @@ +use std::fmt; + +use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TargetKind { + Local, + Backend, +} + +impl fmt::Display for TargetKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local => f.write_str("local"), + Self::Backend => f.write_str("Backend"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalTarget; + +impl LocalTarget { + pub fn new() -> Self { + Self + } + + fn runtime_command(&self) -> Result { + WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command) + } +} + +impl Default for LocalTarget { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendTarget { + pub base_url: String, + pub workspace_id: Option, +} + +impl BackendTarget { + pub fn new(base_url: impl Into, workspace_id: Option>) -> Self { + Self { + base_url: base_url.into(), + workspace_id: workspace_id.map(Into::into), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerListRequest { + pub runtime_id: Option, +} + +impl WorkerListRequest { + pub fn new(runtime_id: Option) -> Self { + Self { runtime_id } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerConnectionSelector { + pub runtime_id: String, + pub worker_id: String, +} + +impl WorkerConnectionSelector { + pub fn new(runtime_id: impl Into, worker_id: impl Into) -> Self { + Self { + runtime_id: runtime_id.into(), + worker_id: worker_id.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerSpawn { + pub runtime_command: WorkerRuntimeCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerByName { + pub runtime_command: WorkerRuntimeCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerResume { + pub runtime_command: WorkerRuntimeCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dashboard { + pub runtime_command: WorkerRuntimeCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerList { + pub target: BackendRuntimeListTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerConnection { + pub target: BackendRuntimeTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetError { + message: String, +} + +impl TargetError { + fn unsupported(operation: &'static str, target: TargetKind) -> Self { + Self { + message: format!("{operation} is not supported by {target} target"), + } + } + + fn local_runtime_command(error: std::io::Error) -> Self { + Self { + message: format!("failed to resolve local Worker runtime command: {error}"), + } + } +} + +impl fmt::Display for TargetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for TargetError {} + +pub trait Target: fmt::Debug + Send + Sync { + fn kind(&self) -> TargetKind; + + fn spawn_worker(&self) -> Result; + + fn worker_by_name(&self) -> Result; + + fn resume_worker(&self) -> Result; + + fn dashboard(&self) -> Result; + + fn list_workers(&self, request: WorkerListRequest) -> Result; + + fn connect_worker( + &self, + selector: WorkerConnectionSelector, + ) -> Result; +} + +impl Target for LocalTarget { + fn kind(&self) -> TargetKind { + TargetKind::Local + } + + fn spawn_worker(&self) -> Result { + Ok(WorkerSpawn { + runtime_command: self.runtime_command()?, + }) + } + + fn worker_by_name(&self) -> Result { + Ok(WorkerByName { + runtime_command: self.runtime_command()?, + }) + } + + fn resume_worker(&self) -> Result { + Ok(WorkerResume { + runtime_command: self.runtime_command()?, + }) + } + + fn dashboard(&self) -> Result { + Ok(Dashboard { + runtime_command: self.runtime_command()?, + }) + } + + fn list_workers(&self, _request: WorkerListRequest) -> Result { + Err(TargetError::unsupported( + "Backend runtime worker listing", + self.kind(), + )) + } + + fn connect_worker( + &self, + _selector: WorkerConnectionSelector, + ) -> Result { + Err(TargetError::unsupported( + "Backend runtime worker connection", + self.kind(), + )) + } +} + +impl Target for BackendTarget { + fn kind(&self) -> TargetKind { + TargetKind::Backend + } + + fn spawn_worker(&self) -> Result { + Err(TargetError::unsupported("Worker spawn", self.kind())) + } + + fn worker_by_name(&self) -> Result { + Err(TargetError::unsupported( + "Worker name attachment", + self.kind(), + )) + } + + fn resume_worker(&self) -> Result { + Err(TargetError::unsupported("Worker resume", self.kind())) + } + + fn dashboard(&self) -> Result { + Err(TargetError::unsupported("Dashboard", self.kind())) + } + + fn list_workers(&self, request: WorkerListRequest) -> Result { + Ok(WorkerList { + target: BackendRuntimeListTarget::new( + self.base_url.clone(), + self.workspace_id.clone(), + request.runtime_id, + ), + }) + } + + fn connect_worker( + &self, + selector: WorkerConnectionSelector, + ) -> Result { + Ok(WorkerConnection { + target: BackendRuntimeTarget::new( + self.base_url.clone(), + selector.runtime_id, + selector.worker_id, + ), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_target_builds_worker_list() { + let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); + let workers = target + .list_workers(WorkerListRequest::new(Some("runtime-a".to_string()))) + .unwrap(); + + assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a")); + assert_eq!(workers.target.runtime_id.as_deref(), Some("runtime-a")); + } + + #[test] + fn backend_target_builds_worker_connection() { + let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); + let connection = target + .connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) + .unwrap(); + + assert_eq!(connection.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(connection.target.runtime_id, "runtime-a"); + assert_eq!(connection.target.worker_id, "worker-b"); + } + + #[test] + fn backend_target_rejects_local_worker_operations() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + let err = target.spawn_worker().unwrap_err(); + + assert_eq!( + err.to_string(), + "Worker spawn is not supported by Backend target" + ); + } + + #[test] + fn local_target_rejects_backend_worker_operations() { + let target = LocalTarget::new(); + let err = target + .list_workers(WorkerListRequest::new(None)) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Backend runtime worker listing is not supported by local target" + ); + } +} diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 2f22e099..17fa1499 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -317,6 +317,9 @@ mod tests { worker_id: worker_id.to_string(), host_id: "host".to_string(), label: "label".to_string(), + display_name: "label".to_string(), + singleton_key: None, + tags: Vec::new(), role: None, profile: profile.map(str::to_string), workspace: BackendWorkerWorkspaceSummary { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index beb4f552..0b49a300 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -34,12 +34,12 @@ use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use session_store::SegmentId; -use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; +use client::{Target, WorkerConnectionSelector, WorkerListRequest}; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct LaunchOptions { + pub target: Box, pub mode: LaunchMode, - pub runtime_command: WorkerRuntimeCommand, pub workspace_root: PathBuf, } @@ -56,12 +56,15 @@ pub enum LaunchMode { worker_name: String, socket_override: Option, }, - /// `yoi workers` / `yoi --backend `: list Backend-authoritative runtime workers, - /// then attach to the selected Worker through the Backend Runtime API. - BackendRuntimePicker { target: BackendRuntimeListTarget }, - /// `yoi --backend --runtime-id --worker-id `: connect through the - /// Workspace Backend Runtime API and observe the Backend-proxied event stream. - BackendRuntime { target: BackendRuntimeTarget }, + /// `yoi workers` / `yoi --backend `: list workers through the selected + /// connection target, then attach to the selected Worker. + Workers { runtime_id: Option }, + /// `yoi --backend --runtime-id --worker-id `: open one Worker + /// through the selected connection target. + OpenWorker { + runtime_id: String, + worker_id: String, + }, /// `yoi resume`: open the Worker picker, then attach to the selected live Worker /// or restore the selected stopped Worker by name. Without `--all`, the picker /// is scoped to the current runtime workspace. @@ -78,8 +81,8 @@ pub enum LaunchMode { pub async fn launch(options: LaunchOptions) -> ExitCode { let LaunchOptions { + target, mode, - runtime_command, workspace_root, } = options; @@ -105,20 +108,55 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { LaunchMode::Spawn { worker_name, profile, - } => console::run_spawn(None, worker_name, profile, runtime_command).await, + } => match target.spawn_worker() { + Ok(spawn) => { + console::run_spawn(None, worker_name, profile, spawn.runtime_command).await + } + Err(e) => Err(Box::new(e) as Box), + }, LaunchMode::WorkerName { worker_name, socket_override, - } => console::run_worker_name(worker_name, socket_override, runtime_command).await, - LaunchMode::BackendRuntimePicker { target } => backend_worker_picker::run(target).await, - LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await, - LaunchMode::Resume { all } => { - console::run_resume(runtime_command, workspace_root.clone(), all).await + } => match target.worker_by_name() { + Ok(worker_by_name) => { + console::run_worker_name( + worker_name, + socket_override, + worker_by_name.runtime_command, + ) + .await + } + Err(e) => Err(Box::new(e) as Box), + }, + LaunchMode::Workers { runtime_id } => { + match target.list_workers(WorkerListRequest::new(runtime_id)) { + Ok(worker_list) => backend_worker_picker::run(worker_list.target).await, + Err(e) => Err(Box::new(e) as Box), + } } - LaunchMode::ResumeWithSession { id, worker_name } => { - console::run_spawn(Some(id), worker_name, None, runtime_command).await - } - LaunchMode::Panel => dashboard::launch(runtime_command).await, + LaunchMode::OpenWorker { + runtime_id, + worker_id, + } => match target.connect_worker(WorkerConnectionSelector::new(runtime_id, worker_id)) { + Ok(connection) => console::run_backend_runtime(connection.target).await, + Err(e) => Err(Box::new(e) as Box), + }, + LaunchMode::Resume { all } => match target.resume_worker() { + Ok(resume) => { + console::run_resume(resume.runtime_command, workspace_root.clone(), all).await + } + Err(e) => Err(Box::new(e) as Box), + }, + LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() { + Ok(spawn) => { + console::run_spawn(Some(id), worker_name, None, spawn.runtime_command).await + } + Err(e) => Err(Box::new(e) as Box), + }, + LaunchMode::Panel => match target.dashboard() { + Ok(dashboard) => dashboard::launch(dashboard.runtime_command).await, + Err(e) => Err(Box::new(e) as Box), + }, }; // Always restore the terminal first so any pending eprintln below diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs new file mode 100644 index 00000000..bf032745 --- /dev/null +++ b/crates/yoi/src/cli_connection.rs @@ -0,0 +1,299 @@ +use client::{BackendTarget, LocalTarget, Target, TargetKind}; +use serde::Deserialize; + +use super::{ParseError, resolve_backend_url}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliConnectionRequirement { + LocalOnly, + BackendOnly, + ConnectionAware, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliCommand { + DefaultTui, + Workers, + Resume, + Panel, + Keys, + SetupModel, + WorkerRuntime, + WorkerCleanup, + Ticket, + Objective, + Plugin, + Mcp, + MemoryLint, + Session, + WorkspaceServer, + Login, +} + +impl CliCommand { + pub(crate) fn display_name(self) -> &'static str { + match self { + CliCommand::DefaultTui => "yoi", + CliCommand::Workers => "yoi workers", + CliCommand::Resume => "yoi resume", + CliCommand::Panel => "yoi panel", + CliCommand::Keys => "yoi keys", + CliCommand::SetupModel => "yoi setup-model", + CliCommand::WorkerRuntime => "yoi worker", + CliCommand::WorkerCleanup => "yoi worker management", + CliCommand::Ticket => "yoi ticket", + CliCommand::Objective => "yoi objective", + CliCommand::Plugin => "yoi plugin", + CliCommand::Mcp => "yoi mcp", + CliCommand::MemoryLint => "yoi memory lint", + CliCommand::Session => "yoi session", + CliCommand::WorkspaceServer => "yoi workspace/server", + CliCommand::Login => "yoi login", + } + } + + pub(crate) fn connection_requirement(self) -> CliConnectionRequirement { + match self { + CliCommand::DefaultTui => CliConnectionRequirement::ConnectionAware, + CliCommand::Workers | CliCommand::Login => CliConnectionRequirement::BackendOnly, + CliCommand::Resume + | CliCommand::Panel + | CliCommand::Keys + | CliCommand::SetupModel + | CliCommand::WorkerRuntime + | CliCommand::WorkerCleanup + | CliCommand::Ticket + | CliCommand::Objective + | CliCommand::Plugin + | CliCommand::Mcp + | CliCommand::MemoryLint + | CliCommand::Session + | CliCommand::WorkspaceServer => CliConnectionRequirement::LocalOnly, + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[allow(dead_code)] +pub(crate) enum ClientDefaultConnection { + Local, + Backend, +} + +impl Default for ClientDefaultConnection { + fn default() -> Self { + Self::Local + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CliConnectionInput<'a> { + LocalDefault, + BackendTarget { + explicit_backend_url: Option, + workspace_id: Option<&'a str>, + }, +} + +pub(crate) trait CliConnectionResolver { + fn resolve_connection( + &self, + command: CliCommand, + input: CliConnectionInput<'_>, + ) -> Result, ParseError>; +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct ClientConfigCliConnectionResolver; + +impl CliConnectionResolver for ClientConfigCliConnectionResolver { + fn resolve_connection( + &self, + command: CliCommand, + input: CliConnectionInput<'_>, + ) -> Result, ParseError> { + match (command.connection_requirement(), input) { + (CliConnectionRequirement::LocalOnly, CliConnectionInput::LocalDefault) => { + Ok(Box::new(LocalTarget::new())) + } + (CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => { + Err(ParseError(format!( + "{} uses a local connection target and cannot accept Backend target options", + command.display_name() + ))) + } + (CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalDefault) => { + Err(ParseError(format!( + "{} requires a Backend connection target", + command.display_name() + ))) + } + ( + CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware, + CliConnectionInput::BackendTarget { + explicit_backend_url, + workspace_id, + }, + ) => Ok(Box::new(BackendTarget::new( + resolve_backend_url(explicit_backend_url, workspace_id)?, + workspace_id.map(str::to_string), + ))), + (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalDefault) => { + Ok(Box::new(LocalTarget::new())) + } + } + } +} + +pub(crate) fn resolve_local_cli_connection( + resolver: &R, + command: CliCommand, +) -> Result, ParseError> { + let target = resolver.resolve_connection(command, CliConnectionInput::LocalDefault)?; + match target.kind() { + TargetKind::Local => Ok(target), + TargetKind::Backend => Err(ParseError(format!( + "{} resolved a Backend target where a local target was required", + command.display_name() + ))), + } +} + +pub(crate) fn resolve_backend_cli_connection( + resolver: &R, + command: CliCommand, + explicit_backend_url: Option, + workspace_id: Option<&str>, +) -> Result, ParseError> { + let target = resolver.resolve_connection( + command, + CliConnectionInput::BackendTarget { + explicit_backend_url, + workspace_id, + }, + )?; + match target.kind() { + TargetKind::Backend => Ok(target), + TargetKind::Local => Err(ParseError(format!( + "{} resolved a local target where a Backend target was required", + command.display_name() + ))), + } +} + +pub(crate) fn backend_target_option_error_for_local_command( + command: CliCommand, + option: &str, +) -> ParseError { + ParseError(format!( + "{} uses a local connection target and cannot accept Backend target option `{option}`", + command.display_name() + )) +} + +pub(crate) fn is_backend_target_option(arg: &str) -> bool { + matches!( + arg, + "--backend" | "--workspace-id" | "--runtime-id" | "--runtime" | "--worker-id" + ) || arg.starts_with("--backend=") + || arg.starts_with("--workspace-id=") + || arg.starts_with("--runtime-id=") + || arg.starts_with("--runtime=") + || arg.starts_with("--worker-id=") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_command_connection_requirements_are_explicit() { + assert_eq!( + CliCommand::DefaultTui.connection_requirement(), + CliConnectionRequirement::ConnectionAware + ); + assert_eq!( + CliCommand::Workers.connection_requirement(), + CliConnectionRequirement::BackendOnly + ); + assert_eq!( + CliCommand::Login.connection_requirement(), + CliConnectionRequirement::BackendOnly + ); + + for command in [ + CliCommand::Resume, + CliCommand::Panel, + CliCommand::Keys, + CliCommand::SetupModel, + CliCommand::WorkerRuntime, + CliCommand::WorkerCleanup, + CliCommand::Ticket, + CliCommand::Objective, + CliCommand::Plugin, + CliCommand::Mcp, + CliCommand::MemoryLint, + CliCommand::Session, + CliCommand::WorkspaceServer, + ] { + assert_eq!( + command.connection_requirement(), + CliConnectionRequirement::LocalOnly, + "{} should be local-only", + command.display_name() + ); + } + } + + #[test] + fn cli_connection_resolver_rejects_local_only_backend_target() { + let resolver = ClientConfigCliConnectionResolver; + let err = resolver + .resolve_connection( + CliCommand::Resume, + CliConnectionInput::BackendTarget { + explicit_backend_url: Some("http://127.0.0.1:8787".to_string()), + workspace_id: None, + }, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "yoi resume uses a local connection target and cannot accept Backend target options" + ); + } + + #[test] + fn cli_connection_resolver_rejects_backend_only_local_default() { + let resolver = ClientConfigCliConnectionResolver; + let err = resolver + .resolve_connection(CliCommand::Workers, CliConnectionInput::LocalDefault) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "yoi workers requires a Backend connection target" + ); + } + + #[test] + fn cli_connection_resolver_selects_backend_for_connection_aware_target() { + let resolver = ClientConfigCliConnectionResolver; + let target = resolve_backend_cli_connection( + &resolver, + CliCommand::DefaultTui, + Some("http://127.0.0.1:8787".to_string()), + None, + ) + .unwrap(); + + assert_eq!(target.kind(), TargetKind::Backend); + let workers = target + .list_workers(client::WorkerListRequest::new(None)) + .unwrap(); + assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(workers.target.workspace_id, None); + } +} diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index a19f7b91..8fd7fe93 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1,3 +1,4 @@ +mod cli_connection; mod mcp_cli; mod memory_lint; mod objective_cli; @@ -14,10 +15,12 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; use std::time::Duration; -use client::{ - BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand, - start_device_login, wait_for_device_login, +use cli_connection::{ + CliCommand, CliConnectionResolver, ClientConfigCliConnectionResolver, ClientDefaultConnection, + backend_target_option_error_for_local_command, is_backend_target_option, + resolve_backend_cli_connection, resolve_local_cli_connection, }; +use client::{BackendAuthTarget, Target, start_device_login, wait_for_device_login}; use memory_lint::{LintCliOptions, LintStatus}; use serde::{Deserialize, Serialize}; use session_store::SegmentId; @@ -48,6 +51,7 @@ enum Mode { Keys, SetupModel, Tui { + target: Box, mode: LaunchMode, workspace_root: PathBuf, }, @@ -181,19 +185,13 @@ async fn main() -> ExitCode { Mode::Keys => tui::keys::launch().await, Mode::SetupModel => tui::setup_model::launch().await, Mode::Tui { + target, mode, workspace_root, } => { - let runtime_command = match WorkerRuntimeCommand::resolve() { - Ok(command) => command, - Err(e) => { - eprintln!("yoi: failed to resolve Worker runtime command: {e}"); - return ExitCode::FAILURE; - } - }; tui::launch(LaunchOptions { + target, mode, - runtime_command, workspace_root, }) .await @@ -215,8 +213,18 @@ where } fn parse_args_slice(args: &[String]) -> Result { + let resolver = ClientConfigCliConnectionResolver; + parse_args_slice_with_connection_resolver(args, &resolver) +} + +fn parse_args_slice_with_connection_resolver( + args: &[String], + connection_resolver: &R, +) -> Result { if args.is_empty() { + let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?; return Ok(Mode::Tui { + target, mode: LaunchMode::Spawn { worker_name: None, profile: None, @@ -227,69 +235,101 @@ fn parse_args_slice(args: &[String]) -> Result { match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), - "resume" => return parse_resume_args(&args[1..]), - "workers" => return parse_workers_args(&args[1..]), + "resume" => return parse_resume_args(&args[1..], connection_resolver), + "workers" => return parse_workers_args(&args[1..], connection_resolver), "worker" => { if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))? { + let _target = + resolve_local_cli_connection(connection_resolver, CliCommand::WorkerCleanup)?; return Ok(Mode::WorkerCleanup(cli)); } + let _target = + resolve_local_cli_connection(connection_resolver, CliCommand::WorkerRuntime)?; return Ok(Mode::WorkerRuntime(args[1..].to_vec())); } "objective" => { + let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Objective)?; let objective_cli = objective_cli::parse_objective_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Objective(objective_cli)); } "session" => { + let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?; let session_cli = session_cli::parse_session_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Session(session_cli)); } "ticket" => { + let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Ticket)?; let ticket_cli = ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Ticket(ticket_cli)); } "plugin" => { + let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?; let plugin_cli = parse_plugin_args(&args[1..])?; return Ok(Mode::Plugin(plugin_cli)); } "workspace" => { + let _target = + resolve_local_cli_connection(connection_resolver, CliCommand::WorkspaceServer)?; return parse_workspace_args(&args[1..]); } "server" => { + let _target = + resolve_local_cli_connection(connection_resolver, CliCommand::WorkspaceServer)?; return parse_workspace_args(&args[1..]); } "login" => { - return parse_login_args(&args[1..]); + return parse_login_args(&args[1..], connection_resolver); } "mcp" => { + let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Mcp)?; let mcp_cli = parse_mcp_args(&args[1..])?; return Ok(Mode::Mcp(mcp_cli)); } "panel" => { + let target = resolve_local_cli_connection(connection_resolver, CliCommand::Panel)?; return Ok(Mode::Tui { + target, mode: LaunchMode::Panel, workspace_root: parse_panel_workspace(&args[1..])?, }); } "keys" => { if args.len() != 1 { + if let Some(arg) = args[1..].iter().find(|arg| is_backend_target_option(arg)) { + return Err(backend_target_option_error_for_local_command( + CliCommand::Keys, + arg, + )); + } return Err(ParseError("yoi keys does not accept arguments".into())); } + let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Keys)?; return Ok(Mode::Keys); } "setup-model" => { if args.len() != 1 { + if let Some(arg) = args[1..].iter().find(|arg| is_backend_target_option(arg)) { + return Err(backend_target_option_error_for_local_command( + CliCommand::SetupModel, + arg, + )); + } return Err(ParseError( "yoi setup-model does not accept arguments".into(), )); } + let _target = + resolve_local_cli_connection(connection_resolver, CliCommand::SetupModel)?; return Ok(Mode::SetupModel); } "memory" if args.get(1).map(String::as_str) == Some("lint") => { + let _target = + resolve_local_cli_connection(connection_resolver, CliCommand::MemoryLint)?; let lint_args = &args[2..]; if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") { return Ok(Mode::MemoryLintHelp); @@ -309,10 +349,13 @@ fn parse_args_slice(args: &[String]) -> Result { _ => {} } - parse_console_options(args) + parse_console_options(args, connection_resolver) } -fn parse_console_options(args: &[String]) -> Result { +fn parse_console_options( + args: &[String], + connection_resolver: &R, +) -> Result { let mut workspace_root = current_dir()?; let mut session = None; let mut worker_name = None; @@ -544,25 +587,34 @@ fn parse_console_options(args: &[String]) -> Result { Some(workspace_id) => Some(workspace_id), None => resolve_workspace_id_from_root(&workspace_root)?, }; - let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; + let target = resolve_backend_cli_connection( + connection_resolver, + CliCommand::DefaultTui, + backend_url, + workspace_id.as_deref(), + )?; if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { return Ok(Mode::Tui { - mode: LaunchMode::BackendRuntime { - target: BackendRuntimeTarget::new(backend_url, runtime_id, worker_id), + target, + mode: LaunchMode::OpenWorker { + runtime_id, + worker_id, }, workspace_root, }); } return Ok(Mode::Tui { - mode: LaunchMode::BackendRuntimePicker { - target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id), - }, + target, + mode: LaunchMode::Workers { runtime_id }, workspace_root, }); } + let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?; + if let Some(profile) = profile { return Ok(Mode::Tui { + target, mode: LaunchMode::Spawn { worker_name, profile: Some(profile), @@ -572,12 +624,14 @@ fn parse_console_options(args: &[String]) -> Result { } if let Some(id) = session { return Ok(Mode::Tui { + target, mode: LaunchMode::ResumeWithSession { id, worker_name }, workspace_root, }); } if let Some(worker_name) = worker_name { return Ok(Mode::Tui { + target, mode: LaunchMode::WorkerName { worker_name, socket_override, @@ -586,6 +640,7 @@ fn parse_console_options(args: &[String]) -> Result { }); } Ok(Mode::Tui { + target, mode: LaunchMode::Spawn { worker_name: None, profile: None, @@ -594,7 +649,10 @@ fn parse_console_options(args: &[String]) -> Result { }) } -fn parse_workers_args(args: &[String]) -> Result { +fn parse_workers_args( + args: &[String], + connection_resolver: &R, +) -> Result { let mut workspace_root = current_dir()?; let mut workspace_id = None; let mut backend_url = None; @@ -704,16 +762,23 @@ fn parse_workers_args(args: &[String]) -> Result { Some(workspace_id) => Some(workspace_id), None => resolve_workspace_id_from_root(&workspace_root)?, }; - let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; + let target = resolve_backend_cli_connection( + connection_resolver, + CliCommand::Workers, + backend_url, + workspace_id.as_deref(), + )?; Ok(Mode::Tui { - mode: LaunchMode::BackendRuntimePicker { - target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id), - }, + target, + mode: LaunchMode::Workers { runtime_id }, workspace_root, }) } -fn parse_resume_args(args: &[String]) -> Result { +fn parse_resume_args( + args: &[String], + connection_resolver: &R, +) -> Result { let mut workspace_root = current_dir()?; let mut workspace_set = false; let mut all = false; @@ -755,6 +820,12 @@ fn parse_resume_args(args: &[String]) -> Result { i += 1; } arg if arg.starts_with('-') => { + if is_backend_target_option(arg) { + return Err(backend_target_option_error_for_local_command( + CliCommand::Resume, + arg, + )); + } return Err(ParseError(format!("unknown yoi resume option `{arg}`"))); } value => { @@ -771,7 +842,10 @@ fn parse_resume_args(args: &[String]) -> Result { )); } + let target = resolve_local_cli_connection(connection_resolver, CliCommand::Resume)?; + Ok(Mode::Tui { + target, mode: LaunchMode::Resume { all }, workspace_root, }) @@ -791,6 +865,11 @@ struct WorkspaceIdentityFile { #[derive(Debug, Default, Deserialize)] struct ClientConfigFile { default_backend: Option, + // Parsed as part of the target model; applying configured connection defaults is left to + // the later user-facing connection-mode work so current `yoi` default launch stays local. + #[allow(dead_code)] + #[serde(default)] + default_connection: ClientDefaultConnection, #[serde(default)] backends: BTreeMap, #[serde(default)] @@ -895,7 +974,10 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String { } } -fn parse_login_args(args: &[String]) -> Result { +fn parse_login_args( + args: &[String], + connection_resolver: &R, +) -> Result { if args.iter().any(|arg| arg == "--help" || arg == "-h") { return Err(ParseError( "yoi login usage: yoi login [--backend ] [--no-wait]".to_string(), @@ -935,6 +1017,12 @@ fn parse_login_args(args: &[String]) -> Result { } } } + let _target = resolve_backend_cli_connection( + connection_resolver, + CliCommand::Login, + backend_url.clone(), + None, + )?; let backend_url = resolve_backend_url(backend_url, None)?; Ok(Mode::Login { backend_url, @@ -1423,6 +1511,13 @@ fn mcp_usage() -> &'static str { } fn parse_panel_workspace(args: &[String]) -> Result { + if let Some(arg) = args.iter().find(|arg| is_backend_target_option(arg)) { + return Err(backend_target_option_error_for_local_command( + CliCommand::Panel, + arg, + )); + } + match args { [] => std::env::current_dir() .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))), @@ -1484,6 +1579,77 @@ fn print_memory_lint_help() { #[cfg(test)] mod tests { use super::*; + use crate::cli_connection::CliConnectionInput; + use client::{BackendTarget, LocalTarget, TargetKind, WorkerListRequest}; + + struct FixedCliConnectionResolver { + backend_url: &'static str, + } + + impl CliConnectionResolver for FixedCliConnectionResolver { + fn resolve_connection( + &self, + _command: CliCommand, + input: CliConnectionInput<'_>, + ) -> Result, ParseError> { + match input { + CliConnectionInput::LocalDefault => Ok(Box::new(LocalTarget::new())), + CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new( + BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)), + )), + } + } + } + + #[test] + fn parser_uses_injected_cli_connection_resolver_for_backend_command() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://fake-backend.example", + }; + let args = vec!["workers".to_string()]; + let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(); + + match mode { + Mode::Tui { + target, + mode: LaunchMode::Workers { runtime_id }, + .. + } => { + assert_eq!(runtime_id, None); + assert_eq!(target.kind(), TargetKind::Backend); + let workers = target.list_workers(WorkerListRequest::new(None)).unwrap(); + assert_eq!(workers.target.base_url, "http://fake-backend.example"); + } + other => panic!("expected Workers mode, got {other:?}"), + } + } + + #[test] + fn client_config_default_connection_defaults_to_local() { + let config = ClientConfigFile::default(); + assert_eq!(config.default_connection, ClientDefaultConnection::Local); + } + + #[test] + fn parse_local_only_commands_reject_backend_target_options() { + let err = parse_args_from(["resume", "--backend", "http://127.0.0.1:8787"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi resume uses a local connection target and cannot accept Backend target option `--backend`" + ); + + let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi panel uses a local connection target and cannot accept Backend target option `--runtime-id`" + ); + + let err = parse_args_from(["keys", "--workspace-id=workspace-a"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi keys uses a local connection target and cannot accept Backend target option `--workspace-id=workspace-a`" + ); + } #[test] fn parse_worker_name_mode() { @@ -1516,14 +1682,26 @@ mod tests { .unwrap() { Mode::Tui { - mode: LaunchMode::BackendRuntime { target }, + target, + mode: + LaunchMode::OpenWorker { + runtime_id, + worker_id, + }, .. } => { - assert_eq!(target.base_url, "http://127.0.0.1:8787"); - assert_eq!(target.runtime_id, "runtime-a"); - assert_eq!(target.worker_id, "worker-b"); + assert_eq!(target.kind(), TargetKind::Backend); + let connection = target + .connect_worker(client::WorkerConnectionSelector::new( + runtime_id.clone(), + worker_id.clone(), + )) + .unwrap(); + assert_eq!(connection.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(runtime_id, "runtime-a"); + assert_eq!(worker_id, "worker-b"); } - _ => panic!("expected BackendRuntime mode"), + _ => panic!("expected OpenWorker mode"), } } @@ -1542,13 +1720,19 @@ mod tests { match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap() { Mode::Tui { - mode: LaunchMode::BackendRuntimePicker { target }, + target, + mode: LaunchMode::Workers { runtime_id }, .. } => { - assert_eq!(target.base_url, "http://127.0.0.1:8787"); - assert_eq!(target.runtime_id.as_deref(), Some("r")); + assert_eq!(target.kind(), TargetKind::Backend); + let workers = target + .list_workers(WorkerListRequest::new(runtime_id.clone())) + .unwrap(); + assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(workers.target.runtime_id.as_deref(), Some("r")); + assert_eq!(runtime_id.as_deref(), Some("r")); } - _ => panic!("expected BackendRuntimePicker mode"), + _ => panic!("expected Workers mode"), } } @@ -1564,14 +1748,19 @@ mod tests { .unwrap() { Mode::Tui { - mode: LaunchMode::BackendRuntimePicker { target }, + target, + mode: LaunchMode::Workers { runtime_id }, .. } => { - assert_eq!(target.base_url, "http://127.0.0.1:8787"); - assert_eq!(target.workspace_id.as_deref(), Some("workspace-a")); - assert_eq!(target.runtime_id, None); + assert_eq!(target.kind(), TargetKind::Backend); + let workers = target + .list_workers(WorkerListRequest::new(runtime_id.clone())) + .unwrap(); + assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a")); + assert_eq!(runtime_id, None); } - _ => panic!("expected BackendRuntimePicker mode"), + _ => panic!("expected Workers mode"), } } @@ -1623,6 +1812,7 @@ mod tests { Mode::Tui { mode: LaunchMode::Resume { all }, workspace_root, + .. } => { assert!(!all); assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace")); @@ -1994,6 +2184,7 @@ mod tests { profile, }, workspace_root, + .. } => { assert_eq!(worker_name, Some("agent".to_string())); assert_eq!(profile, Some("project:companion".to_string())); @@ -2039,6 +2230,7 @@ mod tests { Mode::Tui { mode: LaunchMode::Panel, workspace_root, + .. } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")), _ => panic!("expected Panel mode"), }