cli: configure target defaults

This commit is contained in:
Keisuke Hirata 2026-07-29 18:26:22 +09:00
parent 8ec940f624
commit 297c56c72d
No known key found for this signature in database
5 changed files with 220 additions and 218 deletions

View File

@ -306,7 +306,7 @@ impl WorkspaceBackendConfigFile {
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)), Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!( Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi workspace init --workspace {}` first", "workspace backend local config `{}` does not exist; run `yoi-workspace-server init --workspace {}` first",
path.display(), path.display(),
workspace_root.display() workspace_root.display()
))), ))),

View File

@ -46,7 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path), Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => { Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!( Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi workspace init --workspace {}` first", "workspace is not initialized at {}; run `yoi-workspace-server init --workspace {}` first",
workspace_root.as_ref().display(), workspace_root.as_ref().display(),
workspace_root.as_ref().display() workspace_root.as_ref().display()
))) )))

View File

@ -1,7 +1,7 @@
use client::{BackendTarget, LocalTarget, Target, TargetKind}; use client::{BackendTarget, LocalTarget, Target, TargetKind};
use serde::Deserialize; use serde::Deserialize;
use super::{ParseError, resolve_backend_url}; use super::{ParseError, read_client_default_connection, resolve_backend_url};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CliConnectionRequirement { pub(crate) enum CliConnectionRequirement {
@ -26,7 +26,6 @@ pub(crate) enum CliCommand {
Mcp, Mcp,
MemoryLint, MemoryLint,
Session, Session,
WorkspaceServer,
Login, Login,
} }
@ -47,7 +46,6 @@ impl CliCommand {
CliCommand::Mcp => "yoi mcp", CliCommand::Mcp => "yoi mcp",
CliCommand::MemoryLint => "yoi memory lint", CliCommand::MemoryLint => "yoi memory lint",
CliCommand::Session => "yoi session", CliCommand::Session => "yoi session",
CliCommand::WorkspaceServer => "yoi workspace/server",
CliCommand::Login => "yoi login", CliCommand::Login => "yoi login",
} }
} }
@ -68,15 +66,13 @@ impl CliCommand {
| CliCommand::Plugin | CliCommand::Plugin
| CliCommand::Mcp | CliCommand::Mcp
| CliCommand::MemoryLint | CliCommand::MemoryLint
| CliCommand::Session | CliCommand::Session => CliConnectionRequirement::LocalOnly,
| CliCommand::WorkspaceServer => CliConnectionRequirement::LocalOnly,
} }
} }
} }
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
#[allow(dead_code)]
pub(crate) enum ClientDefaultConnection { pub(crate) enum ClientDefaultConnection {
Local, Local,
Backend, Backend,
@ -90,7 +86,10 @@ impl Default for ClientDefaultConnection {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CliConnectionInput<'a> { pub(crate) enum CliConnectionInput<'a> {
LocalDefault, DefaultTarget {
workspace_id: Option<&'a str>,
},
LocalTarget,
BackendTarget { BackendTarget {
explicit_backend_url: Option<String>, explicit_backend_url: Option<String>,
workspace_id: Option<&'a str>, workspace_id: Option<&'a str>,
@ -115,21 +114,29 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
input: CliConnectionInput<'_>, input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
match (command.connection_requirement(), input) { match (command.connection_requirement(), input) {
(CliConnectionRequirement::LocalOnly, CliConnectionInput::LocalDefault) => { (
Ok(Box::new(LocalTarget::new())) CliConnectionRequirement::LocalOnly,
} CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget,
) => Ok(Box::new(LocalTarget::new())),
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => { (CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
Err(ParseError(format!( Err(ParseError(format!(
"{} uses a local connection target and cannot accept Backend target options", "{} uses a local connection target and cannot accept Backend target options",
command.display_name() command.display_name()
))) )))
} }
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalDefault) => { (CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalTarget) => {
Err(ParseError(format!( Err(ParseError(format!(
"{} requires a Backend connection target", "{} requires a Backend connection target",
command.display_name() command.display_name()
))) )))
} }
(
CliConnectionRequirement::BackendOnly,
CliConnectionInput::DefaultTarget { workspace_id },
) => Ok(Box::new(BackendTarget::new(
resolve_backend_url(None, workspace_id)?,
workspace_id.map(str::to_string),
))),
( (
CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware, CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware,
CliConnectionInput::BackendTarget { CliConnectionInput::BackendTarget {
@ -140,9 +147,19 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
resolve_backend_url(explicit_backend_url, workspace_id)?, resolve_backend_url(explicit_backend_url, workspace_id)?,
workspace_id.map(str::to_string), workspace_id.map(str::to_string),
))), ))),
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalDefault) => { (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
Ok(Box::new(LocalTarget::new())) Ok(Box::new(LocalTarget::new()))
} }
(
CliConnectionRequirement::ConnectionAware,
CliConnectionInput::DefaultTarget { workspace_id },
) => match read_client_default_connection()? {
ClientDefaultConnection::Local => Ok(Box::new(LocalTarget::new())),
ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new(
resolve_backend_url(None, workspace_id)?,
workspace_id.map(str::to_string),
))),
},
} }
} }
} }
@ -151,7 +168,7 @@ pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R, resolver: &R,
command: CliCommand, command: CliCommand,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
let target = resolver.resolve_connection(command, CliConnectionInput::LocalDefault)?; let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?;
match target.kind() { match target.kind() {
TargetKind::Local => Ok(target), TargetKind::Local => Ok(target),
TargetKind::Backend => Err(ParseError(format!( TargetKind::Backend => Err(ParseError(format!(
@ -196,7 +213,7 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
)); ));
} }
if explicit_local { if explicit_local {
return resolver.resolve_connection(command, CliConnectionInput::LocalDefault); return resolver.resolve_connection(command, CliConnectionInput::LocalTarget);
} }
if explicit_backend_url.is_some() { if explicit_backend_url.is_some() {
return resolver.resolve_connection( return resolver.resolve_connection(
@ -207,7 +224,7 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
}, },
); );
} }
resolver.resolve_connection(command, CliConnectionInput::LocalDefault) resolver.resolve_connection(command, CliConnectionInput::DefaultTarget { workspace_id })
} }
pub(crate) fn backend_target_option_error_for_local_command( pub(crate) fn backend_target_option_error_for_local_command(
@ -266,7 +283,6 @@ mod tests {
CliCommand::Mcp, CliCommand::Mcp,
CliCommand::MemoryLint, CliCommand::MemoryLint,
CliCommand::Session, CliCommand::Session,
CliCommand::WorkspaceServer,
] { ] {
assert_eq!( assert_eq!(
command.connection_requirement(), command.connection_requirement(),
@ -297,10 +313,10 @@ mod tests {
} }
#[test] #[test]
fn cli_connection_resolver_rejects_backend_only_local_default() { fn cli_connection_resolver_rejects_backend_only_local_target() {
let resolver = ClientConfigCliConnectionResolver; let resolver = ClientConfigCliConnectionResolver;
let err = resolver let err = resolver
.resolve_connection(CliCommand::Login, CliConnectionInput::LocalDefault) .resolve_connection(CliCommand::Login, CliConnectionInput::LocalTarget)
.unwrap_err(); .unwrap_err();
assert_eq!( assert_eq!(

View File

@ -8,11 +8,10 @@ mod ticket_cli;
mod worker_cleanup_cli; mod worker_cleanup_cli;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode}; use std::process::ExitCode;
use std::time::Duration; use std::time::Duration;
use cli_connection::{ use cli_connection::{
@ -39,11 +38,6 @@ enum Mode {
Session(session_cli::SessionCli), Session(session_cli::SessionCli),
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli), WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
Ticket(ticket_cli::TicketCli), Ticket(ticket_cli::TicketCli),
WorkspaceHelp,
WorkspaceServer {
subcommand: String,
args: Vec<String>,
},
Login { Login {
backend_url: String, backend_url: String,
no_wait: bool, no_wait: bool,
@ -93,11 +87,6 @@ async fn main() -> ExitCode {
print_memory_lint_help(); print_memory_lint_help();
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Mode::WorkspaceHelp => {
print_workspace_help();
ExitCode::SUCCESS
}
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
Mode::Login { Mode::Login {
backend_url, backend_url,
no_wait, no_wait,
@ -332,7 +321,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
&target_selection, &target_selection,
&workspace_root, &workspace_root,
)?; )?;
let mode = if target_selection.explicit_backend() { let mode = if target.kind() == client::TargetKind::Backend {
LaunchMode::Workers { LaunchMode::Workers {
runtime_id: None, runtime_id: None,
include_stopped: false, include_stopped: false,
@ -390,16 +379,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
let plugin_cli = parse_plugin_args(&args[1..])?; let plugin_cli = parse_plugin_args(&args[1..])?;
return Ok(Mode::Plugin(plugin_cli)); 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" => { "login" => {
return parse_login_args(&args[1..], connection_resolver); return parse_login_args(&args[1..], connection_resolver);
} }
@ -937,28 +916,65 @@ struct WorkspaceIdentityFile {
id: String, id: String,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default)]
struct ClientConfigFile { struct ClientConfigFile {
default_backend: Option<String>, default_backend: Option<String>,
// 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, default_connection: ClientDefaultConnection,
#[serde(default)]
backends: BTreeMap<String, ClientBackendConfig>, backends: BTreeMap<String, ClientBackendConfig>,
#[serde(default)]
workspaces: BTreeMap<String, ClientWorkspaceConfig>, workspaces: BTreeMap<String, ClientWorkspaceConfig>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ClientBackendConfig { struct ClientConfigOverlay {
url: String, default_backend: Option<String>,
default_connection: Option<ClientDefaultConnection>,
#[serde(default)]
backends: BTreeMap<String, ClientBackendConfigOverlay>,
#[serde(default)]
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default)]
struct ClientBackendConfig {
url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ClientBackendConfigOverlay {
url: Option<String>,
}
#[derive(Debug, Default)]
struct ClientWorkspaceConfig { struct ClientWorkspaceConfig {
backend: String, backend: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ClientWorkspaceConfigOverlay {
backend: Option<String>,
}
impl ClientConfigFile {
fn apply_overlay(&mut self, overlay: ClientConfigOverlay) {
if let Some(default_backend) = overlay.default_backend {
self.default_backend = Some(default_backend);
}
if let Some(default_connection) = overlay.default_connection {
self.default_connection = default_connection;
}
for (name, backend) in overlay.backends {
let entry = self.backends.entry(name).or_default();
if let Some(url) = backend.url {
entry.url = Some(url);
}
}
for (id, workspace) in overlay.workspaces {
let entry = self.workspaces.entry(id).or_default();
if let Some(backend) = workspace.backend {
entry.backend = Some(backend);
}
}
}
} }
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> { fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
@ -1004,7 +1020,7 @@ fn resolve_backend_url(
config config
.workspaces .workspaces
.get(id) .get(id)
.map(|workspace| workspace.backend.as_str()) .and_then(|workspace| workspace.backend.as_deref())
}) })
.or(config.default_backend.as_deref()) .or(config.default_backend.as_deref())
.ok_or_else(|| ParseError(client_config_missing_message(workspace_id)))?; .ok_or_else(|| ParseError(client_config_missing_message(workspace_id)))?;
@ -1013,7 +1029,11 @@ fn resolve_backend_url(
"client config references backend `{backend_name}`, but [backends.{backend_name}] is not defined" "client config references backend `{backend_name}`, but [backends.{backend_name}] is not defined"
)) ))
})?; })?;
let url = backend.url.trim(); let Some(url) = backend.url.as_deref().map(str::trim) else {
return Err(ParseError(format!(
"client config backend `{backend_name}` must contain a url"
)));
};
if url.is_empty() { if url.is_empty() {
return Err(ParseError(format!( return Err(ParseError(format!(
"client config backend `{backend_name}` must contain a non-empty url" "client config backend `{backend_name}` must contain a non-empty url"
@ -1022,30 +1042,67 @@ fn resolve_backend_url(
Ok(url.to_string()) Ok(url.to_string())
} }
fn read_client_default_connection() -> Result<ClientDefaultConnection, ParseError> {
Ok(read_client_config()?
.map(|config| config.default_connection)
.unwrap_or_default())
}
fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> { fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
let Some(path) = client_config_path() else { let mut config = ClientConfigFile::default();
return Ok(None); let mut found = false;
};
if let Some(path) = client_global_config_path() {
if let Some(overlay) = read_client_config_overlay(&path)? {
config.apply_overlay(overlay);
found = true;
}
}
let cwd_path = client_cwd_config_path()?;
if let Some(overlay) = read_client_config_overlay(&cwd_path)? {
config.apply_overlay(overlay);
found = true;
}
Ok(found.then_some(config))
}
fn read_client_config_overlay(path: &Path) -> Result<Option<ClientConfigOverlay>, ParseError> {
if !path.is_file() { if !path.is_file() {
return Ok(None); return Ok(None);
} }
let contents = fs::read_to_string(&path) let contents = fs::read_to_string(path)
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?; .map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
toml::from_str::<ClientConfigFile>(&contents) toml::from_str::<ClientConfigOverlay>(&contents)
.map(Some) .map(Some)
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display()))) .map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))
} }
fn client_config_path() -> Option<PathBuf> { fn client_global_config_path() -> Option<PathBuf> {
yoi_config_dir().map(|dir| dir.join("client.toml")) manifest::paths::data_dir().map(|dir| dir.join("client").join("config.toml"))
}
fn client_cwd_config_path() -> Result<PathBuf, ParseError> {
Ok(current_dir()?.join(".yoi").join("client.config.toml"))
}
fn client_config_location_message() -> String {
match client_global_config_path() {
Some(path) => format!("{} or <cwd>/.yoi/client.config.toml", path.display()),
None => "<data_dir>/client/config.toml or <cwd>/.yoi/client.config.toml".to_string(),
}
} }
fn client_config_missing_message(workspace_id: Option<&str>) -> String { fn client_config_missing_message(workspace_id: Option<&str>) -> String {
let locations = client_config_location_message();
match workspace_id { match workspace_id {
Some(workspace_id) => format!( Some(workspace_id) => format!(
"Backend URL is required. Pass --backend <URL> or configure $XDG_CONFIG_HOME/yoi/client.toml with [workspaces.{workspace_id}] backend = <name> and [backends.<name>].url" "Backend URL is required. Pass --backend <URL> or configure {locations} with [workspaces.{workspace_id}] backend = <name> and [backends.<name>].url"
),
None => format!(
"Backend URL is required. Pass --backend <URL> or configure default_backend in {locations}"
), ),
None => "Backend URL is required. Pass --backend <URL> or configure default_backend in $XDG_CONFIG_HOME/yoi/client.toml".to_string(),
} }
} }
@ -1189,94 +1246,6 @@ fn yoi_config_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi")) std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
} }
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
"yoi workspace requires `init`, `config`, `identity`, `trust-runtime`, or `serve` (try `yoi workspace --help`)"
.to_string(),
));
};
match subcommand.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServer {
subcommand: "init".to_string(),
args: rest.to_vec(),
})
}
"config" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServer {
subcommand: "config".to_string(),
args: rest.to_vec(),
})
}
"identity" | "trust-runtime" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServer {
subcommand: subcommand.clone(),
args: rest.to_vec(),
})
}
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServer {
subcommand: "serve".to_string(),
args: rest.to_vec(),
})
}
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
other => Err(ParseError(format!(
"unknown yoi workspace subcommand `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, or `serve`"
))),
}
}
fn run_workspace_server(subcommand: &str, args: Vec<String>) -> ExitCode {
let command = match resolve_workspace_server_command() {
Ok(command) => command,
Err(error) => {
eprintln!("yoi workspace: {error}");
return ExitCode::FAILURE;
}
};
let mut child = Command::new(&command);
child.arg(subcommand);
child.args(args);
match child.status() {
Ok(status) if status.success() => ExitCode::SUCCESS,
Ok(status) => ExitCode::from(status.code().unwrap_or(1).min(255) as u8),
Err(error) => {
eprintln!(
"yoi workspace: failed to launch `{}`: {error}",
command.to_string_lossy()
);
ExitCode::FAILURE
}
}
}
fn resolve_workspace_server_command() -> Result<OsString, ParseError> {
if let Some(value) = std::env::var_os("YOI_WORKSPACE_SERVER_COMMAND") {
if !value.is_empty() {
return Ok(value);
}
}
let current = std::env::current_exe()
.map_err(|error| ParseError(format!("failed to resolve current executable: {error}")))?;
let sibling = current.with_file_name("yoi-workspace-server");
Ok(sibling.into_os_string())
}
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> { fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(
@ -1622,20 +1591,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [TARGET_OPTIONS] [OPTIONS]\n yoi [TARGET_OPTIONS] resume [--workspace <PATH>] [--all] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS] "yoi\n\nUsage:\n yoi [TARGET_OPTIONS] [OPTIONS]\n yoi [TARGET_OPTIONS] resume [--workspace <PATH>] [--all] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
yoi workspace config <COMMAND> [OPTIONS]
yoi workspace identity <COMMAND> [OPTIONS]
yoi workspace trust-runtime <COMMAND> [OPTIONS]
yoi workspace serve [OPTIONS]
yoi server identity <COMMAND> [OPTIONS]
yoi server trust-runtime <COMMAND> [OPTIONS]
yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}
fn print_workspace_help() {
println!(
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace identity <COMMAND> [OPTIONS]\n yoi workspace trust-runtime <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi server identity <COMMAND> [OPTIONS]\n yoi server trust-runtime <COMMAND> [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` reads Workspace records from the Yoi server DB.\n\nSubcommands:\n init Initialize .yoi/workspace.toml, .yoi/workspace-backend.local.toml, and a server DB Workspace record\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n identity Initialize/show the Server signing identity\n trust-runtime Register/list/revoke trusted remote Runtime records\n serve Serve the Workspace recorded in the server DB\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
); );
} }
@ -1668,7 +1624,9 @@ mod tests {
input: CliConnectionInput<'_>, input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
match input { match input {
CliConnectionInput::LocalDefault => Ok(Box::new(LocalTarget::new())), CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget => {
Ok(Box::new(LocalTarget::new()))
}
CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new( CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new(
BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)), BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)),
)), )),
@ -1706,6 +1664,74 @@ mod tests {
assert_eq!(config.default_connection, ClientDefaultConnection::Local); assert_eq!(config.default_connection, ClientDefaultConnection::Local);
} }
#[test]
fn client_config_overlay_merges_property_wise() {
let mut config = ClientConfigFile::default();
config.apply_overlay(
toml::from_str(
r#"
default_backend = "global"
default_connection = "backend"
[backends.global]
url = "http://global.example"
[backends.shared]
url = "http://shared-global.example"
[workspaces.workspace-a]
backend = "global"
"#,
)
.unwrap(),
);
config.apply_overlay(
toml::from_str(
r#"
default_backend = "shared"
[backends.shared]
url = "http://shared-cwd.example"
[workspaces.workspace-b]
backend = "shared"
"#,
)
.unwrap(),
);
assert_eq!(config.default_connection, ClientDefaultConnection::Backend);
assert_eq!(config.default_backend.as_deref(), Some("shared"));
assert_eq!(
config
.backends
.get("global")
.and_then(|backend| backend.url.as_deref()),
Some("http://global.example")
);
assert_eq!(
config
.backends
.get("shared")
.and_then(|backend| backend.url.as_deref()),
Some("http://shared-cwd.example")
);
assert_eq!(
config
.workspaces
.get("workspace-a")
.and_then(|workspace| workspace.backend.as_deref()),
Some("global")
);
assert_eq!(
config
.workspaces
.get("workspace-b")
.and_then(|workspace| workspace.backend.as_deref()),
Some("shared")
);
}
#[test] #[test]
fn parse_local_only_commands_reject_backend_target_options() { fn parse_local_only_commands_reject_backend_target_options() {
let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err(); let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err();
@ -2009,55 +2035,15 @@ mod tests {
} }
#[test] #[test]
fn parse_workspace_serve_passthrough() { fn parse_workspace_command_is_removed_from_yoi_surface() {
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() { let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
Mode::WorkspaceServer { subcommand, args } => { assert_eq!(err.to_string(), "unknown command `workspace`");
assert_eq!(subcommand, "serve");
assert_eq!(args, vec!["--listen", "127.0.0.1:0"]);
}
other => panic!("unexpected mode: {other:?}"),
}
} }
#[test] #[test]
fn parse_workspace_serve_leaves_legacy_flags_to_server() { fn parse_server_command_is_removed_from_yoi_surface() {
match parse_args_from(["workspace", "serve", "--workspace", "/tmp/ws"]).unwrap() { let err = parse_args_from(["server", "identity", "show"]).unwrap_err();
Mode::WorkspaceServer { subcommand, args } => { assert_eq!(err.to_string(), "unknown command `server`");
assert_eq!(subcommand, "serve");
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}
#[test]
fn parse_workspace_init_passthrough() {
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {
Mode::WorkspaceServer { subcommand, args } => {
assert_eq!(subcommand, "init");
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}
#[test]
fn parse_workspace_config_passthrough() {
match parse_args_from(["workspace", "config", "diff", "--workspace", "/tmp/ws"]).unwrap() {
Mode::WorkspaceServer { subcommand, args } => {
assert_eq!(subcommand, "config");
assert_eq!(args, vec!["diff", "--workspace", "/tmp/ws"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}
#[test]
fn parse_workspace_help() {
assert!(matches!(
parse_args_from(["workspace", "--help"]).unwrap(),
Mode::WorkspaceHelp
));
} }
#[test] #[test]

View File

@ -1,14 +1,14 @@
# Workspace Backend local config template. # Workspace Backend local config template.
# #
# `yoi workspace init` copies this packaged template to # `yoi-workspace-server init` copies this packaged template to
# `.yoi/workspace-backend.local.toml` without overwriting an existing file. # `.yoi/workspace-backend.local.toml` without overwriting an existing file.
# The `.local` file is intentionally git-ignored. # The `.local` file is intentionally git-ignored.
# #
# Print the latest packaged template with: # Print the latest packaged template with:
# yoi workspace config default # yoi-workspace-server config default
# #
# Compare the local config with the latest packaged template with: # Compare the local config with the latest packaged template with:
# yoi workspace config diff # yoi-workspace-server config diff
# #
# Omit a key to use the built-in fallback. TOML has no `null`, so optional # Omit a key to use the built-in fallback. TOML has no `null`, so optional
# settings are represented by leaving the key commented out. # settings are represented by leaving the key commented out.