From 22d974a72256ca28fa5590344c6c650abe823302 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 31 May 2026 22:07:52 +0900 Subject: [PATCH 1/2] cli: move product entrypoint to insomnia --- Cargo.lock | 16 +- Cargo.toml | 1 + crates/client/Cargo.toml | 1 - crates/client/src/lib.rs | 3 + .../lib.rs => client/src/runtime_command.rs} | 0 crates/client/src/spawn.rs | 9 +- crates/insomnia/Cargo.toml | 11 + crates/insomnia/src/main.rs | 551 ++++++++++++++++++ crates/{tui => insomnia}/src/memory_lint.rs | 0 crates/pod/Cargo.toml | 2 +- crates/pod/src/discovery.rs | 2 +- crates/pod/src/spawn/tool.rs | 10 +- crates/pod/tests/spawn_pod_test.rs | 2 +- crates/tui/Cargo.toml | 6 - crates/tui/src/{main.rs => lib.rs} | 487 +--------------- crates/tui/src/spawn.rs | 19 +- package.nix | 4 +- 17 files changed, 637 insertions(+), 487 deletions(-) rename crates/{insomnia/src/lib.rs => client/src/runtime_command.rs} (100%) create mode 100644 crates/insomnia/src/main.rs rename crates/{tui => insomnia}/src/memory_lint.rs (100%) rename crates/tui/src/{main.rs => lib.rs} (79%) diff --git a/Cargo.lock b/Cargo.lock index e84cc4e4..a15dc817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,7 +332,6 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "client" version = "0.1.0" dependencies = [ - "insomnia", "manifest", "protocol", "serde_json", @@ -1483,6 +1482,17 @@ dependencies = [ [[package]] name = "insomnia" version = "0.1.0" +dependencies = [ + "client", + "memory", + "pod", + "serde", + "serde_json", + "session-store", + "tempfile", + "tokio", + "tui", +] [[package]] name = "instability" @@ -2334,11 +2344,11 @@ dependencies = [ "async-trait", "chrono", "clap", + "client", "dotenv", "fs4", "futures", "include_dir", - "insomnia", "libc", "llm-worker", "manifest", @@ -3917,8 +3927,6 @@ dependencies = [ "crossterm 0.28.1", "llm-worker", "manifest", - "memory", - "pod", "pod-registry", "pod-store", "protocol", diff --git a/Cargo.toml b/Cargo.toml index ad7d4403..6717b04f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ provider = { path = "crates/provider" } session-metrics = { path = "crates/session-metrics" } session-store = { path = "crates/session-store" } tools = { path = "crates/tools" } +tui = { path = "crates/tui" } # External # Note: `reqwest` and `chrono` are not aggregated here because some crates diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 9e304ceb..7eccbe23 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true [dependencies] protocol = { workspace = true } manifest = { workspace = true } -insomnia = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } uuid = { workspace = true } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index af8820bc..0302ed15 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -9,7 +9,10 @@ //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 mod pod_client; +pub mod runtime_command; pub mod spawn; +pub use runtime_command::PodRuntimeCommand; + pub use pod_client::PodClient; pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod}; diff --git a/crates/insomnia/src/lib.rs b/crates/client/src/runtime_command.rs similarity index 100% rename from crates/insomnia/src/lib.rs rename to crates/client/src/runtime_command.rs diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs index 23ccb0ec..d20bfbf9 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; -use insomnia::PodRuntimeCommand; +use crate::PodRuntimeCommand; use tokio::process::Command; use uuid::Uuid; @@ -24,6 +24,7 @@ const READY_TIMEOUT: Duration = Duration::from_secs(20); /// `spawn_pod` の入力。 pub struct SpawnConfig { + pub runtime_command: PodRuntimeCommand, /// `pod.name` として使う識別子。runtime ディレクトリ /// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る /// 名前との突き合わせに使う。 @@ -100,17 +101,15 @@ pub async fn spawn_pod(config: SpawnConfig, mut progress: F) -> Result), + Tui(LaunchMode), +} + +#[derive(Debug)] +struct ParseError(String); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ParseError {} + +#[tokio::main] +async fn main() -> ExitCode { + let mode = match parse_args() { + Ok(mode) => mode, + Err(e) => { + eprintln!("insomnia: {e}"); + eprintln!("try `insomnia --help` for usage."); + return ExitCode::FAILURE; + } + }; + + match mode { + Mode::Help => { + print_help(); + ExitCode::SUCCESS + } + Mode::MemoryLintHelp => { + print_memory_lint_help(); + ExitCode::SUCCESS + } + Mode::MemoryLint(options) => match memory_lint::run(&options) { + Ok(LintStatus::Clean) => ExitCode::SUCCESS, + Ok(LintStatus::Failed) => ExitCode::FAILURE, + Err(e) => { + eprintln!("insomnia memory lint: {e}"); + ExitCode::FAILURE + } + }, + Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("insomnia pod", args).await, + Mode::Tui(mode) => { + let runtime_command = match PodRuntimeCommand::resolve() { + Ok(command) => command, + Err(e) => { + eprintln!("insomnia: failed to resolve Pod runtime command: {e}"); + return ExitCode::FAILURE; + } + }; + tui::launch(LaunchOptions { + mode, + runtime_command, + }) + .await + } + } +} + +fn parse_args() -> Result { + parse_args_from(std::env::args().skip(1)) +} + +fn parse_args_from(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let args = args.into_iter().map(Into::into).collect::>(); + parse_args_slice(&args) +} + +fn parse_args_slice(args: &[String]) -> Result { + if args.is_empty() { + return Ok(Mode::Tui(LaunchMode::Spawn { profile: None })); + } + + match args[0].as_str() { + "--help" | "-h" => return Ok(Mode::Help), + "pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())), + "memory" if args.get(1).map(String::as_str) == Some("lint") => { + let lint_args = &args[2..]; + if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Ok(Mode::MemoryLintHelp); + } + let options = + memory_lint::parse_lint_args(lint_args).map_err(|e| ParseError(e.to_string()))?; + return Ok(Mode::MemoryLint(options)); + } + "memory" => { + return Ok(Mode::Tui(LaunchMode::PodName { + pod_name: "memory".to_string(), + socket_override: None, + })); + } + _ => {} + } + + let mut resume = false; + let mut session = None; + let mut pod_name = None; + let mut socket_override = None; + let mut profile = None; + let mut multi = false; + let mut positional = None; + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "--resume" | "-r" => { + resume = true; + i += 1; + } + "--multi" => { + multi = true; + i += 1; + } + "--session" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--session requires a value".to_string()))?; + session = Some(parse_session_id(value)?); + i += 2; + } + "--pod" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--pod requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--pod requires a value".to_string())); + } + pod_name = Some(value.clone()); + i += 2; + } + "--socket" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--socket requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--socket requires a value".to_string())); + } + socket_override = Some(PathBuf::from(value)); + i += 2; + } + "--profile" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--profile requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--profile requires a value".to_string())); + } + profile = Some(value.clone()); + i += 2; + } + arg if arg.starts_with("--session=") => { + let value = arg.trim_start_matches("--session="); + if value.is_empty() { + return Err(ParseError("--session requires a value".to_string())); + } + session = Some(parse_session_id(value)?); + i += 1; + } + arg if arg.starts_with("--pod=") => { + let value = arg.trim_start_matches("--pod="); + if value.is_empty() { + return Err(ParseError("--pod requires a value".to_string())); + } + pod_name = Some(value.to_string()); + i += 1; + } + arg if arg.starts_with("--socket=") => { + let value = arg.trim_start_matches("--socket="); + if value.is_empty() { + return Err(ParseError("--socket requires a value".to_string())); + } + socket_override = Some(PathBuf::from(value)); + i += 1; + } + arg if arg.starts_with("--profile=") => { + let value = arg.trim_start_matches("--profile="); + if value.is_empty() { + return Err(ParseError("--profile requires a value".to_string())); + } + profile = Some(value.to_string()); + i += 1; + } + arg if arg.starts_with('-') => { + return Err(ParseError(format!("unknown argument: {arg}"))); + } + value => { + if positional.replace(value.to_string()).is_some() { + return Err(ParseError( + "only one positional Pod name is supported".to_string(), + )); + } + i += 1; + } + } + } + + if pod_name.is_some() && positional.is_some() { + return Err(ParseError( + "--pod and a positional Pod name are mutually exclusive".to_string(), + )); + } + let pod_name = pod_name.or(positional); + + if profile.is_some() + && (resume || session.is_some() || pod_name.is_some() || socket_override.is_some() || multi) + { + return Err(ParseError( + "--profile can only be used for fresh spawn".to_string(), + )); + } + if multi && resume { + return Err(ParseError( + "--multi and --resume are mutually exclusive".to_string(), + )); + } + if multi && session.is_some() { + return Err(ParseError( + "--multi and --session are mutually exclusive".to_string(), + )); + } + if multi && pod_name.is_some() { + return Err(ParseError( + "--multi cannot be used with a positional Pod name".to_string(), + )); + } + if multi && socket_override.is_some() { + return Err(ParseError( + "--multi and --socket are mutually exclusive".to_string(), + )); + } + if pod_name.is_some() && session.is_some() { + return Err(ParseError( + "--pod and --session are mutually exclusive".to_string(), + )); + } + if socket_override.is_some() && pod_name.is_none() { + return Err(ParseError( + "--socket requires --pod or a positional Pod name".to_string(), + )); + } + if resume && session.is_some() { + return Err(ParseError( + "--resume and --session are mutually exclusive".to_string(), + )); + } + + if multi { + return Ok(Mode::Tui(LaunchMode::Multi)); + } + if let Some(pod_name) = pod_name { + return Ok(Mode::Tui(LaunchMode::PodName { + pod_name, + socket_override, + })); + } + if resume { + return Ok(Mode::Tui(LaunchMode::Resume)); + } + if let Some(id) = session { + return Ok(Mode::Tui(LaunchMode::ResumeWithSession(id))); + } + + Ok(Mode::Tui(LaunchMode::Spawn { profile })) +} + +fn parse_session_id(value: &str) -> Result { + value + .parse() + .map_err(|_| ParseError(format!("invalid --session UUID: {value}"))) +} + +fn print_help() { + println!( + "insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Start a fresh Pod from a profile\n -h, --help Print help\n" + ); +} + +fn print_memory_lint_help() { + println!( + "insomnia memory lint\n\nUsage:\n insomnia memory lint [OPTIONS]\n\nOptions:\n --workspace Workspace root to lint (defaults to cwd)\n --json Emit a JSON report\n --warnings-as-errors Return failure when warnings are present\n -h, --help Print help\n" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pod_name_mode() { + match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() { + Mode::Tui(LaunchMode::PodName { + pod_name, + socket_override, + }) => { + assert_eq!(pod_name, "agent"); + assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock"))); + } + _ => panic!("expected PodName mode"), + } + } + + #[test] + fn parse_positional_name_uses_pod_name_mode() { + match parse_args_from(["agent"]).unwrap() { + Mode::Tui(LaunchMode::PodName { + pod_name, + socket_override, + }) => { + assert_eq!(pod_name, "agent"); + assert_eq!(socket_override, None); + } + _ => panic!("expected PodName mode"), + } + } + + #[test] + fn parse_memory_alone_remains_positional_pod_name() { + match parse_args_from(["memory"]).unwrap() { + Mode::Tui(LaunchMode::PodName { + pod_name, + socket_override, + }) => { + assert_eq!(pod_name, "memory"); + assert_eq!(socket_override, None); + } + _ => panic!("expected PodName mode"), + } + } + + #[test] + fn parse_pod_subcommand_uses_runtime_mode() { + match parse_args_from(["pod", "--pod", "agent", "--profile", "default"]).unwrap() { + Mode::PodRuntime(args) => assert_eq!(args, ["--pod", "agent", "--profile", "default"]), + _ => panic!("expected PodRuntime mode"), + } + } + + #[test] + fn parse_literal_pod_name_still_available_with_flag() { + match parse_args_from(["--pod", "pod"]).unwrap() { + Mode::Tui(LaunchMode::PodName { + pod_name, + socket_override, + }) => { + assert_eq!(pod_name, "pod"); + assert_eq!(socket_override, None); + } + _ => panic!("expected PodName mode"), + } + } + + #[test] + fn parse_memory_lint_mode() { + match parse_args_from([ + "memory", + "lint", + "--workspace", + "/tmp/ws", + "--json", + "--warnings-as-errors", + ]) + .unwrap() + { + Mode::MemoryLint(options) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(options.json); + assert!(options.warnings_as_errors); + } + _ => panic!("expected MemoryLint mode"), + } + } + + #[test] + fn parse_memory_lint_rejects_usage_errors() { + let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err(); + assert_eq!(err.to_string(), "--workspace requires a value"); + } + + #[test] + fn parse_memory_lint_workspace_equals() { + match parse_args_from(["memory", "lint", "--workspace=/tmp/ws"]).unwrap() { + Mode::MemoryLint(options) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(!options.json); + assert!(!options.warnings_as_errors); + } + _ => panic!("expected MemoryLint mode"), + } + } + + #[test] + fn memory_lint_with_other_second_word_remains_positional_pod_name() { + match parse_args_from(["memory", "other"]).unwrap() { + Mode::Tui(LaunchMode::PodName { pod_name, .. }) => assert_eq!(pod_name, "memory"), + _ => panic!("expected PodName mode"), + } + } + + #[test] + fn parse_rejects_pod_and_session() { + let segment_id = session_store::new_segment_id().to_string(); + let err = parse_args_from(["--pod", "agent", "--session", &segment_id]).unwrap_err(); + assert_eq!( + err.to_string(), + "--pod and --session are mutually exclusive" + ); + } + + #[test] + fn parse_profile_spawn_mode() { + match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() { + Mode::Tui(LaunchMode::Spawn { profile }) => { + assert_eq!(profile, Some("/profiles/coder.lua".to_string())); + } + _ => panic!("expected Spawn mode"), + } + } + + #[test] + fn parse_profile_rejects_resume_attach_modes() { + let segment_id = session_store::new_segment_id().to_string(); + let cases = [ + ( + vec![ + "--profile".to_string(), + "p.lua".to_string(), + "--resume".to_string(), + ], + "--profile can only be used for fresh spawn", + ), + ( + vec![ + "--profile".to_string(), + "p.lua".to_string(), + "--session".to_string(), + segment_id, + ], + "--profile can only be used for fresh spawn", + ), + ( + vec![ + "--profile".to_string(), + "p.lua".to_string(), + "--socket".to_string(), + "/tmp/insomnia/sock".to_string(), + ], + "--profile can only be used for fresh spawn", + ), + ( + vec![ + "--profile".to_string(), + "p.lua".to_string(), + "agent".to_string(), + ], + "--profile can only be used for fresh spawn", + ), + ]; + + for (args, message) in cases { + let err = parse_args_from(args).unwrap_err(); + assert_eq!(err.to_string(), message); + } + } + + #[test] + fn parse_multi_mode() { + match parse_args_from(["--multi"]).unwrap() { + Mode::Tui(LaunchMode::Multi) => {} + _ => panic!("expected Multi mode"), + } + } + + #[test] + fn parse_top_level_help() { + match parse_args_from(["--help"]).unwrap() { + Mode::Help => {} + _ => panic!("expected Help mode"), + } + } + + #[test] + fn parse_memory_lint_help() { + match parse_args_from(["memory", "lint", "--help"]).unwrap() { + Mode::MemoryLintHelp => {} + _ => panic!("expected MemoryLintHelp mode"), + } + } + + #[test] + fn parse_multi_conflicts_are_clear() { + let segment_id = session_store::new_segment_id().to_string(); + let cases = [ + ( + vec!["--multi".to_string(), "--resume".to_string()], + "--multi and --resume are mutually exclusive", + ), + ( + vec!["--multi".to_string(), "--session".to_string(), segment_id], + "--multi and --session are mutually exclusive", + ), + ( + vec![ + "--multi".to_string(), + "--pod".to_string(), + "agent".to_string(), + ], + "--multi cannot be used with a positional Pod name", + ), + ( + vec!["--multi".to_string(), "agent".to_string()], + "--multi cannot be used with a positional Pod name", + ), + ( + vec![ + "--multi".to_string(), + "--socket".to_string(), + "/tmp/a.sock".to_string(), + ], + "--multi and --socket are mutually exclusive", + ), + ]; + + for (args, message) in cases { + let err = parse_args_from(args).unwrap_err(); + assert_eq!(err.to_string(), message); + } + } +} diff --git a/crates/tui/src/memory_lint.rs b/crates/insomnia/src/memory_lint.rs similarity index 100% rename from crates/tui/src/memory_lint.rs rename to crates/insomnia/src/memory_lint.rs diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index 666038f1..f76ac484 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -14,7 +14,7 @@ pod-store = { workspace = true } manifest = { workspace = true } protocol = { workspace = true } provider = { workspace = true } -insomnia = { workspace = true } +client = { workspace = true } pod-registry = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/pod/src/discovery.rs b/crates/pod/src/discovery.rs index 7cda62dd..c0e6ae46 100644 --- a/crates/pod/src/discovery.rs +++ b/crates/pod/src/discovery.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use insomnia::PodRuntimeCommand; +use client::PodRuntimeCommand; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{Permission, ScopeRule}; use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 86b2e651..aaf85d59 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use insomnia::PodRuntimeCommand; +use client::PodRuntimeCommand; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial, @@ -1300,7 +1300,7 @@ return profile { assert!(invalid.contains("Use `default`, `inherit`")); assert!(invalid.contains("`project:coder`")); - let no_default = build_spawn_config_json_for_profile( + let default_config = build_spawn_config_json_for_profile( &parent, &available, &project, @@ -1309,10 +1309,8 @@ return profile { &scope, SpawnProfileSelector::Default, ) - .unwrap_err(); - assert!(no_default.contains("no default profile"), "{no_default}"); - assert!(no_default.contains("Use `default`, `inherit`")); - assert!(no_default.contains("`project:coder`")); + .unwrap(); + assert!(default_config.contains("\"name\":\"child\"")); let user_config = tmp.path().join("user-profiles.toml"); std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.lua\"\n").unwrap(); diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index 77334ed8..5a62917a 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; -use insomnia::PodRuntimeCommand; +use client::PodRuntimeCommand; use llm_worker::tool::{ToolError, ToolOutput}; use manifest::{ AuthRef, ModelManifest, Permission, PodManifest, PodManifestConfig, PodMetaConfig, SchemeKind, diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 5262dc3a..54063634 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -4,10 +4,6 @@ version = "0.1.0" edition.workspace = true license.workspace = true -[[bin]] -name = "insomnia" -path = "src/main.rs" - [dependencies] client = { workspace = true } protocol = { workspace = true } @@ -19,10 +15,8 @@ unicode-width = "0.2.2" uuid = { workspace = true } toml = { workspace = true } manifest = { workspace = true } -memory = { workspace = true } session-store = { workspace = true } pod-store = { workspace = true } -pod = { workspace = true } pod-registry = { workspace = true } serde = { workspace = true, features = ["derive"] } pulldown-cmark = { version = "0.13.3", default-features = false } diff --git a/crates/tui/src/main.rs b/crates/tui/src/lib.rs similarity index 79% rename from crates/tui/src/main.rs rename to crates/tui/src/lib.rs index 6e9da21d..16e76e4a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/lib.rs @@ -4,7 +4,6 @@ mod cache; mod command; mod input; mod markdown; -mod memory_lint; mod multi_pod; mod picker; mod pod_list; @@ -39,7 +38,7 @@ use ratatui::backend::CrosstermBackend; use session_store::SegmentId; use tokio::sync::mpsc; -use client::PodClient; +use client::{PodClient, PodRuntimeCommand}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::picker::PickerOutcome; @@ -59,8 +58,14 @@ fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { }) } -#[derive(Debug)] -enum Mode { +#[derive(Debug, Clone)] +pub struct LaunchOptions { + pub mode: LaunchMode, + pub runtime_command: PodRuntimeCommand, +} + +#[derive(Debug, Clone)] +pub enum LaunchMode { Spawn { profile: Option, }, @@ -81,218 +86,13 @@ enum Mode { /// separate from `-r`/`--resume`, which keeps its single-Pod picker /// meaning. Multi, - /// `insomnia memory lint`: headless lint for workspace memory and knowledge files. - MemoryLint(memory_lint::LintCliOptions), - /// `insomnia pod ...`: run the Pod runtime parser/entrypoint without TUI side effects. - PodRuntime(Vec), } -#[derive(Debug)] -enum ParseError { - Conflict(&'static str), - InvalidSession(String), - MemoryLint(memory_lint::UsageError), - MissingValue(&'static str), -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Conflict(message) => write!(f, "{message}"), - Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"), - Self::MemoryLint(err) => write!(f, "{err}"), - Self::MissingValue(flag) => write!(f, "{flag} requires a value"), - } - } -} - -fn parse_args() -> Result { - parse_args_from(std::env::args().skip(1)) -} - -fn parse_args_from(args: I) -> Result -where - I: IntoIterator, - S: Into, -{ - let args: Vec = args.into_iter().map(Into::into).collect(); - if args.first().map(String::as_str) == Some("memory") - && args.get(1).map(String::as_str) == Some("lint") - { - let options = memory_lint::parse_lint_args(&args[2..]).map_err(ParseError::MemoryLint)?; - return Ok(Mode::MemoryLint(options)); - } - if args.first().map(String::as_str) == Some("pod") { - return Ok(Mode::PodRuntime(args[1..].to_vec())); - } - - let mut resume = false; - let mut multi = false; - let mut session: Option = None; - let mut pod: Option = None; - let mut profile: Option = None; - let mut socket_override: Option = None; - let mut socket_seen = false; - let mut positional: Option = None; - - let mut i = 0; - while i < args.len() { - match args[i].as_str() { - "-r" | "--resume" => { - resume = true; - i += 1; - } - "--multi" => { - multi = true; - i += 1; - } - "--session" => { - let raw = args - .get(i + 1) - .ok_or(ParseError::MissingValue("--session"))?; - session = Some( - raw.parse::() - .map_err(|_| ParseError::InvalidSession(raw.clone()))?, - ); - i += 2; - } - "--pod" => { - let raw = args.get(i + 1).ok_or(ParseError::MissingValue("--pod"))?; - pod = Some(raw.clone()); - i += 2; - } - "--profile" => { - let raw = args - .get(i + 1) - .ok_or(ParseError::MissingValue("--profile"))?; - profile = Some(raw.clone()); - i += 2; - } - "--socket" => { - socket_seen = true; - let raw = args - .get(i + 1) - .ok_or(ParseError::MissingValue("--socket"))?; - socket_override = Some(PathBuf::from(raw)); - i += 2; - } - other if positional.is_none() && !other.starts_with('-') => { - positional = Some(other.to_string()); - i += 1; - } - _ => { - // Unknown flag or extra positional — keep older - // behaviour of ignoring unknowns rather than aborting. - i += 1; - } - } - } - - if multi { - if resume { - return Err(ParseError::Conflict( - "--multi and --resume are mutually exclusive", - )); - } - if session.is_some() { - return Err(ParseError::Conflict( - "--multi and --session are mutually exclusive", - )); - } - if pod.is_some() { - return Err(ParseError::Conflict( - "--multi and --pod are mutually exclusive", - )); - } - if positional.is_some() { - return Err(ParseError::Conflict( - "--multi cannot be used with a positional Pod name", - )); - } - if socket_seen { - return Err(ParseError::Conflict( - "--multi and --socket are mutually exclusive", - )); - } - if profile.is_some() { - return Err(ParseError::Conflict( - "--multi and --profile are mutually exclusive", - )); - } - return Ok(Mode::Multi); - } - - if resume && session.is_some() { - return Err(ParseError::Conflict( - "--resume and --session are mutually exclusive", - )); - } - if pod.is_some() && session.is_some() { - return Err(ParseError::Conflict( - "--pod and --session are mutually exclusive", - )); - } - if pod.is_some() && resume { - return Err(ParseError::Conflict( - "--pod and --resume are mutually exclusive", - )); - } - if profile.is_some() - && (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen) - { - return Err(ParseError::Conflict( - "--profile can only be used for fresh spawn", - )); - } - - if let Some(pod_name) = pod { - return Ok(Mode::PodName { - pod_name, - socket_override, - }); - } - if let Some(id) = session { - return Ok(Mode::ResumeWithSession(id)); - } - if resume { - return Ok(Mode::Resume); - } - if let Some(pod_name) = positional { - return Ok(Mode::PodName { - pod_name, - socket_override, - }); - } - Ok(Mode::Spawn { profile }) -} - -#[tokio::main] -async fn main() -> ExitCode { - let mode = match parse_args() { - Ok(m) => m, - Err(e) => { - eprintln!("insomnia: {e}"); - return match e { - ParseError::MemoryLint(_) => ExitCode::from(2), - _ => ExitCode::FAILURE, - }; - } - }; - - if let Mode::MemoryLint(ref options) = mode { - return match memory_lint::run(options) { - Ok(memory_lint::LintStatus::Clean) => ExitCode::SUCCESS, - Ok(memory_lint::LintStatus::Failed) => ExitCode::FAILURE, - Err(err) => { - eprintln!("insomnia: {err}"); - ExitCode::from(2) - } - }; - } - - if let Mode::PodRuntime(args) = mode { - return pod::entrypoint::run_cli_from("insomnia pod", args).await; - } +pub async fn launch(options: LaunchOptions) -> ExitCode { + let LaunchOptions { + mode, + runtime_command, + } = options; if let Err(e) = enable_raw_mode() { eprintln!("insomnia: failed to enter raw mode: {e}"); @@ -305,16 +105,14 @@ async fn main() -> ExitCode { } let result = match mode { - Mode::Spawn { profile } => run_spawn(None, profile).await, - Mode::PodName { + LaunchMode::Spawn { profile } => run_spawn(None, profile, runtime_command).await, + LaunchMode::PodName { pod_name, socket_override, - } => run_pod_name(pod_name, socket_override).await, - Mode::Resume => run_resume().await, - Mode::ResumeWithSession(id) => run_spawn(Some(id), None).await, - Mode::Multi => run_multi().await, - Mode::MemoryLint(_) => unreachable!("memory lint returns before terminal setup"), - Mode::PodRuntime(_) => unreachable!("pod runtime returns before terminal setup"), + } => run_pod_name(pod_name, socket_override, runtime_command).await, + LaunchMode::Resume => run_resume(runtime_command).await, + LaunchMode::ResumeWithSession(id) => run_spawn(Some(id), None, runtime_command).await, + LaunchMode::Multi => run_multi(runtime_command).await, }; // Always restore the terminal first so any pending eprintln below @@ -349,6 +147,7 @@ async fn main() -> ExitCode { async fn run_pod_name( pod_name: String, socket_override: Option, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { let mut terminal = enter_fullscreen()?; @@ -356,7 +155,7 @@ async fn run_pod_name( return Ok(()); } - let ready = match spawn::run_pod_name(pod_name).await? { + let ready = match spawn::run_pod_name(pod_name, runtime_command).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; @@ -380,6 +179,7 @@ async fn run_connected_pod( async fn run_pod_name_nested( terminal: &mut FullscreenTerminal, request: multi_pod::OpenPodRequest, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let multi_pod::OpenPodRequest { pod_name, @@ -390,16 +190,17 @@ async fn run_pod_name_nested( return run_connected_pod(terminal, pod_name, client).await; } - let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name).await?; + let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command).await?; run_ready_pod(terminal, ready).await } async fn spawn_pod_name_from_fullscreen( terminal: &mut FullscreenTerminal, pod_name: &str, + runtime_command: PodRuntimeCommand, ) -> Result> { leave_fullscreen(terminal)?; - let outcome = spawn::run_pod_name(pod_name.to_string()).await; + let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await; enter_fullscreen_existing(terminal)?; terminal.clear()?; @@ -463,7 +264,7 @@ async fn connect_live_pod( .map(|client| (registry_socket, client)) } -async fn run_resume() -> Result<(), Box> { +async fn run_resume(runtime_command: PodRuntimeCommand) -> Result<(), Box> { // Pick a Pod in its own inline viewport, dropping the viewport before // attaching/restoring so each phase gets fresh vertical room. let (pod_name, socket_override) = match picker::run().await? { @@ -473,10 +274,10 @@ async fn run_resume() -> Result<(), Box> { } => (pod_name, socket_override), PickerOutcome::Cancelled => return Ok(()), }; - run_pod_name(pod_name, socket_override).await + run_pod_name(pod_name, socket_override, runtime_command).await } -async fn run_multi() -> Result<(), Box> { +async fn run_multi(runtime_command: PodRuntimeCommand) -> Result<(), Box> { let mut app = multi_pod::load_app().await?; let mut terminal = enter_fullscreen()?; @@ -488,7 +289,7 @@ async fn run_multi() -> Result<(), Box> { } multi_pod::MultiPodOutcome::Open(request) => { let pod_name = request.pod_name.clone(); - match run_pod_name_nested(&mut terminal, request).await { + match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await { Ok(()) => app.finish_open(&pod_name, Ok(())), Err(error) if is_recoverable_multi_open_error(error.as_ref()) => { app.finish_open(&pod_name, Err(error.as_ref())); @@ -511,8 +312,9 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> async fn run_spawn( resume_from: Option, profile: Option, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let ready = match spawn::run(resume_from, profile).await? { + let ready = match spawn::run(resume_from, profile, runtime_command).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; @@ -1178,231 +980,6 @@ mod tests { use super::*; use protocol::{Event, RewindTarget, RewindTargetId, Segment}; - #[test] - fn parse_pod_name_mode() { - match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() { - Mode::PodName { - pod_name, - socket_override, - } => { - assert_eq!(pod_name, "agent"); - assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock"))); - } - _ => panic!("expected PodName mode"), - } - } - - #[test] - fn parse_positional_name_uses_pod_name_mode() { - match parse_args_from(["agent"]).unwrap() { - Mode::PodName { - pod_name, - socket_override, - } => { - assert_eq!(pod_name, "agent"); - assert_eq!(socket_override, None); - } - _ => panic!("expected PodName mode"), - } - } - - #[test] - fn parse_memory_alone_remains_positional_pod_name() { - match parse_args_from(["memory"]).unwrap() { - Mode::PodName { - pod_name, - socket_override, - } => { - assert_eq!(pod_name, "memory"); - assert_eq!(socket_override, None); - } - _ => panic!("expected PodName mode"), - } - } - - #[test] - fn parse_pod_subcommand_uses_runtime_mode() { - match parse_args_from(["pod", "--pod", "agent", "--profile", "default"]).unwrap() { - Mode::PodRuntime(args) => assert_eq!(args, ["--pod", "agent", "--profile", "default"]), - _ => panic!("expected PodRuntime mode"), - } - } - - #[test] - fn parse_literal_pod_name_still_available_with_flag() { - match parse_args_from(["--pod", "pod"]).unwrap() { - Mode::PodName { - pod_name, - socket_override, - } => { - assert_eq!(pod_name, "pod"); - assert_eq!(socket_override, None); - } - _ => panic!("expected PodName mode"), - } - } - - #[test] - fn parse_memory_lint_mode() { - match parse_args_from([ - "memory", - "lint", - "--workspace", - "/tmp/ws", - "--json", - "--warnings-as-errors", - ]) - .unwrap() - { - Mode::MemoryLint(options) => { - assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); - assert!(options.json); - assert!(options.warnings_as_errors); - } - _ => panic!("expected MemoryLint mode"), - } - } - - #[test] - fn parse_memory_lint_rejects_usage_errors() { - let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err(); - assert_eq!(err.to_string(), "--workspace requires a value"); - } - - #[test] - fn parse_memory_lint_workspace_equals() { - match parse_args_from(["memory", "lint", "--workspace=/tmp/ws"]).unwrap() { - Mode::MemoryLint(options) => { - assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); - assert!(!options.json); - assert!(!options.warnings_as_errors); - } - _ => panic!("expected MemoryLint mode"), - } - } - - #[test] - fn memory_lint_with_other_second_word_remains_positional_pod_name() { - match parse_args_from(["memory", "other"]).unwrap() { - Mode::PodName { pod_name, .. } => assert_eq!(pod_name, "memory"), - _ => panic!("expected PodName mode"), - } - } - - #[test] - fn parse_rejects_pod_and_session() { - let segment_id = session_store::new_segment_id().to_string(); - let err = parse_args_from(["--pod", "agent", "--session", &segment_id]).unwrap_err(); - assert_eq!( - err.to_string(), - "--pod and --session are mutually exclusive" - ); - } - - #[test] - fn parse_profile_spawn_mode() { - match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() { - Mode::Spawn { profile } => { - assert_eq!(profile, Some("/profiles/coder.lua".to_string())); - } - _ => panic!("expected Spawn mode"), - } - } - - #[test] - fn parse_profile_rejects_resume_attach_modes() { - let segment_id = session_store::new_segment_id().to_string(); - let cases = [ - ( - vec![ - "--profile".to_string(), - "p.lua".to_string(), - "--resume".to_string(), - ], - "--profile can only be used for fresh spawn", - ), - ( - vec![ - "--profile".to_string(), - "p.lua".to_string(), - "--session".to_string(), - segment_id, - ], - "--profile can only be used for fresh spawn", - ), - ( - vec![ - "--profile".to_string(), - "p.lua".to_string(), - "--socket".to_string(), - "/tmp/insomnia/sock".to_string(), - ], - "--profile can only be used for fresh spawn", - ), - ( - vec![ - "--profile".to_string(), - "p.lua".to_string(), - "agent".to_string(), - ], - "--profile can only be used for fresh spawn", - ), - ]; - - for (args, message) in cases { - let err = parse_args_from(args).unwrap_err(); - assert_eq!(err.to_string(), message); - } - } - - #[test] - fn parse_multi_mode() { - match parse_args_from(["--multi"]).unwrap() { - Mode::Multi => {} - _ => panic!("expected Multi mode"), - } - } - - #[test] - fn parse_multi_conflicts_are_clear() { - let segment_id = session_store::new_segment_id().to_string(); - let cases = [ - ( - vec!["--multi".to_string(), "--resume".to_string()], - "--multi and --resume are mutually exclusive", - ), - ( - vec!["--multi".to_string(), "--session".to_string(), segment_id], - "--multi and --session are mutually exclusive", - ), - ( - vec![ - "--multi".to_string(), - "--pod".to_string(), - "agent".to_string(), - ], - "--multi and --pod are mutually exclusive", - ), - ( - vec!["--multi".to_string(), "agent".to_string()], - "--multi cannot be used with a positional Pod name", - ), - ( - vec![ - "--multi".to_string(), - "--socket".to_string(), - "/tmp/a.sock".to_string(), - ], - "--multi and --socket are mutually exclusive", - ), - ]; - - for (args, message) in cases { - let err = parse_args_from(args).unwrap_err(); - assert_eq!(err.to_string(), message); - } - } - #[tokio::test] async fn terminal_event_is_selected_before_ready_pod_event() { let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index d9c51b98..beb61c31 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -15,7 +15,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; -use client::{SpawnConfig, spawn_pod}; +use client::{PodRuntimeCommand, SpawnConfig, spawn_pod}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use manifest::ProfileDiscovery; use ratatui::Terminal; @@ -76,6 +76,7 @@ type InlineTerminal = Terminal>; pub async fn run( resume_from: Option, profile: Option, + runtime_command: PodRuntimeCommand, ) -> Result { let defaults = load_spawn_defaults()?; let mut profile_choices = if resume_from.is_some() { @@ -143,7 +144,7 @@ pub async fn run( form.message = Some(("starting pod...".to_string(), MessageKind::Progress)); terminal.draw(|f| draw_form(f, &form))?; - match wait_for_ready(&mut terminal, &mut form).await { + match wait_for_ready(&mut terminal, &mut form, &runtime_command).await { Ok(ready) => { form.message = Some(( format!("ready: {} attaching...", ready.pod_name), @@ -165,13 +166,16 @@ pub async fn run( /// Launch a Pod runtime command with `--pod ` without opening the name dialog. The child Pod /// resolves persisted Pod metadata if present, or creates a fresh same-name Pod /// from the default profile. -pub async fn run_pod_name(pod_name: String) -> Result { +pub async fn run_pod_name( + pod_name: String, + runtime_command: PodRuntimeCommand, +) -> Result { let defaults = load_spawn_defaults()?; let mut form = form_for_pod_name(pod_name, defaults); let mut terminal = make_inline_terminal()?; terminal.draw(|f| draw_form(f, &form))?; - match wait_for_ready(&mut terminal, &mut form).await { + match wait_for_ready(&mut terminal, &mut form, &runtime_command).await { Ok(ready) => { form.message = Some(( format!("ready: {} attaching...", ready.pod_name), @@ -360,8 +364,10 @@ fn sanitise_default_name(s: &str) -> String { async fn wait_for_ready( terminal: &mut InlineTerminal, form: &mut Form, + runtime_command: &PodRuntimeCommand, ) -> Result { let config = SpawnConfig { + runtime_command: runtime_command.clone(), pod_name: form.name.clone(), profile: form.selected_profile_selector(), cwd: form.cwd.clone(), @@ -687,7 +693,10 @@ description = "Project coder" let (choices, default_index) = profile_choices_for_cwd(&project); assert_eq!(choices[0].selector.as_deref(), Some("builtin:default")); - assert_eq!(choices[0].label, "builtin:default"); + assert_eq!( + choices[0].label, + "builtin:default — Bundled default Insomnia coding profile" + ); assert_eq!(default_index, 1); assert_eq!(choices[1].selector.as_deref(), Some("project:coder")); assert_eq!(choices[1].label, "project:coder (default) — Project coder"); diff --git a/package.nix b/package.nix index 931102e6..628ad30e 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-Nu+QAXwRhqqSwgc5/9XLwQEpjEnF54tWoEknM17wYq8="; + cargoHash = "sha256-tYql+E6s7WeZ2vQSfjH0BzmXmNQaqm54VQy8mUnpzLg="; depsExtraArgs = { # nixpkgs 25.11's fetchCargoVendor still uses crates.io's API @@ -83,7 +83,7 @@ rustPlatform.buildRustPackage rec { cargoBuildFlags = [ "-p" - "tui" + "insomnia" ]; # The package check is a credential-free install smoke check below. Running the From 37281b64f279f3ee202867fa03f72e030f924108 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 31 May 2026 22:15:12 +0900 Subject: [PATCH 2/2] cli: reject resume with pod selection --- crates/insomnia/src/main.rs | 51 ++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/insomnia/src/main.rs b/crates/insomnia/src/main.rs index f909d411..544fe4bc 100644 --- a/crates/insomnia/src/main.rs +++ b/crates/insomnia/src/main.rs @@ -222,10 +222,14 @@ fn parse_args_slice(args: &[String]) -> Result { "--pod and a positional Pod name are mutually exclusive".to_string(), )); } - let pod_name = pod_name.or(positional); if profile.is_some() - && (resume || session.is_some() || pod_name.is_some() || socket_override.is_some() || multi) + && (resume + || session.is_some() + || pod_name.is_some() + || positional.is_some() + || socket_override.is_some() + || multi) { return Err(ParseError( "--profile can only be used for fresh spawn".to_string(), @@ -242,6 +246,11 @@ fn parse_args_slice(args: &[String]) -> Result { )); } if multi && pod_name.is_some() { + return Err(ParseError( + "--multi and --pod are mutually exclusive".to_string(), + )); + } + if multi && positional.is_some() { return Err(ParseError( "--multi cannot be used with a positional Pod name".to_string(), )); @@ -256,7 +265,17 @@ fn parse_args_slice(args: &[String]) -> Result { "--pod and --session are mutually exclusive".to_string(), )); } - if socket_override.is_some() && pod_name.is_none() { + if pod_name.is_some() && resume { + return Err(ParseError( + "--pod and --resume are mutually exclusive".to_string(), + )); + } + if positional.is_some() && resume { + return Err(ParseError( + "--resume cannot be used with a positional Pod name".to_string(), + )); + } + if socket_override.is_some() && pod_name.is_none() && positional.is_none() { return Err(ParseError( "--socket requires --pod or a positional Pod name".to_string(), )); @@ -270,6 +289,7 @@ fn parse_args_slice(args: &[String]) -> Result { if multi { return Ok(Mode::Tui(LaunchMode::Multi)); } + let pod_name = pod_name.or(positional); if let Some(pod_name) = pod_name { return Ok(Mode::Tui(LaunchMode::PodName { pod_name, @@ -429,6 +449,29 @@ mod tests { ); } + #[test] + fn parse_rejects_resume_and_pod_name_selection() { + let cases = [ + ( + vec!["-r".to_string(), "--pod".to_string(), "agent".to_string()], + "--pod and --resume are mutually exclusive", + ), + ( + vec!["--pod".to_string(), "agent".to_string(), "-r".to_string()], + "--pod and --resume are mutually exclusive", + ), + ( + vec!["-r".to_string(), "agent".to_string()], + "--resume cannot be used with a positional Pod name", + ), + ]; + + for (args, message) in cases { + let err = parse_args_from(args).unwrap_err(); + assert_eq!(err.to_string(), message); + } + } + #[test] fn parse_profile_spawn_mode() { match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() { @@ -527,7 +570,7 @@ mod tests { "--pod".to_string(), "agent".to_string(), ], - "--multi cannot be used with a positional Pod name", + "--multi and --pod are mutually exclusive", ), ( vec!["--multi".to_string(), "agent".to_string()],