Compare commits

...

52 Commits

Author SHA1 Message Date
6e5ed683d6
decision: remove credential env compatibility 2026-06-01 06:23:48 +09:00
faac237f0d
plan: clarify phased secrets implementation 2026-06-01 06:19:31 +09:00
50ccf0f21b
decision: simplify secret store model 2026-06-01 06:05:09 +09:00
9a00c91632
plan: detail encrypted secrets requirements 2026-06-01 05:58:02 +09:00
52d8807282
close: dev pod runtime env 2026-06-01 05:41:58 +09:00
32d20eea01
merge: dev pod runtime env 2026-06-01 05:41:36 +09:00
17e799a766
review: approve dev pod runtime env 2026-06-01 05:41:28 +09:00
0031953ed3
dev: add pod runtime command override 2026-06-01 05:37:17 +09:00
ca9e1840cf
close: tui runtime extraction 2026-05-31 22:57:04 +09:00
a24d026b0e
merge: tui runtime extraction 2026-05-31 22:56:39 +09:00
8ec398522e
review: approve tui runtime extraction 2026-05-31 22:56:32 +09:00
4d897186c2
tui: extract single pod runtime 2026-05-31 22:51:21 +09:00
c4cc93512f
plan: rescope tui runtime extraction 2026-05-31 22:46:32 +09:00
0a538380a9
close: tui view mode state 2026-05-31 22:45:41 +09:00
9afe8a3243
merge: tui view mode state 2026-05-31 22:45:10 +09:00
72d6ac8719
review: approve tui view mode move 2026-05-31 22:45:04 +09:00
bc31bfac58
tui: move view mode state 2026-05-31 22:41:38 +09:00
80a41d3264
close: tui cli parsing extraction 2026-05-31 22:38:32 +09:00
7141734f07
close: insomnia cli ownership 2026-05-31 22:20:05 +09:00
6b49be085d
merge: insomnia cli ownership 2026-05-31 22:17:32 +09:00
b12e3320a9
review: approve insomnia cli ownership 2026-05-31 22:17:15 +09:00
37281b64f2
cli: reject resume with pod selection 2026-05-31 22:15:12 +09:00
22d974a722
cli: move product entrypoint to insomnia 2026-05-31 22:07:52 +09:00
2deb93c7ce
plan: preflight insomnia cli ownership 2026-05-31 21:42:24 +09:00
4c7088d757
ticket: add dev pod runtime command env 2026-05-31 21:41:24 +09:00
f0efafbba9
close: single binary cli migration 2026-05-31 21:15:52 +09:00
5783503f74
docs: remove obsolete nix package doc 2026-05-31 21:11:48 +09:00
530c14e4b8
close: remove resource_dir 2026-05-31 20:58:30 +09:00
f7293411cd
merge: remove resource_dir 2026-05-31 20:54:37 +09:00
18ae9934e1
review: approve resource_dir removal 2026-05-31 20:54:28 +09:00
365ec8b7fa
manifest: embed builtin resources 2026-05-31 20:47:06 +09:00
607923dfbd
plan: preflight resource_dir removal 2026-05-31 20:28:43 +09:00
f5c4943337
ticket: plan insomnia cli ownership 2026-05-31 20:20:34 +09:00
3a18842ac7
ticket: plan resource_dir removal 2026-05-31 20:09:12 +09:00
469410cbcf
docs: prefer pure env fallback tests 2026-05-31 19:58:57 +09:00
813ff7e199
close: pure path fallback tests 2026-05-31 19:54:51 +09:00
7851fc0aac
merge: pure path fallback tests 2026-05-31 19:54:24 +09:00
974dde3b88
review: approve pure path fallback tests 2026-05-31 19:54:18 +09:00
e232f5468a
test: make path fallback tests pure 2026-05-31 19:51:23 +09:00
6fa67097aa
ticket: plan pure path fallback tests 2026-05-31 19:47:02 +09:00
506719796e
docs: remove absent env surfaces 2026-05-31 19:31:33 +09:00
20f975c9b7
docs: simplify test env policy 2026-05-31 19:29:31 +09:00
1f29d1a39d
docs: drop explicit test env name 2026-05-31 19:28:33 +09:00
e605a5886f
close: remove pod command env override 2026-05-31 19:12:05 +09:00
400cca9252
docs: drop pod command env reference 2026-05-31 19:11:50 +09:00
458a87e81a
merge: remove pod command env override
# Conflicts:
#	docs/environment.md
2026-05-31 19:10:58 +09:00
e8d8f139d0
review: approve pod command env removal 2026-05-31 19:09:52 +09:00
e8e50bfa6b
close: eliminate test-only env vars 2026-05-31 19:04:31 +09:00
b61504e821
merge: eliminate test-only env vars 2026-05-31 19:04:04 +09:00
fc3a0718a1
review: approve test env removal 2026-05-31 19:03:55 +09:00
c618fa694c
cli: remove pod command env override 2026-05-31 19:03:49 +09:00
e64a559595
test: remove test-only env vars 2026-05-31 19:00:16 +09:00
75 changed files with 3264 additions and 1248 deletions

16
Cargo.lock generated
View File

@ -332,7 +332,6 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
name = "client" name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"insomnia",
"manifest", "manifest",
"protocol", "protocol",
"serde_json", "serde_json",
@ -1483,6 +1482,17 @@ dependencies = [
[[package]] [[package]]
name = "insomnia" name = "insomnia"
version = "0.1.0" version = "0.1.0"
dependencies = [
"client",
"memory",
"pod",
"serde",
"serde_json",
"session-store",
"tempfile",
"tokio",
"tui",
]
[[package]] [[package]]
name = "instability" name = "instability"
@ -2334,11 +2344,11 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"clap", "clap",
"client",
"dotenv", "dotenv",
"fs4", "fs4",
"futures", "futures",
"include_dir", "include_dir",
"insomnia",
"libc", "libc",
"llm-worker", "llm-worker",
"manifest", "manifest",
@ -3917,8 +3927,6 @@ dependencies = [
"crossterm 0.28.1", "crossterm 0.28.1",
"llm-worker", "llm-worker",
"manifest", "manifest",
"memory",
"pod",
"pod-registry", "pod-registry",
"pod-store", "pod-store",
"protocol", "protocol",

View File

@ -42,6 +42,7 @@ provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" } session-metrics = { path = "crates/session-metrics" }
session-store = { path = "crates/session-store" } session-store = { path = "crates/session-store" }
tools = { path = "crates/tools" } tools = { path = "crates/tools" }
tui = { path = "crates/tui" }
# External # External
# Note: `reqwest` and `chrono` are not aggregated here because some crates # Note: `reqwest` and `chrono` are not aggregated here because some crates

View File

@ -7,7 +7,6 @@ license.workspace = true
[dependencies] [dependencies]
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
insomnia = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
uuid = { workspace = true } uuid = { workspace = true }

View File

@ -9,7 +9,10 @@
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
mod pod_client; mod pod_client;
pub mod runtime_command;
pub mod spawn; pub mod spawn;
pub use runtime_command::PodRuntimeCommand;
pub use pod_client::PodClient; pub use pod_client::PodClient;
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod}; pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};

View File

@ -3,7 +3,7 @@ use std::fmt;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const POD_COMMAND_OVERRIDE_ENV: &str = "INSOMNIA_POD_COMMAND"; const POD_RUNTIME_COMMAND_ENV: &str = "INSOMNIA_POD_RUNTIME_COMMAND";
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct PodRuntimeCommand { pub struct PodRuntimeCommand {
@ -19,10 +19,6 @@ impl PodRuntimeCommand {
} }
} }
pub fn executable_only(program: impl Into<PathBuf>) -> Self {
Self::new(program, Vec::new())
}
pub fn for_current_exe() -> io::Result<Self> { pub fn for_current_exe() -> io::Result<Self> {
Ok(Self::for_executable(std::env::current_exe()?)) Ok(Self::for_executable(std::env::current_exe()?))
} }
@ -33,23 +29,30 @@ impl PodRuntimeCommand {
/// Resolve the Pod runtime command used for subprocess launches. /// Resolve the Pod runtime command used for subprocess launches.
/// ///
/// `INSOMNIA_POD_COMMAND` is intentionally executable-only: its value is /// The default launch path is always the current `insomnia` executable plus
/// used as the program path without shell parsing and without the unified /// the unified `pod` prefix argument. During development, a non-empty
/// `pod` prefix arg. That keeps development/test overrides safe while the /// `INSOMNIA_POD_RUNTIME_COMMAND` value replaces only the executable path;
/// default path is always `current_exe() + ["pod"]`. /// the `pod` prefix is still added here and the env value is not parsed as a
/// shell command.
pub fn resolve() -> io::Result<Self> { pub fn resolve() -> io::Result<Self> {
if let Some(command) = Self::from_override_env() { Self::resolve_from_env_value(
return Ok(command); std::env::var_os(POD_RUNTIME_COMMAND_ENV),
} std::env::current_exe,
Self::for_current_exe() )
} }
pub fn from_override_env() -> Option<Self> { fn resolve_from_env_value<F>(
let raw = std::env::var_os(POD_COMMAND_OVERRIDE_ENV)?; override_program: Option<OsString>,
if raw.is_empty() { current_exe: F,
return None; ) -> io::Result<Self>
where
F: FnOnce() -> io::Result<PathBuf>,
{
if let Some(program) = override_program.filter(|program| !program.as_os_str().is_empty()) {
return Ok(Self::for_executable(program));
} }
Some(Self::executable_only(raw))
Ok(Self::for_executable(current_exe()?))
} }
pub fn program(&self) -> &Path { pub fn program(&self) -> &Path {
@ -84,28 +87,6 @@ impl fmt::Display for PodRuntimeCommand {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvRestore(Option<OsString>);
impl EnvRestore {
fn capture() -> Self {
Self(std::env::var_os(POD_COMMAND_OVERRIDE_ENV))
}
}
impl Drop for EnvRestore {
fn drop(&mut self) {
unsafe {
match &self.0 {
Some(value) => std::env::set_var(POD_COMMAND_OVERRIDE_ENV, value),
None => std::env::remove_var(POD_COMMAND_OVERRIDE_ENV),
}
}
}
}
#[test] #[test]
fn insomnia_binary_defaults_to_pod_prefix() { fn insomnia_binary_defaults_to_pod_prefix() {
@ -141,16 +122,47 @@ mod tests {
} }
#[test] #[test]
fn env_override_is_executable_only_and_not_shell_parsed() { fn resolve_uses_current_exe_when_override_is_unset() {
let _guard = ENV_LOCK.lock().unwrap(); let command = PodRuntimeCommand::resolve_from_env_value(None, || {
let _restore = EnvRestore::capture(); Ok(PathBuf::from("/opt/insomnia/bin/insomnia"))
unsafe { })
std::env::set_var(POD_COMMAND_OVERRIDE_ENV, "/tmp/mock pod --flag"); .unwrap();
}
let command = PodRuntimeCommand::resolve().unwrap(); assert_eq!(
command,
PodRuntimeCommand::for_executable("/opt/insomnia/bin/insomnia")
);
}
assert_eq!(command.program(), Path::new("/tmp/mock pod --flag")); #[test]
assert!(command.prefix_args().is_empty()); fn resolve_uses_current_exe_when_override_is_empty() {
let command = PodRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
Ok(PathBuf::from("/opt/insomnia/bin/insomnia"))
})
.unwrap();
assert_eq!(
command,
PodRuntimeCommand::for_executable("/opt/insomnia/bin/insomnia")
);
}
#[test]
fn resolve_override_replaces_only_program_and_keeps_pod_prefix() {
let command = PodRuntimeCommand::resolve_from_env_value(
Some(OsString::from("/tmp/rebuilt insomnia")),
|| panic!("override must not inspect current_exe"),
)
.unwrap();
assert_eq!(command.program(), Path::new("/tmp/rebuilt insomnia"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
} }
} }

View File

@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use insomnia::PodRuntimeCommand; use crate::PodRuntimeCommand;
use tokio::process::Command; use tokio::process::Command;
use uuid::Uuid; use uuid::Uuid;
@ -24,6 +24,7 @@ const READY_TIMEOUT: Duration = Duration::from_secs(20);
/// `spawn_pod` の入力。 /// `spawn_pod` の入力。
pub struct SpawnConfig { pub struct SpawnConfig {
pub runtime_command: PodRuntimeCommand,
/// `pod.name` として使う識別子。runtime ディレクトリ /// `pod.name` として使う識別子。runtime ディレクトリ
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る /// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。 /// 名前との突き合わせに使う。
@ -52,7 +53,10 @@ pub enum SpawnError {
Io(io::Error), Io(io::Error),
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。 /// runtime ディレクトリが解決できなかった (環境変数未設定等)。
RuntimeDirUnavailable, RuntimeDirUnavailable,
PodLaunchFailed(io::Error), PodLaunchFailed {
command: PodRuntimeCommand,
source: io::Error,
},
PodExitedEarly { PodExitedEarly {
stderr_tail: String, stderr_tail: String,
}, },
@ -67,7 +71,10 @@ impl std::fmt::Display for SpawnError {
f, f,
"could not resolve runtime directory (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)" "could not resolve runtime directory (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
), ),
Self::PodLaunchFailed(e) => write!(f, "failed to launch pod: {e}"), Self::PodLaunchFailed { command, source } => write!(
f,
"failed to launch pod runtime command `{command}`: {source}"
),
Self::PodExitedEarly { stderr_tail } => { Self::PodExitedEarly { stderr_tail } => {
if stderr_tail.is_empty() { if stderr_tail.is_empty() {
write!(f, "pod exited before becoming ready") write!(f, "pod exited before becoming ready")
@ -84,7 +91,14 @@ impl std::fmt::Display for SpawnError {
} }
} }
impl std::error::Error for SpawnError {} impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) | Self::PodLaunchFailed { source: error, .. } => Some(error),
Self::RuntimeDirUnavailable | Self::PodExitedEarly { .. } | Self::Timeout => None,
}
}
}
impl From<io::Error> for SpawnError { impl From<io::Error> for SpawnError {
fn from(e: io::Error) -> Self { fn from(e: io::Error) -> Self {
@ -100,17 +114,15 @@ pub async fn spawn_pod<F>(config: SpawnConfig, mut progress: F) -> Result<SpawnR
where where
F: FnMut(&str), F: FnMut(&str),
{ {
let runtime_command = PodRuntimeCommand::resolve().map_err(SpawnError::Io)?;
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name) let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name)
.ok_or(SpawnError::RuntimeDirUnavailable)?; .ok_or(SpawnError::RuntimeDirUnavailable)?;
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?; std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = pod_runtime_dir.join("stderr.log"); let stderr_path = pod_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?; let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
let mut command = Command::new(runtime_command.program()); let mut command = Command::new(config.runtime_command.program());
command command
.args(runtime_command.prefix_args()) .args(config.runtime_command.prefix_args())
.current_dir(&config.cwd) .current_dir(&config.cwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
@ -133,7 +145,12 @@ where
.arg("--session-pod-name") .arg("--session-pod-name")
.arg(&config.pod_name); .arg(&config.pod_name);
} }
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?; let mut child = command
.spawn()
.map_err(|source| SpawnError::PodLaunchFailed {
command: config.runtime_command.clone(),
source,
})?;
// Default `kill_on_drop = false` plus `process_group(0)` makes this // Default `kill_on_drop = false` plus `process_group(0)` makes this
// a detached Pod once startup succeeds: dropping the handle does not // a detached Pod once startup succeeds: dropping the handle does not

View File

@ -5,3 +5,14 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
client = { workspace = true }
memory = { workspace = true }
pod = { workspace = true }
session-store = { workspace = true }
tui = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
tempfile = { workspace = true }

594
crates/insomnia/src/main.rs Normal file
View File

@ -0,0 +1,594 @@
mod memory_lint;
use std::fmt;
use std::path::PathBuf;
use std::process::ExitCode;
use client::PodRuntimeCommand;
use memory_lint::{LintCliOptions, LintStatus};
use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions};
#[derive(Debug)]
enum Mode {
Help,
MemoryLintHelp,
MemoryLint(LintCliOptions),
PodRuntime(Vec<String>),
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<Mode, ParseError> {
parse_args_from(std::env::args().skip(1))
}
fn parse_args_from<I, S>(args: I) -> Result<Mode, ParseError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
parse_args_slice(&args)
}
fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
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(),
));
}
if profile.is_some()
&& (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(),
));
}
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 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(),
));
}
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 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(),
));
}
if resume && session.is_some() {
return Err(ParseError(
"--resume and --session are mutually exclusive".to_string(),
));
}
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,
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<SegmentId, ParseError> {
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 <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> 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 <PATH> 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_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() {
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 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);
}
}
}

View File

@ -25,49 +25,36 @@
use std::path::PathBuf; use std::path::PathBuf;
/// Environment variable that points at installed project resources.
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
/// 設定ディレクトリ。`profiles.toml`, `providers.toml`, `models.toml`, /// 設定ディレクトリ。`profiles.toml`, `providers.toml`, `models.toml`,
/// `prompts/` などが置かれる。 /// `prompts/` などが置かれる。
pub fn config_dir() -> Option<PathBuf> { pub fn config_dir() -> Option<PathBuf> {
if let Some(p) = env_path("INSOMNIA_CONFIG_DIR") { resolve_config_dir_from_parts(
return Some(p); env_path("INSOMNIA_CONFIG_DIR"),
} env_path("INSOMNIA_HOME"),
if let Some(p) = env_path("INSOMNIA_HOME") { env_path("XDG_CONFIG_HOME"),
return Some(p.join("config")); env_path("HOME"),
} )
if let Some(p) = env_path("XDG_CONFIG_HOME") {
return Some(p.join("insomnia"));
}
Some(env_path("HOME")?.join(".config").join("insomnia"))
} }
/// データディレクトリ。`sessions/` などプログラムが書く永続データの /// データディレクトリ。`sessions/` などプログラムが書く永続データの
/// 置き場。 /// 置き場。
pub fn data_dir() -> Option<PathBuf> { pub fn data_dir() -> Option<PathBuf> {
if let Some(p) = env_path("INSOMNIA_DATA_DIR") { resolve_data_dir_from_parts(
return Some(p); env_path("INSOMNIA_DATA_DIR"),
} env_path("INSOMNIA_HOME"),
if let Some(p) = env_path("INSOMNIA_HOME") { env_path("HOME"),
return Some(p); )
}
Some(env_path("HOME")?.join(".insomnia"))
} }
/// ランタイムディレクトリ。socket, `pods.json`, Pod ごとの `pid` / /// ランタイムディレクトリ。socket, `pods.json`, Pod ごとの `pid` /
/// `status.json` 等が置かれる。再起動で消えて構わない。 /// `status.json` 等が置かれる。再起動で消えて構わない。
pub fn runtime_dir() -> Option<PathBuf> { pub fn runtime_dir() -> Option<PathBuf> {
if let Some(p) = env_path("INSOMNIA_RUNTIME_DIR") { resolve_runtime_dir_from_parts(
return Some(p); env_path("INSOMNIA_RUNTIME_DIR"),
} env_path("INSOMNIA_HOME"),
if let Some(p) = env_path("INSOMNIA_HOME") { env_path("XDG_RUNTIME_DIR"),
return Some(p.join("run")); env_path("HOME"),
} )
if let Some(p) = env_path("XDG_RUNTIME_DIR") {
return Some(p.join("insomnia"));
}
Some(env_path("HOME")?.join(".insomnia").join("run"))
} }
// ---- well-known file getters ------------------------------------------------ // ---- well-known file getters ------------------------------------------------
@ -77,64 +64,38 @@ pub fn runtime_dir() -> Option<PathBuf> {
/// This is application/profile selection configuration, not a Pod manifest /// This is application/profile selection configuration, not a Pod manifest
/// layer. /// layer.
pub fn user_profiles_path() -> Option<PathBuf> { pub fn user_profiles_path() -> Option<PathBuf> {
Some(config_dir()?.join("profiles.toml")) user_profiles_path_from_config_dir(config_dir())
} }
/// `<config_dir>/prompts/` — user prompts ライブラリ。 /// `<config_dir>/prompts/` — user prompts ライブラリ。
pub fn user_prompts_dir() -> Option<PathBuf> { pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts")) user_prompts_dir_from_config_dir(config_dir())
}
/// Root resource directory used for bundled prompts, profiles, catalogs, and docs.
pub fn resource_dir() -> Option<PathBuf> {
if let Some(p) = env_path(RESOURCE_DIR_ENV) {
return Some(p);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) {
let installed = prefix.join("share").join("insomnia").join("resources");
if installed.exists() {
return Some(installed);
}
}
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("resources"),
)
}
/// Bundled Lua profile registry directory. Missing directories are treated as
/// an empty builtin registry by discovery.
pub fn builtin_profiles_dir() -> Option<PathBuf> {
Some(resource_dir()?.join("profiles"))
} }
/// `<config_dir>/prompts.toml` — user prompt pack。 /// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> { pub fn user_pack_file() -> Option<PathBuf> {
Some(config_dir()?.join("prompts.toml")) user_pack_file_from_config_dir(config_dir())
} }
/// `<config_dir>/<file_name>` — providers.toml / models.toml 等の /// `<config_dir>/<file_name>` — providers.toml / models.toml 等の
/// user override ファイル。 /// user override ファイル。
pub fn user_catalog_override(file_name: &str) -> Option<PathBuf> { pub fn user_catalog_override(file_name: &str) -> Option<PathBuf> {
Some(config_dir()?.join(file_name)) user_catalog_override_from_config_dir(config_dir(), file_name)
} }
/// `<data_dir>/sessions/` — session store のデフォルト位置。 /// `<data_dir>/sessions/` — session store のデフォルト位置。
pub fn sessions_dir() -> Option<PathBuf> { pub fn sessions_dir() -> Option<PathBuf> {
Some(data_dir()?.join("sessions")) sessions_dir_from_data_dir(data_dir())
} }
/// `<runtime_dir>/pods.json` — machine-wide Pod allocation registry。 /// `<runtime_dir>/pods.json` — machine-wide Pod allocation registry。
pub fn pod_registry_path() -> Option<PathBuf> { pub fn pod_registry_path() -> Option<PathBuf> {
Some(runtime_dir()?.join("pods.json")) pod_registry_path_from_runtime_dir(runtime_dir())
} }
/// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。 /// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。
pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> { pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> {
Some(runtime_dir()?.join(pod_name)) pod_runtime_dir_from_runtime_dir(runtime_dir(), pod_name)
} }
/// `<runtime_dir>/<pod_name>/sock` — Pod の Unix socket パス。 /// `<runtime_dir>/<pod_name>/sock` — Pod の Unix socket パス。
@ -144,206 +105,306 @@ pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> {
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス /// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
/// を返すことが期待される。 /// を返すことが期待される。
pub fn pod_socket_path(pod_name: &str) -> Option<PathBuf> { pub fn pod_socket_path(pod_name: &str) -> Option<PathBuf> {
Some(pod_runtime_dir(pod_name)?.join("sock")) pod_socket_path_from_runtime_dir(runtime_dir(), pod_name)
} }
// ---- internals -------------------------------------------------------------- // ---- internals --------------------------------------------------------------
fn resolve_config_dir_from_parts(
insomnia_config_dir: Option<PathBuf>,
insomnia_home: Option<PathBuf>,
xdg_config_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> Option<PathBuf> {
if let Some(p) = insomnia_config_dir {
return Some(p);
}
if let Some(p) = insomnia_home {
return Some(p.join("config"));
}
if let Some(p) = xdg_config_home {
return Some(p.join("insomnia"));
}
Some(home?.join(".config").join("insomnia"))
}
fn resolve_data_dir_from_parts(
insomnia_data_dir: Option<PathBuf>,
insomnia_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> Option<PathBuf> {
if let Some(p) = insomnia_data_dir {
return Some(p);
}
if let Some(p) = insomnia_home {
return Some(p);
}
Some(home?.join(".insomnia"))
}
fn resolve_runtime_dir_from_parts(
insomnia_runtime_dir: Option<PathBuf>,
insomnia_home: Option<PathBuf>,
xdg_runtime_dir: Option<PathBuf>,
home: Option<PathBuf>,
) -> Option<PathBuf> {
if let Some(p) = insomnia_runtime_dir {
return Some(p);
}
if let Some(p) = insomnia_home {
return Some(p.join("run"));
}
if let Some(p) = xdg_runtime_dir {
return Some(p.join("insomnia"));
}
Some(home?.join(".insomnia").join("run"))
}
fn user_profiles_path_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("profiles.toml"))
}
fn user_prompts_dir_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("prompts"))
}
fn user_pack_file_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("prompts.toml"))
}
fn user_catalog_override_from_config_dir(
config_dir: Option<PathBuf>,
file_name: &str,
) -> Option<PathBuf> {
Some(config_dir?.join(file_name))
}
fn sessions_dir_from_data_dir(data_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(data_dir?.join("sessions"))
}
fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(runtime_dir?.join("pods.json"))
}
fn pod_runtime_dir_from_runtime_dir(
runtime_dir: Option<PathBuf>,
pod_name: &str,
) -> Option<PathBuf> {
Some(runtime_dir?.join(pod_name))
}
fn pod_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>,
pod_name: &str,
) -> Option<PathBuf> {
Some(pod_runtime_dir_from_runtime_dir(runtime_dir, pod_name)?.join("sock"))
}
/// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と /// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と
/// `Err(NotPresent)` を区別するが、パス解決においては両者を未設定と /// `Err(NotPresent)` を区別するが、パス解決においては両者を未設定と
/// 同等に扱うのが直感的。 /// 同等に扱うのが直感的。
fn env_path(name: &str) -> Option<PathBuf> { fn env_path(name: &str) -> Option<PathBuf> {
std::env::var(name) let value = std::env::var(name).ok()?;
.ok() path_from_env_value(Some(value.as_str()))
.filter(|s| !s.is_empty()) }
.map(PathBuf::from)
fn path_from_env_value(value: Option<&str>) -> Option<PathBuf> {
value.filter(|s| !s.is_empty()).map(PathBuf::from)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
/// プロセス全体で env を弄るテスト同士が並行に走らないように保護
/// する。Cargo の test harness はファイル単位で別プロセスにせず
/// マルチスレッドで実行するため、env を読む全テストはこの lock を
/// 取ってから操作する。
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// テスト中だけ env を上書きし、drop 時に元の値に戻す RAII guard。
struct EnvGuard {
vars: Vec<(&'static str, Option<String>)>,
_lock: MutexGuard<'static, ()>,
}
impl EnvGuard {
fn new(overrides: &[(&'static str, Option<&str>)]) -> Self {
let lock = env_lock();
let names = [
"INSOMNIA_CONFIG_DIR",
"INSOMNIA_DATA_DIR",
"INSOMNIA_RUNTIME_DIR",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"HOME",
];
let saved: Vec<_> = names.iter().map(|n| (*n, std::env::var(n).ok())).collect();
// SAFETY: env_lock() 取得済みなので env への並行アクセスは
// この test バイナリ内では発生しない。
unsafe {
for (n, _) in &saved {
std::env::remove_var(n);
}
for (n, v) in overrides {
if let Some(v) = v {
std::env::set_var(n, v);
}
}
}
Self {
vars: saved,
_lock: lock,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: lock を握ったまま元に戻す。
unsafe {
for (n, v) in &self.vars {
match v {
Some(v) => std::env::set_var(n, v),
None => std::env::remove_var(n),
}
}
}
}
}
#[test] #[test]
fn config_dir_falls_back_to_home_dot_config() { fn config_dir_falls_back_to_home_dot_config() {
let _g = EnvGuard::new(&[("HOME", Some("/h"))]); assert_eq!(
assert_eq!(config_dir().unwrap(), PathBuf::from("/h/.config/insomnia")); resolve_config_dir_from_parts(None, None, None, Some(PathBuf::from("/h"))).unwrap(),
PathBuf::from("/h/.config/insomnia")
);
} }
#[test] #[test]
fn config_dir_uses_xdg_when_set() { fn config_dir_uses_xdg_when_set() {
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("XDG_CONFIG_HOME", Some("/x"))]); assert_eq!(
assert_eq!(config_dir().unwrap(), PathBuf::from("/x/insomnia")); resolve_config_dir_from_parts(
None,
None,
Some(PathBuf::from("/x")),
Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/x/insomnia")
);
} }
#[test] #[test]
fn config_dir_insomnia_home_outranks_xdg() { fn config_dir_insomnia_home_outranks_xdg() {
let _g = EnvGuard::new(&[ assert_eq!(
("HOME", Some("/h")), resolve_config_dir_from_parts(
("XDG_CONFIG_HOME", Some("/x")), None,
("INSOMNIA_HOME", Some("/sand")), Some(PathBuf::from("/sand")),
]); Some(PathBuf::from("/x")),
assert_eq!(config_dir().unwrap(), PathBuf::from("/sand/config")); Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/sand/config")
);
} }
#[test] #[test]
fn config_dir_explicit_wins_over_insomnia_home() { fn config_dir_explicit_wins_over_insomnia_home() {
let _g = EnvGuard::new(&[ assert_eq!(
("HOME", Some("/h")), resolve_config_dir_from_parts(
("INSOMNIA_HOME", Some("/sand")), Some(PathBuf::from("/explicit-cfg")),
("INSOMNIA_CONFIG_DIR", Some("/explicit-cfg")), Some(PathBuf::from("/sand")),
]); None,
assert_eq!(config_dir().unwrap(), PathBuf::from("/explicit-cfg")); Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/explicit-cfg")
);
} }
#[test] #[test]
fn data_dir_default_is_dot_insomnia() { fn data_dir_default_is_dot_insomnia() {
let _g = EnvGuard::new(&[("HOME", Some("/h"))]); assert_eq!(
assert_eq!(data_dir().unwrap(), PathBuf::from("/h/.insomnia")); resolve_data_dir_from_parts(None, None, Some(PathBuf::from("/h"))).unwrap(),
PathBuf::from("/h/.insomnia")
);
} }
#[test] #[test]
fn data_dir_insomnia_home_is_data_dir_itself() { fn data_dir_insomnia_home_is_data_dir_itself() {
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("INSOMNIA_HOME", Some("/sand"))]); assert_eq!(
assert_eq!(data_dir().unwrap(), PathBuf::from("/sand")); resolve_data_dir_from_parts(
None,
Some(PathBuf::from("/sand")),
Some(PathBuf::from("/h"))
)
.unwrap(),
PathBuf::from("/sand")
);
}
#[test]
fn data_dir_explicit_wins_over_insomnia_home() {
assert_eq!(
resolve_data_dir_from_parts(
Some(PathBuf::from("/explicit-data")),
Some(PathBuf::from("/sand")),
Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/explicit-data")
);
} }
#[test] #[test]
fn runtime_dir_prefers_xdg_runtime_dir() { fn runtime_dir_prefers_xdg_runtime_dir() {
let _g = EnvGuard::new(&[
("HOME", Some("/h")),
("XDG_RUNTIME_DIR", Some("/xdg-runtime")),
]);
assert_eq!( assert_eq!(
runtime_dir().unwrap(), resolve_runtime_dir_from_parts(
None,
None,
Some(PathBuf::from("/xdg-runtime")),
Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/xdg-runtime/insomnia") PathBuf::from("/xdg-runtime/insomnia")
); );
} }
#[test] #[test]
fn runtime_dir_falls_back_to_dot_insomnia_run() { fn runtime_dir_falls_back_to_dot_insomnia_run() {
let _g = EnvGuard::new(&[("HOME", Some("/h"))]); assert_eq!(
assert_eq!(runtime_dir().unwrap(), PathBuf::from("/h/.insomnia/run")); resolve_runtime_dir_from_parts(None, None, None, Some(PathBuf::from("/h"))).unwrap(),
PathBuf::from("/h/.insomnia/run")
);
} }
#[test] #[test]
fn runtime_dir_insomnia_home_is_run_subdir() { fn runtime_dir_insomnia_home_is_run_subdir() {
let _g = EnvGuard::new(&[ assert_eq!(
("HOME", Some("/h")), resolve_runtime_dir_from_parts(
("XDG_RUNTIME_DIR", Some("/run/user/1000")), None,
("INSOMNIA_HOME", Some("/sand")), Some(PathBuf::from("/sand")),
]); Some(PathBuf::from("/run/user/1000")),
assert_eq!(runtime_dir().unwrap(), PathBuf::from("/sand/run")); Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/sand/run")
);
} }
#[test] #[test]
fn empty_env_treated_as_unset() { fn runtime_dir_explicit_wins_over_insomnia_home() {
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("XDG_CONFIG_HOME", Some(""))]); assert_eq!(
assert_eq!(config_dir().unwrap(), PathBuf::from("/h/.config/insomnia")); resolve_runtime_dir_from_parts(
Some(PathBuf::from("/explicit-run")),
Some(PathBuf::from("/sand")),
Some(PathBuf::from("/run/user/1000")),
Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/explicit-run")
);
}
#[test]
fn empty_env_value_treated_as_unset_before_path_resolution() {
let xdg_config_home = path_from_env_value(Some(""));
assert_eq!(
resolve_config_dir_from_parts(None, None, xdg_config_home, Some(PathBuf::from("/h")))
.unwrap(),
PathBuf::from("/h/.config/insomnia")
);
} }
#[test] #[test]
fn returns_none_when_nothing_set() { fn returns_none_when_nothing_set() {
let _g = EnvGuard::new(&[]); assert!(resolve_config_dir_from_parts(None, None, None, None).is_none());
assert!(config_dir().is_none()); assert!(resolve_data_dir_from_parts(None, None, None).is_none());
assert!(data_dir().is_none()); assert!(resolve_runtime_dir_from_parts(None, None, None, None).is_none());
assert!(runtime_dir().is_none());
} }
#[test] #[test]
fn well_known_files_compose_off_base_dirs() { fn well_known_files_compose_off_base_dirs() {
let _g = EnvGuard::new(&[("INSOMNIA_HOME", Some("/sand"))]); let config_dir = Some(PathBuf::from("/sand/config"));
let data_dir = Some(PathBuf::from("/sand"));
let runtime_dir = Some(PathBuf::from("/sand/run"));
assert_eq!( assert_eq!(
user_profiles_path().unwrap(), user_profiles_path_from_config_dir(config_dir.clone()).unwrap(),
PathBuf::from("/sand/config/profiles.toml") PathBuf::from("/sand/config/profiles.toml")
); );
assert_eq!( assert_eq!(
user_prompts_dir().unwrap(), user_prompts_dir_from_config_dir(config_dir.clone()).unwrap(),
PathBuf::from("/sand/config/prompts") PathBuf::from("/sand/config/prompts")
); );
assert_eq!( assert_eq!(
user_pack_file().unwrap(), user_pack_file_from_config_dir(config_dir.clone()).unwrap(),
PathBuf::from("/sand/config/prompts.toml") PathBuf::from("/sand/config/prompts.toml")
); );
assert_eq!( assert_eq!(
user_catalog_override("providers.toml").unwrap(), user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(),
PathBuf::from("/sand/config/providers.toml") PathBuf::from("/sand/config/providers.toml")
); );
assert_eq!(sessions_dir().unwrap(), PathBuf::from("/sand/sessions"));
assert_eq!( assert_eq!(
pod_registry_path().unwrap(), sessions_dir_from_data_dir(data_dir).unwrap(),
PathBuf::from("/sand/sessions")
);
assert_eq!(
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
PathBuf::from("/sand/run/pods.json") PathBuf::from("/sand/run/pods.json")
); );
assert_eq!( assert_eq!(
pod_runtime_dir("foo").unwrap(), pod_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
PathBuf::from("/sand/run/foo") PathBuf::from("/sand/run/foo")
); );
assert_eq!( assert_eq!(
pod_socket_path("foo").unwrap(), pod_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
PathBuf::from("/sand/run/foo/sock") PathBuf::from("/sand/run/foo/sock")
); );
} }

View File

@ -22,6 +22,8 @@ use crate::{
const PROFILE_FORMAT_V1: &str = "insomnia.lua-profile.v1"; const PROFILE_FORMAT_V1: &str = "insomnia.lua-profile.v1";
const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default";
const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua");
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
const DEFAULT_POD_NAME: &str = "insomnia"; const DEFAULT_POD_NAME: &str = "insomnia";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@ -126,7 +128,10 @@ pub enum ProfileSource {
Registry { Registry {
source: ProfileRegistrySource, source: ProfileRegistrySource,
name: String, name: String,
path: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<String>,
}, },
} }
@ -134,15 +139,62 @@ pub enum ProfileSource {
pub struct ProfileRegistryEntry { pub struct ProfileRegistryEntry {
pub source: ProfileRegistrySource, pub source: ProfileRegistrySource,
pub name: String, pub name: String,
pub path: PathBuf, pub path: Option<PathBuf>,
pub provenance: String,
pub description: Option<String>, pub description: Option<String>,
pub is_default: bool, pub is_default: bool,
artifact: ProfileRegistryArtifact,
} }
impl ProfileRegistryEntry { impl ProfileRegistryEntry {
pub fn qualified_name(&self) -> String { pub fn qualified_name(&self) -> String {
format!("{}:{}", self.source, self.name) format!("{}:{}", self.source, self.name)
} }
fn path(
source: ProfileRegistrySource,
name: String,
path: PathBuf,
description: Option<String>,
) -> Self {
let provenance = path.display().to_string();
Self {
source,
name,
path: Some(path.clone()),
provenance,
description,
is_default: false,
artifact: ProfileRegistryArtifact::Path(path),
}
}
fn embedded(
source: ProfileRegistrySource,
name: &'static str,
label: &'static str,
content: &'static str,
description: Option<String>,
) -> Self {
Self {
source,
name: name.to_string(),
path: None,
provenance: label.to_string(),
description,
is_default: false,
artifact: ProfileRegistryArtifact::Embedded { label, content },
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ProfileRegistryArtifact {
Path(PathBuf),
Embedded {
label: &'static str,
content: &'static str,
},
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@ -241,7 +293,6 @@ struct ProfileDefault {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProfileDiscovery { pub struct ProfileDiscovery {
builtin_dir: Option<PathBuf>,
user_config: Option<PathBuf>, user_config: Option<PathBuf>,
project_config: Option<PathBuf>, project_config: Option<PathBuf>,
} }
@ -249,27 +300,19 @@ pub struct ProfileDiscovery {
impl ProfileDiscovery { impl ProfileDiscovery {
pub fn for_cwd(cwd: &Path) -> Self { pub fn for_cwd(cwd: &Path) -> Self {
Self { Self {
builtin_dir: paths::builtin_profiles_dir(),
user_config: paths::user_profiles_path(), user_config: paths::user_profiles_path(),
project_config: find_project_profiles_from(cwd), project_config: find_project_profiles_from(cwd),
} }
} }
pub fn with_sources( pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
builtin_dir: Option<PathBuf>,
user_config: Option<PathBuf>,
project_config: Option<PathBuf>,
) -> Self {
Self { Self {
builtin_dir,
user_config, user_config,
project_config, project_config,
} }
} }
pub fn discover(&self) -> Result<ProfileRegistry, ProfileError> { pub fn discover(&self) -> Result<ProfileRegistry, ProfileError> {
let mut registry = ProfileRegistry::default(); let mut registry = ProfileRegistry::default();
if let Some(dir) = &self.builtin_dir { add_builtin_profiles(&mut registry);
discover_profile_dir(&mut registry, ProfileRegistrySource::Builtin, dir)?;
}
if let Some(path) = &self.user_config { if let Some(path) = &self.user_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::User, path)?; load_profile_registry_file(&mut registry, ProfileRegistrySource::User, path)?;
} }
@ -371,15 +414,27 @@ impl ProfileResolver {
)), )),
ProfileSelector::Named { .. } | ProfileSelector::Default => { ProfileSelector::Named { .. } | ProfileSelector::Default => {
let entry = registry.select(selector)?.clone(); let entry = registry.select(selector)?.clone();
self.resolve_path( let source = ProfileSource::Registry {
&entry.path, source: entry.source,
ProfileSource::Registry { name: entry.name.clone(),
source: entry.source, path: entry.path.as_deref().map(absolutize).transpose()?,
name: entry.name, provenance: (entry.path.is_none()).then(|| entry.provenance.clone()),
path: absolutize(&entry.path)?, };
}, self.resolve_registry_entry(&entry, source, options)
options, }
) }
}
fn resolve_registry_entry(
&self,
entry: &ProfileRegistryEntry,
source: ProfileSource,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
match &entry.artifact {
ProfileRegistryArtifact::Path(path) => self.resolve_path(path, source, options),
ProfileRegistryArtifact::Embedded { label, content } => {
self.resolve_embedded_profile(label, content, source, options)
} }
} }
} }
@ -431,6 +486,30 @@ impl ProfileResolver {
raw_artifact, raw_artifact,
) )
} }
fn resolve_embedded_profile(
&self,
label: &'static str,
content: &'static str,
source: ProfileSource,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
let workspace_base = absolutize(
self.workspace_base
.as_deref()
.unwrap_or_else(|| Path::new(".")),
)?;
let lua_value = evaluate_embedded_lua_profile(label, content)?;
let raw_artifact = lua_value.clone();
resolve_lua_profile_value(
source,
&workspace_base,
&workspace_base,
options,
lua_value,
raw_artifact,
)
}
} }
fn resolve_lua_profile_value( fn resolve_lua_profile_value(
@ -591,13 +670,12 @@ fn load_profile_registry_file(
let base = path.parent().unwrap_or_else(|| Path::new(".")); let base = path.parent().unwrap_or_else(|| Path::new("."));
for (name, entry_config) in config.profile { for (name, entry_config) in config.profile {
let (entry_path, description) = entry_config.into_parts(); let (entry_path, description) = entry_config.into_parts();
registry.push_entry(ProfileRegistryEntry { registry.push_entry(ProfileRegistryEntry::path(
source, source,
name, name,
path: join_if_relative(base, &entry_path), join_if_relative(base, &entry_path),
description, description,
is_default: false, ));
});
} }
if let Some(default) = config.default { if let Some(default) = config.default {
let (default_source, default_name) = parse_profile_ref(&default); let (default_source, default_name) = parse_profile_ref(&default);
@ -625,49 +703,14 @@ fn find_project_profiles_from(start: &Path) -> Option<PathBuf> {
None None
} }
fn discover_profile_dir( fn add_builtin_profiles(registry: &mut ProfileRegistry) {
registry: &mut ProfileRegistry, registry.push_entry(ProfileRegistryEntry::embedded(
source: ProfileRegistrySource, ProfileRegistrySource::Builtin,
dir: &Path, BUILTIN_DEFAULT_PROFILE_NAME,
) -> Result<(), ProfileError> { "builtin:default",
if !dir.is_dir() { BUILTIN_DEFAULT_PROFILE,
return Ok(()); Some("Bundled default Insomnia coding profile".into()),
} ));
for entry in std::fs::read_dir(dir).map_err(|source| ProfileError::ConfigRead {
path: dir.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ProfileError::ConfigRead {
path: dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("lua") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
registry.push_entry(ProfileRegistryEntry {
source,
name: name.to_string(),
path,
description: None,
is_default: false,
});
}
} else if path.is_dir() {
let profile = path.join("profile.lua");
if profile.is_file()
&& let Some(name) = path.file_name().and_then(|s| s.to_str())
{
registry.push_entry(ProfileRegistryEntry {
source,
name: name.to_string(),
path: profile,
description: None,
is_default: false,
});
}
}
}
Ok(())
} }
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) { fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
@ -687,15 +730,38 @@ fn evaluate_lua_profile(
path: path.to_path_buf(), path: path.to_path_buf(),
source, source,
})?; })?;
evaluate_lua_profile_source(
&content,
path.display().to_string(),
LocalModuleRoot::Filesystem(module_root.to_path_buf()),
)
}
fn evaluate_embedded_lua_profile(
label: &'static str,
content: &'static str,
) -> Result<serde_json::Value, ProfileError> {
evaluate_lua_profile_source(
content,
label.to_string(),
LocalModuleRoot::Disabled { label },
)
}
fn evaluate_lua_profile_source(
content: &str,
chunk_name: String,
module_root: LocalModuleRoot,
) -> Result<serde_json::Value, ProfileError> {
let lua = Lua::new_with( let lua = Lua::new_with(
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8, StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8,
LuaOptions::default(), LuaOptions::default(),
) )
.map_err(ProfileError::Lua)?; .map_err(ProfileError::Lua)?;
install_lua_api(&lua, module_root.to_path_buf())?; install_lua_api(&lua, module_root)?;
let value: LuaValue = lua let value: LuaValue = lua
.load(&content) .load(content)
.set_name(path.display().to_string()) .set_name(chunk_name)
.eval() .eval()
.map_err(ProfileError::Lua)?; .map_err(ProfileError::Lua)?;
match value { match value {
@ -706,7 +772,7 @@ fn evaluate_lua_profile(
} }
} }
fn install_lua_api(lua: &Lua, module_root: PathBuf) -> Result<(), ProfileError> { fn install_lua_api(lua: &Lua, module_root: LocalModuleRoot) -> Result<(), ProfileError> {
let loader = Rc::new(RefCell::new(LocalModuleLoader { let loader = Rc::new(RefCell::new(LocalModuleLoader {
root: module_root, root: module_root,
cache: HashMap::new(), cache: HashMap::new(),
@ -743,11 +809,16 @@ fn install_lua_api(lua: &Lua, module_root: PathBuf) -> Result<(), ProfileError>
} }
struct LocalModuleLoader { struct LocalModuleLoader {
root: PathBuf, root: LocalModuleRoot,
cache: HashMap<String, RegistryKey>, cache: HashMap<String, RegistryKey>,
loading: HashSet<String>, loading: HashSet<String>,
} }
enum LocalModuleRoot {
Filesystem(PathBuf),
Disabled { label: &'static str },
}
fn require_module( fn require_module(
lua: &Lua, lua: &Lua,
loader: &Rc<RefCell<LocalModuleLoader>>, loader: &Rc<RefCell<LocalModuleLoader>>,
@ -773,7 +844,19 @@ fn require_module(
))); )));
} }
} }
let path = local_module_path(&loader.borrow().root, name).map_err(mlua::Error::RuntimeError)?; let path = {
let state = loader.borrow();
match &state.root {
LocalModuleRoot::Filesystem(root) => {
local_module_path(root, name).map_err(mlua::Error::RuntimeError)?
}
LocalModuleRoot::Disabled { label } => {
return Err(mlua::Error::RuntimeError(format!(
"local require `{name}` is not available for embedded profile `{label}`"
)));
}
}
};
let content = std::fs::read_to_string(&path).map_err(|e| { let content = std::fs::read_to_string(&path).map_err(|e| {
mlua::Error::RuntimeError(format!( mlua::Error::RuntimeError(format!(
"failed to read local module `{name}` ({}): {e}", "failed to read local module `{name}` ({}): {e}",
@ -1042,9 +1125,7 @@ fn model_context_window(model: Option<&ModelManifest>) -> Option<u64> {
} }
fn builtin_model_context_window(reference: &str) -> Option<u64> { fn builtin_model_context_window(reference: &str) -> Option<u64> {
let (provider, model_id) = reference.split_once('/')?; let (provider, model_id) = reference.split_once('/')?;
let path = paths::resource_dir()?.join("models").join("builtin.toml"); let parsed: toml::Value = toml::from_str(BUILTIN_MODEL_CATALOG).ok()?;
let content = std::fs::read_to_string(path).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
for entry in parsed.get("model")?.as_array()? { for entry in parsed.get("model")?.as_array()? {
let table = entry.as_table()?; let table = entry.as_table()?;
if table.get("provider")?.as_str()? == provider && table.get("id")?.as_str()? == model_id { if table.get("provider")?.as_str()? == provider && table.get("id")?.as_str()? == model_id {
@ -1187,58 +1268,8 @@ pub enum ProfileError {
mod tests { mod tests {
use super::*; use super::*;
use crate::{ReasoningControl, ReasoningEffort, SchemeKind}; use crate::{ReasoningControl, ReasoningEffort, SchemeKind};
use std::sync::{Mutex, MutexGuard, OnceLock};
use tempfile::TempDir; use tempfile::TempDir;
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
struct EnvGuard {
vars: Vec<(&'static str, Option<String>)>,
_lock: MutexGuard<'static, ()>,
}
impl EnvGuard {
fn new(overrides: &[(&'static str, Option<&str>)]) -> Self {
let lock = env_lock();
let names = [
"INSOMNIA_CONFIG_DIR",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME",
"XDG_CONFIG_HOME",
"HOME",
];
let saved: Vec<_> = names.iter().map(|n| (*n, std::env::var(n).ok())).collect();
unsafe {
for (n, _) in &saved {
std::env::remove_var(n);
}
for (n, v) in overrides {
if let Some(v) = v {
std::env::set_var(n, v);
}
}
}
Self {
vars: saved,
_lock: lock,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
for (n, v) in &self.vars {
match v {
Some(v) => std::env::set_var(n, v),
None => std::env::remove_var(n),
}
}
}
}
}
fn write_profile(dir: &Path, name: &str, body: &str) -> PathBuf { fn write_profile(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name); let path = dir.join(name);
std::fs::write(&path, body).unwrap(); std::fs::write(&path, body).unwrap();
@ -1266,14 +1297,15 @@ mod tests {
} }
#[test] #[test]
fn builtin_default_profile_is_registered_as_default() { fn builtin_default_profile_is_registered_as_default() {
let registry = ProfileDiscovery::with_sources(paths::builtin_profiles_dir(), None, None) let registry = ProfileDiscovery::with_sources(None, None)
.discover() .discover()
.unwrap(); .unwrap();
let default = registry.default_entry().unwrap(); let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Builtin); assert_eq!(default.source, ProfileRegistrySource::Builtin);
assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME); assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME);
assert!(default.is_default); assert!(default.is_default);
assert!(default.path.ends_with("resources/profiles/default.lua")); assert_eq!(default.path, None);
assert_eq!(default.provenance, "builtin:default");
} }
#[test] #[test]
fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() { fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() {
@ -1458,6 +1490,15 @@ return profile {
resolved.profile.as_ref().unwrap().name.as_deref(), resolved.profile.as_ref().unwrap().name.as_deref(),
Some("default") Some("default")
); );
assert_eq!(
resolved.source,
ProfileSource::Registry {
source: ProfileRegistrySource::Builtin,
name: "default".into(),
path: None,
provenance: Some("builtin:default".into()),
}
);
} }
#[test] #[test]
fn unsupported_profile_extension_has_clear_diagnostic() { fn unsupported_profile_extension_has_clear_diagnostic() {
@ -1486,14 +1527,19 @@ return profile {
) )
.unwrap(); .unwrap();
std::fs::write(&project_config, "default = \"project:coder\"\n[profile.coder]\npath = \"profiles/project-coder.lua\"\ndescription = \"Project coder\"\n").unwrap(); std::fs::write(&project_config, "default = \"project:coder\"\n[profile.coder]\npath = \"profiles/project-coder.lua\"\ndescription = \"Project coder\"\n").unwrap();
let registry = let registry = ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config)) .discover()
.discover() .unwrap();
.unwrap();
let default = registry.default_entry().unwrap(); let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Project); assert_eq!(default.source, ProfileRegistrySource::Project);
assert_eq!(default.name, "coder"); assert_eq!(default.name, "coder");
assert!(default.path.ends_with("profiles/project-coder.lua")); assert!(
default
.path
.as_ref()
.unwrap()
.ends_with("profiles/project-coder.lua")
);
} }
#[test] #[test]
fn default_marks_direct_profile_entry() { fn default_marks_direct_profile_entry() {
@ -1506,7 +1552,7 @@ return profile {
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n", "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n",
) )
.unwrap(); .unwrap();
let registry = ProfileDiscovery::with_sources(None, None, Some(project_config)) let registry = ProfileDiscovery::with_sources(None, Some(project_config))
.discover() .discover()
.unwrap(); .unwrap();
let default = registry.default_entry().unwrap(); let default = registry.default_entry().unwrap();
@ -1525,20 +1571,18 @@ return profile {
#[test] #[test]
fn unqualified_ambiguous_names_fail_closed() { fn unqualified_ambiguous_names_fail_closed() {
let mut registry = ProfileRegistry::default(); let mut registry = ProfileRegistry::default();
registry.push_entry(ProfileRegistryEntry { registry.push_entry(ProfileRegistryEntry::path(
source: ProfileRegistrySource::User, ProfileRegistrySource::User,
name: "coder".to_string(), "coder".to_string(),
path: PathBuf::from("/user/coder.lua"), PathBuf::from("/user/coder.lua"),
description: None, None,
is_default: false, ));
}); registry.push_entry(ProfileRegistryEntry::path(
registry.push_entry(ProfileRegistryEntry { ProfileRegistrySource::Project,
source: ProfileRegistrySource::Project, "coder".to_string(),
name: "coder".to_string(), PathBuf::from("/project/coder.lua"),
path: PathBuf::from("/project/coder.lua"), None,
description: None, ));
is_default: false,
});
let err = registry let err = registry
.select(&ProfileSelector::named("coder")) .select(&ProfileSelector::named("coder"))
.unwrap_err(); .unwrap_err();
@ -1549,6 +1593,9 @@ return profile {
"coder", "coder",
)) ))
.unwrap(); .unwrap();
assert_eq!(selected.path, PathBuf::from("/project/coder.lua")); assert_eq!(
selected.path.as_deref(),
Some(Path::new("/project/coder.lua"))
);
} }
} }

View File

@ -14,7 +14,7 @@ pod-store = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
protocol = { workspace = true } protocol = { workspace = true }
provider = { workspace = true } provider = { workspace = true }
insomnia = { workspace = true } client = { workspace = true }
pod-registry = { workspace = true } pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@ -14,7 +14,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use insomnia::PodRuntimeCommand; use client::PodRuntimeCommand;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{Permission, ScopeRule}; use manifest::{Permission, ScopeRule};
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
@ -346,7 +346,13 @@ where
command.arg("--store").arg(store_dir); command.arg("--store").arg(store_dir);
} }
let mut child = command.spawn().map_err(PodDiscoveryError::RestoreSpawn)?; let mut child =
command
.spawn()
.map_err(|source| PodDiscoveryError::RestoreLaunchFailed {
command: runtime_command.clone(),
source,
})?;
let deadline = tokio::time::Instant::now() + RESTORE_START_TIMEOUT; let deadline = tokio::time::Instant::now() + RESTORE_START_TIMEOUT;
loop { loop {
if probe_socket(socket_path).await.reachable { if probe_socket(socket_path).await.reachable {
@ -545,6 +551,12 @@ pub enum PodDiscoveryError {
ScopeLock(#[from] pod_registry::ScopeLockError), ScopeLock(#[from] pod_registry::ScopeLockError),
#[error("failed to launch restore process: {0}")] #[error("failed to launch restore process: {0}")]
RestoreSpawn(io::Error), RestoreSpawn(io::Error),
#[error("failed to launch restore runtime command `{command}`: {source}")]
RestoreLaunchFailed {
command: PodRuntimeCommand,
#[source]
source: io::Error,
},
#[error("restore process exited before socket became reachable: {status}")] #[error("restore process exited before socket became reachable: {status}")]
RestoreExited { status: std::process::ExitStatus }, RestoreExited { status: std::process::ExitStatus },
#[error("restore process did not become reachable before timeout")] #[error("restore process did not become reachable before timeout")]
@ -779,6 +791,7 @@ fn discovery_error_to_tool_error(error: PodDiscoveryError) -> ToolError {
| PodDiscoveryError::PodStore(_) | PodDiscoveryError::PodStore(_)
| PodDiscoveryError::ScopeLock(_) | PodDiscoveryError::ScopeLock(_)
| PodDiscoveryError::RestoreSpawn(_) | PodDiscoveryError::RestoreSpawn(_)
| PodDiscoveryError::RestoreLaunchFailed { .. }
| PodDiscoveryError::RestoreExited { .. } | PodDiscoveryError::RestoreExited { .. }
| PodDiscoveryError::RestoreTimeout => ToolError::ExecutionFailed(error.to_string()), | PodDiscoveryError::RestoreTimeout => ToolError::ExecutionFailed(error.to_string()),
} }

View File

@ -12,7 +12,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use insomnia::PodRuntimeCommand; use client::PodRuntimeCommand;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{ use manifest::{
CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial, CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial,
@ -220,6 +220,9 @@ pub struct SpawnPodTool {
/// Directory the spawned Pod should run in when the LLM did not /// Directory the spawned Pod should run in when the LLM did not
/// override it. Defaults to the spawner's pwd — see module docs. /// override it. Defaults to the spawner's pwd — see module docs.
spawner_pwd: PathBuf, spawner_pwd: PathBuf,
/// Optional typed runtime command injected by tests. Production resolves
/// the runtime command from `std::env::current_exe()` at launch time.
runtime_command: Option<PodRuntimeCommand>,
/// Shared registry of spawned children, also used by the /// Shared registry of spawned children, also used by the
/// pod-comm tools (`SendToPod` / `ReadPodOutput` / `StopPod`) and by /// pod-comm tools (`SendToPod` / `ReadPodOutput` / `StopPod`) and by
/// Pod discovery. Writes the list to runtime and durable Pod state on /// Pod discovery. Writes the list to runtime and durable Pod state on
@ -258,12 +261,14 @@ impl SpawnPodTool {
spawner_manifest: PodManifest, spawner_manifest: PodManifest,
available_profiles: AvailableProfiles, available_profiles: AvailableProfiles,
spawner_scope: SharedScope, spawner_scope: SharedScope,
runtime_command: Option<PodRuntimeCommand>,
) -> Self { ) -> Self {
Self { Self {
spawner_name, spawner_name,
callback_socket, callback_socket,
runtime_base, runtime_base,
spawner_pwd, spawner_pwd,
runtime_command,
registry, registry,
parent_socket, parent_socket,
spawner_manifest, spawner_manifest,
@ -409,9 +414,14 @@ impl SpawnPodTool {
spawn_config_json: &str, spawn_config_json: &str,
predicted_socket: &Path, predicted_socket: &Path,
) -> Result<(), ToolError> { ) -> Result<(), ToolError> {
let runtime_command = PodRuntimeCommand::resolve().map_err(|error| { let runtime_command = match &self.runtime_command {
ToolError::ExecutionFailed(format!("failed to resolve Pod runtime command: {error}")) Some(command) => command.clone(),
})?; None => PodRuntimeCommand::resolve().map_err(|error| {
ToolError::ExecutionFailed(format!(
"failed to resolve Pod runtime command: {error}"
))
})?,
};
// Pre-create the child's runtime dir so we have a stable place to // Pre-create the child's runtime dir so we have a stable place to
// capture its stderr before it has had a chance to bind anything. // capture its stderr before it has had a chance to bind anything.
@ -764,6 +774,59 @@ pub fn spawn_pod_tool(
spawner_manifest: PodManifest, spawner_manifest: PodManifest,
spawner_scope: SharedScope, spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>, prompts: Arc<PromptCatalog>,
) -> ToolDefinition {
spawn_pod_tool_impl(
spawner_name,
callback_socket,
runtime_base,
spawner_pwd,
registry,
parent_socket,
spawner_manifest,
spawner_scope,
prompts,
None,
)
}
#[doc(hidden)]
pub fn spawn_pod_tool_with_runtime_command(
spawner_name: String,
callback_socket: PathBuf,
runtime_base: PathBuf,
spawner_pwd: PathBuf,
registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
spawner_manifest: PodManifest,
spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>,
runtime_command: PodRuntimeCommand,
) -> ToolDefinition {
spawn_pod_tool_impl(
spawner_name,
callback_socket,
runtime_base,
spawner_pwd,
registry,
parent_socket,
spawner_manifest,
spawner_scope,
prompts,
Some(runtime_command),
)
}
fn spawn_pod_tool_impl(
spawner_name: String,
callback_socket: PathBuf,
runtime_base: PathBuf,
spawner_pwd: PathBuf,
registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
spawner_manifest: PodManifest,
spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>,
runtime_command: Option<PodRuntimeCommand>,
) -> ToolDefinition { ) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(SpawnPodInput); let schema = schemars::schema_for!(SpawnPodInput);
@ -794,6 +857,7 @@ pub fn spawn_pod_tool(
spawner_manifest.clone(), spawner_manifest.clone(),
available_profiles, available_profiles,
spawner_scope.clone(), spawner_scope.clone(),
runtime_command.clone(),
)); ));
(meta, tool) (meta, tool)
}) })
@ -868,7 +932,7 @@ mod tests {
std::fs::write(&registry_path, registry_toml).unwrap(); std::fs::write(&registry_path, registry_toml).unwrap();
AvailableProfiles { AvailableProfiles {
registry: Some( registry: Some(
ProfileDiscovery::with_sources(None, None, Some(registry_path)) ProfileDiscovery::with_sources(None, Some(registry_path))
.discover() .discover()
.unwrap(), .unwrap(),
), ),
@ -1236,7 +1300,7 @@ return profile {
assert!(invalid.contains("Use `default`, `inherit`")); assert!(invalid.contains("Use `default`, `inherit`"));
assert!(invalid.contains("`project:coder`")); assert!(invalid.contains("`project:coder`"));
let no_default = build_spawn_config_json_for_profile( let default_config = build_spawn_config_json_for_profile(
&parent, &parent,
&available, &available,
&project, &project,
@ -1245,17 +1309,15 @@ return profile {
&scope, &scope,
SpawnProfileSelector::Default, SpawnProfileSelector::Default,
) )
.unwrap_err(); .unwrap();
assert!(no_default.contains("no default profile"), "{no_default}"); assert!(default_config.contains("\"name\":\"child\""));
assert!(no_default.contains("Use `default`, `inherit`"));
assert!(no_default.contains("`project:coder`"));
let user_config = tmp.path().join("user-profiles.toml"); let user_config = tmp.path().join("user-profiles.toml");
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.lua\"\n").unwrap(); std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.lua\"\n").unwrap();
let project_config = project.join(".insomnia/profiles.toml"); let project_config = project.join(".insomnia/profiles.toml");
let ambiguous = AvailableProfiles { let ambiguous = AvailableProfiles {
registry: Some( registry: Some(
ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config)) ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
.discover() .discover()
.unwrap(), .unwrap(),
), ),

View File

@ -1,15 +1,15 @@
//! Integration tests for the `SpawnPod` tool. //! Integration tests for the `SpawnPod` tool.
//! //!
//! These tests exercise the tool's pod-registry delegation, subprocess //! These tests exercise the tool's pod-registry delegation, subprocess
//! launch, socket handoff, and `spawned_pods.json` write without relying //! launch, socket handoff, and `spawned_pods.json` write through an injected
//! on the real Pod runtime executable. `INSOMNIA_POD_COMMAND` is pointed at //! typed runtime command. The mock command exits immediately while a
//! `/bin/true` (which exits immediately) while a test-owned Unix //! test-owned Unix listener pre-binds the predicted socket path, so the tool
//! listener pre-binds the predicted socket path, so the tool sees the //! sees the "child" as live.
//! "child" as live.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex};
use client::PodRuntimeCommand;
use llm_worker::tool::{ToolError, ToolOutput}; use llm_worker::tool::{ToolError, ToolOutput};
use manifest::{ use manifest::{
AuthRef, ModelManifest, Permission, PodManifest, PodManifestConfig, PodMetaConfig, SchemeKind, AuthRef, ModelManifest, Permission, PodManifest, PodManifestConfig, PodMetaConfig, SchemeKind,
@ -18,7 +18,7 @@ use manifest::{
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord}; use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
use pod::runtime::pod_registry::{self, LockFileGuard}; use pod::runtime::pod_registry::{self, LockFileGuard};
use pod::spawn::registry::SpawnedPodRegistry; use pod::spawn::registry::SpawnedPodRegistry;
use pod::spawn::tool::spawn_pod_tool; use pod::spawn::tool::spawn_pod_tool_with_runtime_command;
use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Method}; use protocol::{Event, Method};
use serde_json::json; use serde_json::json;
@ -26,8 +26,8 @@ use std::sync::Arc;
use tempfile::TempDir; use tempfile::TempDir;
use tokio::net::UnixListener; use tokio::net::UnixListener;
/// Serialises tests that mutate `INSOMNIA_RUNTIME_DIR` / /// Serialises tests that mutate `INSOMNIA_RUNTIME_DIR` across the
/// `INSOMNIA_POD_COMMAND` across the thread-pooled test harness. /// thread-pooled test harness.
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(())); static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct EnvGuard { struct EnvGuard {
@ -141,11 +141,8 @@ fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle<Option<M
}) })
} }
fn point_runtime_command_at_true() { fn mock_runtime_command() -> PodRuntimeCommand {
let path = which_true(); PodRuntimeCommand::new(which_true(), Vec::new())
unsafe {
std::env::set_var("INSOMNIA_POD_COMMAND", &path);
}
} }
/// `/bin/true` only exists on FHS-compliant systems. Resolve it via PATH /// `/bin/true` only exists on FHS-compliant systems. Resolve it via PATH
@ -213,7 +210,6 @@ fn shared_scope_for(allow_root: &Path) -> SharedScope {
fn clear_env() { fn clear_env() {
unsafe { unsafe {
std::env::remove_var("INSOMNIA_RUNTIME_DIR"); std::env::remove_var("INSOMNIA_RUNTIME_DIR");
std::env::remove_var("INSOMNIA_POD_COMMAND");
} }
} }
@ -224,14 +220,13 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
let allow_root = TempDir::new().unwrap(); let allow_root = TempDir::new().unwrap();
let (_tmp, runtime_base, spawner_socket, spawner_rd) = let (_tmp, runtime_base, spawner_socket, spawner_rd) =
setup_spawner("root", allow_root.path()).await; setup_spawner("root", allow_root.path()).await;
point_runtime_command_at_true();
let (_predicted_socket, listener) = bind_mock_pod_socket(&runtime_base, "child").await; let (_predicted_socket, listener) = bind_mock_pod_socket(&runtime_base, "child").await;
let received = accept_one_method(listener); let received = accept_one_method(listener);
let registry = SpawnedPodRegistry::new(spawner_rd.clone()); let registry = SpawnedPodRegistry::new(spawner_rd.clone());
let spawner_scope = shared_scope_for(allow_root.path()); let spawner_scope = shared_scope_for(allow_root.path());
let def = spawn_pod_tool( let def = spawn_pod_tool_with_runtime_command(
"root".into(), "root".into(),
spawner_socket.clone(), spawner_socket.clone(),
runtime_base.clone(), runtime_base.clone(),
@ -241,6 +236,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
dummy_manifest(allow_root.path()), dummy_manifest(allow_root.path()),
spawner_scope.clone(), spawner_scope.clone(),
builtin_prompts(), builtin_prompts(),
mock_runtime_command(),
); );
let (_meta, tool) = def(); let (_meta, tool) = def();
@ -317,11 +313,10 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
let (_tmp, runtime_base, spawner_socket, spawner_rd) = let (_tmp, runtime_base, spawner_socket, spawner_rd) =
setup_spawner("root", allow_root.path()).await; setup_spawner("root", allow_root.path()).await;
point_runtime_command_at_true();
let registry = SpawnedPodRegistry::new(spawner_rd); let registry = SpawnedPodRegistry::new(spawner_rd);
let spawner_scope = shared_scope_for(allow_root.path()); let spawner_scope = shared_scope_for(allow_root.path());
let def = spawn_pod_tool( let def = spawn_pod_tool_with_runtime_command(
"root".into(), "root".into(),
spawner_socket, spawner_socket,
runtime_base, runtime_base,
@ -331,6 +326,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
dummy_manifest(allow_root.path()), dummy_manifest(allow_root.path()),
spawner_scope.clone(), spawner_scope.clone(),
builtin_prompts(), builtin_prompts(),
mock_runtime_command(),
); );
let (_meta, tool) = def(); let (_meta, tool) = def();
@ -379,7 +375,6 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
let allow_root = TempDir::new().unwrap(); let allow_root = TempDir::new().unwrap();
let (_tmp, runtime_base, spawner_socket, spawner_rd) = let (_tmp, runtime_base, spawner_socket, spawner_rd) =
setup_spawner("root", allow_root.path()).await; setup_spawner("root", allow_root.path()).await;
point_runtime_command_at_true();
// Deliberately do NOT bind a socket at the predicted path. The // Deliberately do NOT bind a socket at the predicted path. The
// tool's wait_for_socket should time out, triggering rollback. // tool's wait_for_socket should time out, triggering rollback.
@ -394,7 +389,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
let registry = SpawnedPodRegistry::new(spawner_rd); let registry = SpawnedPodRegistry::new(spawner_rd);
let spawner_scope = shared_scope_for(allow_root.path()); let spawner_scope = shared_scope_for(allow_root.path());
let def = spawn_pod_tool( let def = spawn_pod_tool_with_runtime_command(
"root".into(), "root".into(),
spawner_socket, spawner_socket,
runtime_base, runtime_base,
@ -404,6 +399,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
dummy_manifest(allow_root.path()), dummy_manifest(allow_root.path()),
spawner_scope.clone(), spawner_scope.clone(),
builtin_prompts(), builtin_prompts(),
mock_runtime_command(),
); );
let (_meta, tool) = def(); let (_meta, tool) = def();

View File

@ -234,6 +234,17 @@ async fn brave_search(
))); )));
} }
brave_search_with_api_key(client, cfg, &api_key, query, limit, offset).await
}
async fn brave_search_with_api_key(
client: &Client,
cfg: &WebSearchConfig,
api_key: &str,
query: &str,
limit: usize,
offset: usize,
) -> Result<ToolOutput, ToolError> {
let endpoint = cfg.base_url.as_deref().unwrap_or(BRAVE_SEARCH_ENDPOINT); let endpoint = cfg.base_url.as_deref().unwrap_or(BRAVE_SEARCH_ENDPOINT);
let mut url = Url::parse(endpoint).map_err(|err| { let mut url = Url::parse(endpoint).map_err(|err| {
ToolError::InvalidArgument(format!("invalid Brave search endpoint: {err}")) ToolError::InvalidArgument(format!("invalid Brave search endpoint: {err}"))
@ -1694,6 +1705,17 @@ mod tests {
})) }))
} }
fn brave_search_config(base_url: String) -> WebSearchConfig {
WebSearchConfig {
enabled: Some(true),
provider: Some(WebSearchProvider::Brave),
api_key_env: None,
timeout_secs: Some(2),
base_url: Some(base_url),
..Default::default()
}
}
#[test] #[test]
fn validates_brave_query_limits() { fn validates_brave_query_limits() {
validate_brave_query("hello world").unwrap(); validate_brave_query("hello world").unwrap();
@ -2019,30 +2041,16 @@ mod tests {
async fn searches_brave_with_bounded_output() { async fn searches_brave_with_bounded_output() {
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\",\"extra_snippets\":[\"Extra\"],\"language\":\"en\"}]}}"; let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\",\"extra_snippets\":[\"Extra\"],\"language\":\"en\"}]}}";
let (addr, captured) = serve_once_capture(response).await; let (addr, captured) = serve_once_capture(response).await;
let env_name = format!("INSOMNIA_TEST_BRAVE_KEY_{}", std::process::id());
unsafe { std::env::set_var(&env_name, "test-key") };
let tools = WebTools::new(Some(WebConfig { let tools = WebTools::new(Some(WebConfig {
enabled: Some(true), enabled: Some(true),
allow_private_addresses: Some(true), allow_private_addresses: Some(true),
search: Some(WebSearchConfig { search: None,
enabled: Some(true),
provider: Some(WebSearchProvider::Brave),
api_key_env: Some(env_name.clone()),
timeout_secs: Some(2),
base_url: Some(format!("http://{addr}/search")),
..Default::default()
}),
fetch: None, fetch: None,
})); }));
let result = tools let cfg = brave_search_config(format!("http://{addr}/search"));
.run_search(WebSearchInput { let result = brave_search_with_api_key(&tools.client, &cfg, "test-key", "insomnia", 1, 0)
query: "insomnia".into(),
limit: Some(1),
offset: Some(0),
})
.await .await
.unwrap(); .unwrap();
unsafe { std::env::remove_var(&env_name) };
let value: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap(); let value: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap();
let request = captured.lock().await.clone().unwrap(); let request = captured.lock().await.clone().unwrap();
assert!(request.starts_with("GET /search?q=insomnia&count=1&offset=0 ")); assert!(request.starts_with("GET /search?q=insomnia&count=1&offset=0 "));
@ -2065,29 +2073,16 @@ mod tests {
); );
let response: &'static str = Box::leak(response.into_boxed_str()); let response: &'static str = Box::leak(response.into_boxed_str());
let addr = serve_once(response).await; let addr = serve_once(response).await;
let env_name = format!("INSOMNIA_TEST_BRAVE_OVERSIZED_KEY_{}", std::process::id());
unsafe { std::env::set_var(&env_name, "test-key") };
let tools = WebTools::new(Some(WebConfig { let tools = WebTools::new(Some(WebConfig {
enabled: Some(true), enabled: Some(true),
allow_private_addresses: Some(true), allow_private_addresses: Some(true),
search: Some(WebSearchConfig { search: None,
enabled: Some(true),
provider: Some(WebSearchProvider::Brave),
api_key_env: Some(env_name.clone()),
base_url: Some(format!("http://{addr}/search")),
..Default::default()
}),
fetch: None, fetch: None,
})); }));
let err = tools let cfg = brave_search_config(format!("http://{addr}/search"));
.run_search(WebSearchInput { let err = brave_search_with_api_key(&tools.client, &cfg, "test-key", "insomnia", 1, 0)
query: "insomnia".into(),
limit: Some(1),
offset: Some(0),
})
.await .await
.unwrap_err(); .unwrap_err();
unsafe { std::env::remove_var(&env_name) };
assert!(err.to_string().contains("Content-Length")); assert!(err.to_string().contains("Content-Length"));
} }
} }

View File

@ -4,10 +4,6 @@ version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
[[bin]]
name = "insomnia"
path = "src/main.rs"
[dependencies] [dependencies]
client = { workspace = true } client = { workspace = true }
protocol = { workspace = true } protocol = { workspace = true }
@ -19,10 +15,8 @@ unicode-width = "0.2.2"
uuid = { workspace = true } uuid = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
memory = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
pod-store = { workspace = true } pod-store = { workspace = true }
pod = { workspace = true }
pod-registry = { workspace = true } pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
pulldown-cmark = { version = "0.13.3", default-features = false } pulldown-cmark = { version = "0.13.3", default-features = false }

View File

@ -16,7 +16,7 @@ use crate::command::{
use crate::input::InputBuffer; use crate::input::InputBuffer;
use crate::scroll::Scroll; use crate::scroll::Scroll;
use crate::task::TaskStore; use crate::task::TaskStore;
use crate::ui::Mode; use crate::view_mode::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandCompletionApply { pub enum CommandCompletionApply {

117
crates/tui/src/lib.rs Normal file
View File

@ -0,0 +1,117 @@
mod app;
mod block;
mod cache;
mod command;
mod input;
mod markdown;
mod multi_pod;
mod picker;
mod pod_list;
mod scroll;
mod single_pod;
mod spawn;
mod task;
mod tool;
mod ui;
mod view_mode;
use std::io;
use std::path::PathBuf;
use std::process::ExitCode;
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste};
use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
use session_store::SegmentId;
use client::PodRuntimeCommand;
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub mode: LaunchMode,
pub runtime_command: PodRuntimeCommand,
}
#[derive(Debug, Clone)]
pub enum LaunchMode {
Spawn {
profile: Option<String>,
},
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch the Pod runtime command with `--pod <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Pod.
PodName {
pod_name: String,
socket_override: Option<PathBuf>,
},
/// `insomnia -r` / `insomnia --resume`: open the Pod picker, then attach to the
/// selected live Pod or restore the selected stopped Pod by name.
Resume,
/// `insomnia --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession(SegmentId),
/// `insomnia --multi`: open the multi-Pod dashboard. This is intentionally
/// separate from `-r`/`--resume`, which keeps its single-Pod picker
/// meaning.
Multi,
}
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}");
return ExitCode::FAILURE;
}
if let Err(e) = execute!(io::stdout(), EnableBracketedPaste) {
let _ = disable_raw_mode();
eprintln!("insomnia: {e}");
return ExitCode::FAILURE;
}
let result = match mode {
LaunchMode::Spawn { profile } => {
single_pod::run_spawn(None, profile, runtime_command).await
}
LaunchMode::PodName {
pod_name,
socket_override,
} => single_pod::run_pod_name(pod_name, socket_override, runtime_command).await,
LaunchMode::Resume => single_pod::run_resume(runtime_command).await,
LaunchMode::ResumeWithSession(id) => {
single_pod::run_spawn(Some(id), None, runtime_command).await
}
LaunchMode::Multi => single_pod::run_multi(runtime_command).await,
};
// Always restore the terminal first so any pending eprintln below
// shows up cleanly in scrollback rather than inside an active
// alternate-screen buffer.
let mut stdout = io::stdout();
let _ = execute!(
stdout,
DisableMouseCapture,
LeaveAlternateScreen,
DisableBracketedPaste
);
let _ = disable_raw_mode();
let _ = execute!(stdout, crossterm::cursor::Show);
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
// SpawnError has already been painted into the inline
// viewport's final frame, so it's already visible in the
// user's scrollback — printing it again would be a noisy
// duplicate. Other errors (pod-name failures, terminal setup
// hiccups, etc.) need surfacing here.
if e.downcast_ref::<spawn::SpawnError>().is_none() {
eprintln!("insomnia: {e}");
}
ExitCode::FAILURE
}
}
}

View File

@ -1,23 +1,6 @@
mod app;
mod block;
mod cache;
mod command;
mod input;
mod markdown;
mod memory_lint;
mod multi_pod;
mod picker;
mod pod_list;
mod scroll;
mod spawn;
mod task;
mod tool;
mod ui;
use std::future::Future; use std::future::Future;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::{ use std::sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@ -26,24 +9,23 @@ use std::thread;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{ use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, self, DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyEvent,
Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, KeyModifiers, MouseEvent, MouseEventKind,
}; };
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use protocol::{Method, PodStatus}; use protocol::{Method, PodStatus};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SegmentId; use session_store::SegmentId;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use client::PodClient; use client::{PodClient, PodRuntimeCommand};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::picker::PickerOutcome; use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady}; use crate::spawn::{SpawnOutcome, SpawnReady};
use crate::{multi_pod, picker, spawn, ui};
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>; type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
@ -59,296 +41,10 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
}) })
} }
#[derive(Debug)] pub(crate) async fn run_pod_name(
enum Mode {
Spawn {
profile: Option<String>,
},
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch the Pod runtime command with `--pod <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Pod.
PodName {
pod_name: String,
socket_override: Option<PathBuf>,
},
/// `insomnia -r` / `insomnia --resume`: open the Pod picker, then attach to the
/// selected live Pod or restore the selected stopped Pod by name.
Resume,
/// `insomnia --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession(SegmentId),
/// `insomnia --multi`: open the multi-Pod dashboard. This is intentionally
/// 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<String>),
}
#[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<Mode, ParseError> {
parse_args_from(std::env::args().skip(1))
}
fn parse_args_from<I, S>(args: I) -> Result<Mode, ParseError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args: Vec<String> = 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<SegmentId> = None;
let mut pod: Option<String> = None;
let mut profile: Option<String> = None;
let mut socket_override: Option<PathBuf> = None;
let mut socket_seen = false;
let mut positional: Option<String> = 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::<SegmentId>()
.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;
}
if let Err(e) = enable_raw_mode() {
eprintln!("insomnia: failed to enter raw mode: {e}");
return ExitCode::FAILURE;
}
if let Err(e) = execute!(io::stdout(), EnableBracketedPaste) {
let _ = disable_raw_mode();
eprintln!("insomnia: {e}");
return ExitCode::FAILURE;
}
let result = match mode {
Mode::Spawn { profile } => run_spawn(None, profile).await,
Mode::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"),
};
// Always restore the terminal first so any pending eprintln below
// shows up cleanly in scrollback rather than inside an active
// alternate-screen buffer.
let mut stdout = io::stdout();
let _ = execute!(
stdout,
DisableMouseCapture,
LeaveAlternateScreen,
DisableBracketedPaste
);
let _ = disable_raw_mode();
let _ = execute!(stdout, crossterm::cursor::Show);
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
// SpawnError has already been painted into the inline
// viewport's final frame, so it's already visible in the
// user's scrollback — printing it again would be a noisy
// duplicate. Other errors (pod-name failures, terminal setup
// hiccups, etc.) need surfacing here.
if e.downcast_ref::<spawn::SpawnError>().is_none() {
eprintln!("insomnia: {e}");
}
ExitCode::FAILURE
}
}
}
async fn run_pod_name(
pod_name: String, pod_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
@ -356,7 +52,7 @@ async fn run_pod_name(
return Ok(()); 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::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), SpawnOutcome::Cancelled => return Ok(()),
}; };
@ -380,6 +76,7 @@ async fn run_connected_pod(
async fn run_pod_name_nested( async fn run_pod_name_nested(
terminal: &mut FullscreenTerminal, terminal: &mut FullscreenTerminal,
request: multi_pod::OpenPodRequest, request: multi_pod::OpenPodRequest,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let multi_pod::OpenPodRequest { let multi_pod::OpenPodRequest {
pod_name, pod_name,
@ -390,16 +87,17 @@ async fn run_pod_name_nested(
return run_connected_pod(terminal, pod_name, client).await; 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 run_ready_pod(terminal, ready).await
} }
async fn spawn_pod_name_from_fullscreen( async fn spawn_pod_name_from_fullscreen(
terminal: &mut FullscreenTerminal, terminal: &mut FullscreenTerminal,
pod_name: &str, pod_name: &str,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> { ) -> Result<SpawnReady, Box<dyn std::error::Error>> {
leave_fullscreen(terminal)?; 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)?; enter_fullscreen_existing(terminal)?;
terminal.clear()?; terminal.clear()?;
@ -463,7 +161,9 @@ async fn connect_live_pod(
.map(|client| (registry_socket, client)) .map(|client| (registry_socket, client))
} }
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> { pub(crate) async fn run_resume(
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
// Pick a Pod in its own inline viewport, dropping the viewport before // Pick a Pod in its own inline viewport, dropping the viewport before
// attaching/restoring so each phase gets fresh vertical room. // attaching/restoring so each phase gets fresh vertical room.
let (pod_name, socket_override) = match picker::run().await? { let (pod_name, socket_override) = match picker::run().await? {
@ -473,10 +173,12 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
} => (pod_name, socket_override), } => (pod_name, socket_override),
PickerOutcome::Cancelled => return Ok(()), 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<dyn std::error::Error>> { pub(crate) async fn run_multi(
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = multi_pod::load_app().await?; let mut app = multi_pod::load_app().await?;
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
@ -488,7 +190,7 @@ async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
} }
multi_pod::MultiPodOutcome::Open(request) => { multi_pod::MultiPodOutcome::Open(request) => {
let pod_name = request.pod_name.clone(); 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(())), Ok(()) => app.finish_open(&pod_name, Ok(())),
Err(error) if is_recoverable_multi_open_error(error.as_ref()) => { Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
app.finish_open(&pod_name, Err(error.as_ref())); app.finish_open(&pod_name, Err(error.as_ref()));
@ -508,11 +210,12 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>() error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
} }
async fn run_spawn( pub(crate) async fn run_spawn(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
profile: Option<String>, profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
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::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), SpawnOutcome::Cancelled => return Ok(()),
}; };
@ -1178,231 +881,6 @@ mod tests {
use super::*; use super::*;
use protocol::{Event, RewindTarget, RewindTargetId, Segment}; 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] #[tokio::test]
async fn terminal_event_is_selected_before_ready_pod_event() { async fn terminal_event_is_selected_before_ready_pod_event() {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();

View File

@ -15,7 +15,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; 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 crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery; use manifest::ProfileDiscovery;
use ratatui::Terminal; use ratatui::Terminal;
@ -76,6 +76,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn run( pub async fn run(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
profile: Option<String>, profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> { ) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?; let defaults = load_spawn_defaults()?;
let mut profile_choices = if resume_from.is_some() { 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)); form.message = Some(("starting pod...".to_string(), MessageKind::Progress));
terminal.draw(|f| draw_form(f, &form))?; 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) => { Ok(ready) => {
form.message = Some(( form.message = Some((
format!("ready: {} attaching...", ready.pod_name), format!("ready: {} attaching...", ready.pod_name),
@ -165,13 +166,16 @@ pub async fn run(
/// Launch a Pod runtime command with `--pod <name>` without opening the name dialog. The child Pod /// Launch a Pod runtime command with `--pod <name>` without opening the name dialog. The child Pod
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod /// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
/// from the default profile. /// from the default profile.
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> { pub async fn run_pod_name(
pod_name: String,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?; let defaults = load_spawn_defaults()?;
let mut form = form_for_pod_name(pod_name, defaults); let mut form = form_for_pod_name(pod_name, defaults);
let mut terminal = make_inline_terminal()?; let mut terminal = make_inline_terminal()?;
terminal.draw(|f| draw_form(f, &form))?; 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) => { Ok(ready) => {
form.message = Some(( form.message = Some((
format!("ready: {} attaching...", ready.pod_name), format!("ready: {} attaching...", ready.pod_name),
@ -360,8 +364,10 @@ fn sanitise_default_name(s: &str) -> String {
async fn wait_for_ready( async fn wait_for_ready(
terminal: &mut InlineTerminal, terminal: &mut InlineTerminal,
form: &mut Form, form: &mut Form,
runtime_command: &PodRuntimeCommand,
) -> Result<SpawnReady, SpawnError> { ) -> Result<SpawnReady, SpawnError> {
let config = SpawnConfig { let config = SpawnConfig {
runtime_command: runtime_command.clone(),
pod_name: form.name.clone(), pod_name: form.name.clone(),
profile: form.selected_profile_selector(), profile: form.selected_profile_selector(),
cwd: form.cwd.clone(), cwd: form.cwd.clone(),
@ -687,7 +693,10 @@ description = "Project coder"
let (choices, default_index) = profile_choices_for_cwd(&project); let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(choices[0].selector.as_deref(), Some("builtin:default")); 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!(default_index, 1);
assert_eq!(choices[1].selector.as_deref(), Some("project:coder")); assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
assert_eq!(choices[1].label, "project:coder (default) — Project coder"); assert_eq!(choices[1].label, "project:coder (default) — Project coder");

View File

@ -12,7 +12,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::block::{Block, ToolCallBlock, ToolCallState}; use crate::block::{Block, ToolCallBlock, ToolCallState};
use crate::cache::FileCache; use crate::cache::FileCache;
use crate::ui::Mode; use crate::view_mode::Mode;
/// Maximum body lines in normal mode for tool output previews. /// Maximum body lines in normal mode for tool output previews.
const NORMAL_MAX_BODY: usize = 5; const NORMAL_MAX_BODY: usize = 5;

View File

@ -31,36 +31,7 @@ use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label,
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
use crate::command::CommandCandidate; use crate::command::CommandCandidate;
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
use crate::view_mode::Mode;
/// Display density for the history view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Every block fully expanded.
Detail,
/// Completed blocks compressed to roughly 56 lines; in-progress
/// tool blocks stay in detail.
Normal,
/// Each block rendered as a single line.
Overview,
}
impl Mode {
pub fn cycle(self) -> Self {
match self {
Mode::Detail => Mode::Normal,
Mode::Normal => Mode::Overview,
Mode::Overview => Mode::Detail,
}
}
pub fn label(self) -> &'static str {
match self {
Mode::Detail => "detail",
Mode::Normal => "normal",
Mode::Overview => "overview",
}
}
}
pub fn draw(frame: &mut Frame, app: &mut App) { pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area(); let area = frame.area();

View File

@ -0,0 +1,29 @@
/// Display density for the history view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Every block fully expanded.
Detail,
/// Completed blocks compressed to roughly 56 lines; in-progress
/// tool blocks stay in detail.
Normal,
/// Each block rendered as a single line.
Overview,
}
impl Mode {
pub fn cycle(self) -> Self {
match self {
Mode::Detail => Mode::Normal,
Mode::Normal => Mode::Overview,
Mode::Overview => Mode::Detail,
}
}
pub fn label(self) -> &'static str {
match self {
Mode::Detail => "detail",
Mode::Normal => "normal",
Mode::Overview => "overview",
}
}
}

View File

@ -2,7 +2,7 @@
INSOMNIA では、プロセス境界で本当に必要な場合を除き、環境変数の利用を避ける。新しい ambient な入力を増やすより、明示的な profile / manifest / config file / typed secret reference / CLI argument を優先する。 INSOMNIA では、プロセス境界で本当に必要な場合を除き、環境変数の利用を避ける。新しい ambient な入力を増やすより、明示的な profile / manifest / config file / typed secret reference / CLI argument を優先する。
それでも、path discovery、runtime directory、package resource lookup、外部 provider の credential 慣習との移行互換のために、一部の環境変数はまだサポートしている。この文書に載せた環境変数は公開 surface として扱う。ただし、fallback 変数は独立した設定項目ではなく、対応する main key の解決順の一部として扱う。開発・テスト都合だけの環境変数は原則として追加せず、既存のものも削除する。 それでも、path discovery、runtime directory、外部 provider の credential 慣習との移行互換のために、一部の環境変数はまだサポートしている。この文書に載せた通常 runtime 用の環境変数は公開 surface として扱う。ただし、fallback 変数は独立した設定項目ではなく、対応する main key の解決順の一部として扱う。開発・テスト都合だけの環境変数は、通常ユーザー向け configuration として扱わない明確な escape hatch に限る。
## 原則 ## 原則
@ -23,7 +23,6 @@ Path 系の環境変数は論理的な key ごとに立項する。`XDG_*` や `
| `config_dir` | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config``$XDG_CONFIG_HOME/insomnia``$HOME/.config/insomnia` | 人が書く設定・override の置き場。`profiles.toml`、prompt override、model/provider override など。 | | `config_dir` | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config``$XDG_CONFIG_HOME/insomnia``$HOME/.config/insomnia` | 人が書く設定・override の置き場。`profiles.toml`、prompt override、model/provider override など。 |
| `data_dir` | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME``$HOME/.insomnia` | プログラムが書く永続データの置き場。session log、Pod metadata など、再起動後も restore / replay の根拠になるもの。通常ユーザー向けの primary knob ではなく、migration、test、isolated data store 用の advanced override。 | | `data_dir` | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME``$HOME/.insomnia` | プログラムが書く永続データの置き場。session log、Pod metadata など、再起動後も restore / replay の根拠になるもの。通常ユーザー向けの primary knob ではなく、migration、test、isolated data store 用の advanced override。 |
| `runtime_dir` | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run``$XDG_RUNTIME_DIR/insomnia``$HOME/.insomnia/run` | socket、pid/status file、live registry mirror など、再起動で捨ててよい runtime state の置き場。 | | `runtime_dir` | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run``$XDG_RUNTIME_DIR/insomnia``$HOME/.insomnia/run` | socket、pid/status file、live registry mirror など、再起動で捨ててよい runtime state の置き場。 |
| `resource_dir` | `INSOMNIA_RESOURCE_DIR` | installed executable から見た `share/insomnia/resources` → build tree の `resources/` | bundled prompts、builtin profiles、provider/model catalog など、package が所有する immutable-ish な builtin asset の置き場。通常 user configuration ではなく、packaging / development / debug 用の override。 |
空の path 環境変数は、`manifest::paths` では原則として unset 相当に扱う。 空の path 環境変数は、`manifest::paths` では原則として unset 相当に扱う。
@ -35,13 +34,9 @@ Path 系の環境変数は論理的な key ごとに立項する。`XDG_*` や `
このため、socket や pid file を `data_dir` に置かない。永続データと揮発 runtime state は分ける。 このため、socket や pid file を `data_dir` に置かない。永続データと揮発 runtime state は分ける。
### `resource_dir``config_dir` の違い ### Builtin assets と `config_dir`
`resource_dir` は package-owned builtin asset の場所である。installed package では `share/insomnia/resources` に置かれ、binary version と対応する。ユーザーが普段編集する場所ではない。 Builtin profiles and catalogs are embedded in the binary at build time. User/project-owned overrides remain under `config_dir` and project `.insomnia/` files such as `profiles.toml`; package runtime resource lookup is not a supported configuration surface.
`config_dir` は user/project-owned override の場所である。`profiles.toml` や prompt/model/provider override はここに置き、package update で上書きされない。
つまり、builtin fallback は `resource_dir`、user override は `config_dir` で扱う。`INSOMNIA_RESOURCE_DIR` を user configuration の代わりに使わない。
## Credential と外部 auth ## Credential と外部 auth
@ -61,9 +56,17 @@ Provider credential は、現在は manifest / profile / catalog の設定から
Credential env var は interoperability のために現時点では残っているが、長期的に望ましい secret mechanism ではない。現時点では適切なら `auth.file` を優先し、今後は typed secret reference へ寄せる。credential UX のために implicit `.env` loading を追加しないこと。project secret を漏らしやすく、profile ごとの credential model とも相性が悪い。 Credential env var は interoperability のために現時点では残っているが、長期的に望ましい secret mechanism ではない。現時点では適切なら `auth.file` を優先し、今後は typed secret reference へ寄せる。credential UX のために implicit `.env` loading を追加しないこと。project secret を漏らしやすく、profile ごとの credential model とも相性が悪い。
## Build / example / test 変数 ## Development-only escape hatches
これらは通常の application configuration ではない。test-only の user-facing env var は supported surface として立てず、既存の `INSOMNIA_TEST_*` も削除する。test が public env behavior を検証する必要がある場合だけ、shared guard / test-support crate で process environment mutation を閉じ込める。 これらは dogfooding / self-rebuild / fixture などの開発運用だけの逃げ道であり、通常ユーザー向けの configuration surface ではない。profile、manifest、CLI option の代替として案内しない。
| 変数 | Context | 備考 |
| --- | --- | --- |
| `INSOMNIA_POD_RUNTIME_COMMAND` | 開発中に起動中の `insomnia` binary が rebuild され、`std::env::current_exe()` が `target/debug/insomnia (deleted)` のような stale path を返す場合の Pod runtime executable override。 | Unset または empty の場合は既定どおり current executable に `pod` prefix argument を付けて起動する。Non-empty の場合は値を executable path としてそのまま使い、`pod` prefix argument は常に自動追加する。shell parsing や argument splitting は行わないため、値に flags や `pod` を含めない。 |
## Build / example variables
これらは通常の application configuration ではない。
| 変数 | Context | 備考 | | 変数 | Context | 備考 |
| --- | --- | --- | | --- | --- | --- |
@ -73,23 +76,14 @@ Credential env var は interoperability のために現時点では残ってい
| `RUST_LOG` | example / dev diagnostics。 | example CLI が tracing setup 経由で読む場合がある。 | | `RUST_LOG` | example / dev diagnostics。 | example CLI が tracing setup 経由で読む場合がある。 |
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` などの provider example vars | `llm-worker` や Pod example / fixture recorder。 | example code が `dotenv::dotenv().ok()` を呼ぶことがある。通常の `insomnia` runtime startup には適用されない。 | | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` などの provider example vars | `llm-worker` や Pod example / fixture recorder。 | example code が `dotenv::dotenv().ok()` を呼ぶことがある。通常の `insomnia` runtime startup には適用されない。 |
## deprecated または意図的に存在しない surface
- `INSOMNIA_USER_MANIFEST` は通常の profile-based Pod/TUI startup の一部ではない。one-file manifest の debug / compatibility path には `insomnia pod --manifest <PATH>` を使う。
- ambient `.insomnia/manifest.toml` discovery は通常の fresh startup の一部ではない。
- `INSOMNIA_POD_COMMAND` は single-binary 化に伴って削除する。Pod runtime は `insomnia pod ...` の typed command として起動する。
- `INSOMNIA_TEST_*` のような test-only 環境変数は supported surface にしない。既存利用も削除する。
- `insomnia-pod` は installed command ではない。Pod runtime は `insomnia pod ...` から起動する。
- 通常 runtime は `.env` ファイルを load しない。
## 整理方針 ## 整理方針
環境変数に関わるコードを触る場合は、以下を優先する。 環境変数に関わるコードを触る場合は、以下を優先する。
1. test-only env var を削除し、public env behavior を検証する test だけを shared guard / test-support crate に集約する。 1. fallback / precedence の test は、process environment を読ませず、直接入力を渡せる小さな pure helper で検証する。
2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。 2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。
3. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference へ寄せる。encrypted secret store 導入時に credential env var 依存を削除する。 3. test が process environment を変更するのは、process env から読む thin wrapper 自体を検証する場合や、subprocess isolation に必要な場合に限る。
4. `INSOMNIA_POD_COMMAND` は削除し、Pod runtime 起動は `current_exe() + ["pod"]` の typed command に一本化する。 4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference へ寄せる。encrypted secret store 導入時に credential env var 依存を削除する。
5. fallback env は独立した設定項目として増やさず、対応する main key の解決順として文書化する。 5. fallback env は独立した設定項目として増やさず、対応する main key の解決順として文書化する。
6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。 6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。
7. 外部 process integration が env inheritance / filtering を必要とする場合は、ambient な inherited process state に頼らず、明示的な policy boundary として設計する。 7. 外部 process integration が env inheritance / filtering を必要とする場合は、ambient な inherited process state に頼らず、明示的な policy boundary として設計する。

View File

@ -1,76 +0,0 @@
# Nix package
INSOMNIA provides a flake package for installing the user-facing `insomnia` command without relying on a source checkout at runtime. The Pod runtime still runs as a separate process through `insomnia pod ...`; the installed package does not expose a separate `insomnia-pod` command.
## Build
From the repository root:
```sh
nix build .#
```
The default package is implemented by `package.nix` and builds the Cargo package `tui` as the installed binary `insomnia`. The `pod` crate remains a library dependency that provides the Pod runtime entrypoint used by `insomnia pod ...`. The derivation uses the checked-in `Cargo.lock`, so Cargo dependencies are fetched by the normal Nix Rust packaging path instead of by network access during the build.
The package output contains:
- `bin/insomnia` — terminal UI and `insomnia pod ...` runtime entrypoint.
- `share/insomnia/resources/` — bundled runtime resources, including `resources/prompts/`.
- `share/doc/insomnia/nix.md` — this document.
- `share/doc/insomnia/environment.md` — environment-variable policy and supported variables.
## Run
After `nix build`:
```sh
./result/bin/insomnia pod --help
./result/bin/insomnia
```
With flakes:
```sh
nix run .#insomnia
nix run .#insomnia -- pod --help
```
`nix run .#` defaults to the TUI.
## Configuration discovery
The Nix package does not put user configuration, sessions, sockets, or other mutable state in the Nix store. The installed binary keeps the same path semantics as non-Nix builds:
| Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback |
| --- | --- | --- | --- |
| User config (`profiles.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
| Persistent data (`sessions/`, Pod metadata) | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | `$HOME/.insomnia` |
| Runtime state (sockets, lock files, live registry) | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia`, then `$HOME/.insomnia/run` |
Normal fresh startup is profile-based. The package ships a builtin default profile, user/project `profiles.toml` files may select or define profiles, and `insomnia pod --manifest <PATH>` remains a one-file compatibility/debug input. `INSOMNIA_USER_MANIFEST` and ambient `.insomnia/manifest.toml` discovery are not part of normal Pod/TUI startup. See [`environment.md`](environment.md) for the environment-variable policy; new configuration should prefer profiles/manifests/config files over additional environment variables.
## Validation
The package derivation has a credential-free install check that verifies:
- `insomnia pod --help` starts successfully.
- `insomnia` is installed and reaches argument parsing.
- `bin/insomnia-pod` is not installed.
- bundled prompt resources and this Nix usage document are present in the output.
For full validation before handing changes to review, run:
```sh
nix build .#
nix flake check
cargo fmt --check
cargo check -p tui -p pod -p client
```
These checks do not require provider credentials.
## Known limitations
- The package currently installs only the `insomnia` command; development-only wrappers from `devshell.nix` are not part of the installable package.
- The TUI does not currently expose a conventional `--help` / `--version` CLI path, so the package smoke check uses an argument-parse failure path for the TUI rather than launching an interactive session.
- Bundled resources are installed under `share/insomnia/resources/` for packaging completeness and inspection. Built-in prompt/resource loading remains governed by the existing application code and user/project override rules.

View File

@ -119,12 +119,12 @@ Use `--manifest` only when you need the complete low-level Manifest escape hatch
## Builtin defaults ## Builtin defaults
Base defaults that are independent of profile choice live in Rust constants under `crates/manifest/src/defaults.rs` and in `PodManifestConfig::builtin_defaults()`. The bundled default role profile lives at `resources/profiles/default.lua` and is discovered as `builtin:default`. Base defaults that are independent of profile choice live in Rust constants under `crates/manifest/src/defaults.rs` and in `PodManifestConfig::builtin_defaults()`. The default role profile is embedded from `resources/profiles/default.lua` at build time and is discovered as `builtin:default`.
デフォルト値を変更するときは、次のどちらを変更するのかを明確にする。 デフォルト値を変更するときは、次のどちらを変更するのかを明確にする。
- all manifests/profiles の baseline default: Rust defaults - all manifests/profiles の baseline default: Rust defaults
- ordinary dogfooding/default role: `resources/profiles/default.lua` - ordinary dogfooding/default role: embedded `builtin:default` profile sourced from `resources/profiles/default.lua`
## Path resolution ## Path resolution

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-fisV77ZqAPsI0eLZIqw06HTj1CfmnL3NBHhjruZPZUE="; cargoHash = "sha256-tYql+E6s7WeZ2vQSfjH0BzmXmNQaqm54VQy8mUnpzLg=";
depsExtraArgs = { depsExtraArgs = {
# nixpkgs 25.11's fetchCargoVendor still uses crates.io's API # nixpkgs 25.11's fetchCargoVendor still uses crates.io's API
@ -83,7 +83,7 @@ rustPlatform.buildRustPackage rec {
cargoBuildFlags = [ cargoBuildFlags = [
"-p" "-p"
"tui" "insomnia"
]; ];
# The package check is a credential-free install smoke check below. Running the # The package check is a credential-free install smoke check below. Running the
@ -92,10 +92,7 @@ rustPlatform.buildRustPackage rec {
doCheck = false; doCheck = false;
postInstall = '' postInstall = ''
install -Dm644 docs/nix.md "$out/share/doc/insomnia/nix.md"
install -Dm644 docs/environment.md "$out/share/doc/insomnia/environment.md" install -Dm644 docs/environment.md "$out/share/doc/insomnia/environment.md"
mkdir -p "$out/share/insomnia"
cp -R resources "$out/share/insomnia/resources"
''; '';
doInstallCheck = true; doInstallCheck = true;
@ -105,14 +102,13 @@ rustPlatform.buildRustPackage rec {
"$out/bin/insomnia" pod --help >/dev/null "$out/bin/insomnia" pod --help >/dev/null
test -x "$out/bin/insomnia" test -x "$out/bin/insomnia"
test ! -e "$out/bin/insomnia-pod" test ! -e "$out/bin/insomnia-pod"
test ! -e "$out/share/insomnia/resources"
if "$out/bin/insomnia" --session not-a-uuid 2>insomnia.err; then if "$out/bin/insomnia" --session not-a-uuid 2>insomnia.err; then
echo "insomnia unexpectedly accepted an invalid --session value" >&2 echo "insomnia unexpectedly accepted an invalid --session value" >&2
exit 1 exit 1
fi fi
grep -q "invalid --session UUID" insomnia.err grep -q "invalid --session UUID" insomnia.err
test -d "$out/share/insomnia/resources/prompts"
test -f "$out/share/doc/insomnia/nix.md"
test -f "$out/share/doc/insomnia/environment.md" test -f "$out/share/doc/insomnia/environment.md"
runHook postInstallCheck runHook postInstallCheck

View File

@ -2,12 +2,12 @@
id: 20260531-005557-single-binary-insomnia-cli id: 20260531-005557-single-binary-insomnia-cli
slug: single-binary-insomnia-cli slug: single-binary-insomnia-cli
title: CLI: migrate toward a single insomnia binary title: CLI: migrate toward a single insomnia binary
status: open status: closed
kind: task kind: task
priority: P2 priority: P2
labels: [cli, architecture, nix] labels: [cli, architecture, nix]
created_at: 2026-05-31T00:55:57Z created_at: 2026-05-31T00:55:57Z
updated_at: 2026-05-31T04:32:30Z updated_at: 2026-05-31T12:15:50Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -0,0 +1,27 @@
Completed the umbrella migration from the previous two-command installed layout toward a single primary `insomnia` executable.
Completed phases:
- `insomnia-pod-subcommand-runtime`: moved Pod runtime startup behind `pod::entrypoint` and added `insomnia pod ...` dispatch.
- `spawn-through-insomnia-pod-subcommand`: changed internal spawn/restore defaults to typed runtime command resolution using current executable plus the `pod` prefix argument.
- `remove-insomnia-pod-binary`: removed the long-term `insomnia-pod` binary/package/devshell/flake output.
- Follow-up cleanup removed `INSOMNIA_POD_COMMAND` and `INSOMNIA_RESOURCE_DIR`, keeping the runtime command/config surface narrower.
Outcome:
- The installed package exposes `bin/insomnia` only.
- `insomnia pod ...` is the Pod runtime entrypoint; Pods remain separate processes.
- Internal spawn/restore uses typed command construction rather than shell string parsing.
- `insomnia-pod` is not kept as a compatibility alias.
- Headless `insomnia memory lint` behavior remains part of the `insomnia` CLI surface.
Validation/evidence across completed phases included:
- `insomnia pod --help`
- focused parser/spawn/restore tests
- `cargo fmt --check`
- `cargo check -p tui -p pod -p client`
- `nix build .#insomnia`
- checks that `bin/insomnia-pod` is absent
- `./tickets.sh doctor`
- `git diff --check`
Follow-up intentionally remains separate:
- `insomnia-crate-cli-owner` now tracks the architectural cleanup where the `insomnia` crate owns the product CLI/binary entrypoint and `tui` becomes a library implementation crate. That is not required for this umbrella's original single-installed-binary migration to be complete.

View File

@ -36,4 +36,39 @@ Revised decision from user discussion:
Initial implementation should start by extracting the Pod runtime into a library entrypoint and adding `insomnia pod ...`; subsequent steps can migrate spawn defaults and remove `insomnia-pod` from packaging. Initial implementation should start by extracting the Pod runtime into a library entrypoint and adding `insomnia pod ...`; subsequent steps can migrate spawn defaults and remove `insomnia-pod` from packaging.
---
<!-- event: close author: hare at: 2026-05-31T12:15:50Z status: closed -->
## Closed
Completed the umbrella migration from the previous two-command installed layout toward a single primary `insomnia` executable.
Completed phases:
- `insomnia-pod-subcommand-runtime`: moved Pod runtime startup behind `pod::entrypoint` and added `insomnia pod ...` dispatch.
- `spawn-through-insomnia-pod-subcommand`: changed internal spawn/restore defaults to typed runtime command resolution using current executable plus the `pod` prefix argument.
- `remove-insomnia-pod-binary`: removed the long-term `insomnia-pod` binary/package/devshell/flake output.
- Follow-up cleanup removed `INSOMNIA_POD_COMMAND` and `INSOMNIA_RESOURCE_DIR`, keeping the runtime command/config surface narrower.
Outcome:
- The installed package exposes `bin/insomnia` only.
- `insomnia pod ...` is the Pod runtime entrypoint; Pods remain separate processes.
- Internal spawn/restore uses typed command construction rather than shell string parsing.
- `insomnia-pod` is not kept as a compatibility alias.
- Headless `insomnia memory lint` behavior remains part of the `insomnia` CLI surface.
Validation/evidence across completed phases included:
- `insomnia pod --help`
- focused parser/spawn/restore tests
- `cargo fmt --check`
- `cargo check -p tui -p pod -p client`
- `nix build .#insomnia`
- checks that `bin/insomnia-pod` is absent
- `./tickets.sh doctor`
- `git diff --check`
Follow-up intentionally remains separate:
- `insomnia-crate-cli-owner` now tracks the architectural cleanup where the `insomnia` crate owns the product CLI/binary entrypoint and `tui` becomes a library implementation crate. That is not required for this umbrella's original single-installed-binary migration to be complete.
--- ---

View File

@ -2,12 +2,12 @@
id: 20260531-074258-tui-extract-cli-parsing id: 20260531-074258-tui-extract-cli-parsing
slug: tui-extract-cli-parsing slug: tui-extract-cli-parsing
title: TUI: extract CLI parsing from main.rs title: TUI: extract CLI parsing from main.rs
status: open status: closed
kind: task kind: task
priority: P2 priority: P2
labels: [tui, cleanup] labels: [tui, cleanup]
created_at: 2026-05-31T07:42:58Z created_at: 2026-05-31T07:42:58Z
updated_at: 2026-05-31T07:42:58Z updated_at: 2026-05-31T13:38:30Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -0,0 +1,19 @@
This ticket is completed/superseded by `insomnia-crate-cli-owner`.
The original goal was to remove CLI parsing from `crates/tui/src/main.rs` so the TUI crate would stop owning top-level product CLI parsing. The later `insomnia-crate-cli-owner` implementation went further:
- the product binary moved from package `tui` to package `insomnia`;
- top-level CLI parsing and dispatch now live in `crates/insomnia/src/main.rs`;
- `tui` is now a library implementation crate with no product `main.rs`;
- `insomnia pod ...` and `insomnia memory lint ...` are routed by the `insomnia` crate;
- normal TUI launch is delegated to `tui::launch(...)`.
Because the TUI crate no longer owns the product entrypoint or CLI parser, there is no remaining `tui/src/main.rs` CLI parsing extraction to implement under this ticket. Further CLI parser cleanup, if desired, belongs to the `insomnia` crate, not this TUI cleanup ticket.
Validation evidence from `insomnia-crate-cli-owner` closure:
- `cargo test -p insomnia`
- CLI smoke for `--help`, `pod --help`, `memory lint --help`, invalid `--session`, and resume/Pod selection conflicts
- `cargo check -p client -p pod -p tui -p insomnia`
- `nix build .#insomnia`
- `./tickets.sh doctor`
- `git diff --check`

View File

@ -0,0 +1,34 @@
<!-- event: create author: tickets.sh at: 2026-05-31T07:42:58Z -->
## Created
Created by tickets.sh create.
---
<!-- event: close author: hare at: 2026-05-31T13:38:30Z status: closed -->
## Closed
This ticket is completed/superseded by `insomnia-crate-cli-owner`.
The original goal was to remove CLI parsing from `crates/tui/src/main.rs` so the TUI crate would stop owning top-level product CLI parsing. The later `insomnia-crate-cli-owner` implementation went further:
- the product binary moved from package `tui` to package `insomnia`;
- top-level CLI parsing and dispatch now live in `crates/insomnia/src/main.rs`;
- `tui` is now a library implementation crate with no product `main.rs`;
- `insomnia pod ...` and `insomnia memory lint ...` are routed by the `insomnia` crate;
- normal TUI launch is delegated to `tui::launch(...)`.
Because the TUI crate no longer owns the product entrypoint or CLI parser, there is no remaining `tui/src/main.rs` CLI parsing extraction to implement under this ticket. Further CLI parser cleanup, if desired, belongs to the `insomnia` crate, not this TUI cleanup ticket.
Validation evidence from `insomnia-crate-cli-owner` closure:
- `cargo test -p insomnia`
- CLI smoke for `--help`, `pod --help`, `memory lint --help`, invalid `--session`, and resume/Pod selection conflicts
- `cargo check -p client -p pod -p tui -p insomnia`
- `nix build .#insomnia`
- `./tickets.sh doctor`
- `git diff --check`
---

View File

@ -2,12 +2,12 @@
id: 20260531-074258-tui-extract-single-pod-runtime id: 20260531-074258-tui-extract-single-pod-runtime
slug: tui-extract-single-pod-runtime slug: tui-extract-single-pod-runtime
title: TUI: extract single-Pod runtime loop from main.rs title: TUI: extract single-Pod runtime loop from main.rs
status: open status: closed
kind: task kind: task
priority: P2 priority: P2
labels: [tui, cleanup] labels: [tui, cleanup]
created_at: 2026-05-31T07:42:58Z created_at: 2026-05-31T07:42:58Z
updated_at: 2026-05-31T07:42:58Z updated_at: 2026-05-31T13:57:02Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -0,0 +1,20 @@
Extracted the current single-Pod TUI runtime/event-loop implementation out of `crates/tui/src/lib.rs`.
Implementation:
- Added `crates/tui/src/single_pod.rs`.
- Moved single-Pod attach/spawn/resume orchestration, fullscreen terminal helpers, event loop/drain logic, key/mouse handling, compact/rewind/queue/interrupt handling, and related tests into `single_pod.rs`.
- Left `lib.rs` as the TUI library façade/high-level dispatcher with module declarations, `LaunchOptions`, `LaunchMode`, and `launch(...)`.
- Preserved public `tui::launch`, `LaunchOptions`, and `LaunchMode` behavior for the `insomnia` crate.
- Did not split `multi_pod.rs`, move render helpers, redesign keybindings, rewrite App state, or change Pod protocol/profile/manifest semantics.
Review:
- External reviewer `tui-runtime-reviewer-20260531` approved implementation commit `4d89718`.
- Reviewer noted one non-blocking follow-up: the thin `run_multi` bridge now lives in `single_pod.rs`; acceptable for this extraction, but could move to a small launch/runtime coordination module if dashboard orchestration grows.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p tui`
- `cargo check -p tui -p insomnia` (passed with existing dead-code warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `wc -l crates/tui/src/lib.rs crates/tui/src/single_pod.rs` showed `lib.rs` at 117 lines and `single_pod.rs` at 1765 lines.

View File

@ -0,0 +1,119 @@
<!-- event: create author: tickets.sh at: 2026-05-31T07:42:58Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-31T13:46:30Z -->
## Plan
Preflight classification: implementation-ready with updated current-code scope.
The original ticket was written before `insomnia-crate-cli-owner`, when `crates/tui/src/main.rs` owned both product CLI and single-Pod runtime loop. That product CLI entrypoint has since moved to the `insomnia` crate and `tui` is now library-only. The remaining cleanup target is therefore the same runtime/event-loop code now living in `crates/tui/src/lib.rs`, not an obsolete `main.rs` file.
Updated intent:
- Move the single-Pod TUI runtime/event-loop implementation out of `crates/tui/src/lib.rs` into a focused TUI module, likely `single_pod.rs`.
- Keep `lib.rs` as the library façade: module declarations, launch API/types, and high-level dispatch.
Requirements:
- Extract single-Pod runtime functions from `lib.rs` into a focused module while preserving behavior.
- Include related helpers only where mechanical and clear: event loop, key/mouse handling, stream/event drain helpers, and per-run connection orchestration.
- Keep terminal setup/restore behavior, input queueing, interruption, task reminders, manual rewind/compact handling, and Pod socket behavior unchanged.
- Do not move render helpers, split `multi_pod.rs`, redesign keybindings, or perform unrelated TUI module cleanup.
- Keep public `tui::launch`, `LaunchOptions`, and `LaunchMode` behavior stable for the `insomnia` crate.
Current code map:
- `crates/tui/src/lib.rs`: library façade plus large single-Pod runtime loop and helpers after CLI ownership migration.
- `crates/tui/src/multi_pod.rs`: dashboard runtime; out of scope except imports needed by `lib.rs`.
- `crates/tui/src/app.rs`, `input.rs`, `ui.rs`, `tool.rs`, `view_mode.rs`: single-Pod runtime dependencies; should be imported by the extracted module as needed.
Critical risks:
- Over-extracting unrelated launch/multi-Pod code and increasing churn.
- Accidentally changing key/mouse/event handling semantics while moving functions.
- Creating circular module visibility or broad `pub` exposure. Prefer `pub(crate)` only where `lib.rs` must call into `single_pod`.
Intent packet for coder:
Intent:
- Extract the single-Pod runtime loop from current `tui/src/lib.rs` into a focused module and leave `lib.rs` as a thin façade/high-level dispatcher.
Invariants:
- Behavior-preserving move.
- `tui` remains library-only; `insomnia` remains CLI owner.
- No Pod protocol/profile/manifest changes.
- No broad TUI layout refactor beyond this extraction.
Validation:
- `cargo fmt --check`
- focused `cargo test -p tui` tests for queueing/interrupt/compact/rewind/stream handling if available
- full `cargo test -p tui`
- `cargo check -p tui -p insomnia`
- `./tickets.sh doctor`
- `git diff --check`
---
<!-- event: review author: hare at: 2026-05-31T13:56:32Z status: approve -->
## Review: approve
External reviewer: `tui-runtime-reviewer-20260531`
Reviewed implementation commit: `4d89718` (`tui: extract single pod runtime`)
Verdict: approve
Summary:
- This is a behavior-preserving mechanical extraction.
- `crates/tui/src/lib.rs` is now a façade/high-level dispatcher.
- Single-Pod runtime/event-loop code now lives in `crates/tui/src/single_pod.rs`.
- Public `tui::launch`, `LaunchOptions`, and `LaunchMode` signatures remain stable.
Requirements mapping:
- `lib.rs` keeps module declarations, public launch API/types, and high-level mode dispatch.
- `single_pod.rs` owns Pod-name/spawn/resume attach paths, fullscreen terminal helpers, main loop/drain logic, key/mouse handling, and the focused tests that cover those behaviors.
- Terminal setup/restore behavior is preserved: raw mode/bracketed paste global setup remains in `lib.rs`, while fullscreen/alternate-screen helpers moved with single-Pod runtime code.
- Input queueing, interruption, task reminders, compact/rewind handling, and Pod socket behavior are moved with tests and unchanged by inspection.
- No render helper move, `multi_pod.rs` split, App rewrite, keybinding redesign, Pod protocol/profile/manifest change, crate rename, or CLI ownership regression was introduced.
Blockers: none.
Non-blocking follow-up:
- `single_pod.rs` now contains a thin `run_multi` dashboard bridge. This is acceptable here because it bridges dashboard open requests into private single-Pod attach helpers, but if dashboard orchestration grows a future cleanup could move that bridge into a small launch/runtime coordination module.
Validation adequacy:
- Coder validation covered fmt, full TUI tests, TUI/insomnia check, doctor, and diff check.
- Reviewer reran `cargo fmt --check`, `cargo test -p tui`, `cargo check -p tui -p insomnia`, `./tickets.sh doctor`, and `git diff --check HEAD^ HEAD`; all passed with existing dead-code warnings.
---
<!-- event: close author: hare at: 2026-05-31T13:57:02Z status: closed -->
## Closed
Extracted the current single-Pod TUI runtime/event-loop implementation out of `crates/tui/src/lib.rs`.
Implementation:
- Added `crates/tui/src/single_pod.rs`.
- Moved single-Pod attach/spawn/resume orchestration, fullscreen terminal helpers, event loop/drain logic, key/mouse handling, compact/rewind/queue/interrupt handling, and related tests into `single_pod.rs`.
- Left `lib.rs` as the TUI library façade/high-level dispatcher with module declarations, `LaunchOptions`, `LaunchMode`, and `launch(...)`.
- Preserved public `tui::launch`, `LaunchOptions`, and `LaunchMode` behavior for the `insomnia` crate.
- Did not split `multi_pod.rs`, move render helpers, redesign keybindings, rewrite App state, or change Pod protocol/profile/manifest semantics.
Review:
- External reviewer `tui-runtime-reviewer-20260531` approved implementation commit `4d89718`.
- Reviewer noted one non-blocking follow-up: the thin `run_multi` bridge now lives in `single_pod.rs`; acceptable for this extraction, but could move to a small launch/runtime coordination module if dashboard orchestration grows.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p tui`
- `cargo check -p tui -p insomnia` (passed with existing dead-code warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `wc -l crates/tui/src/lib.rs crates/tui/src/single_pod.rs` showed `lib.rs` at 117 lines and `single_pod.rs` at 1765 lines.
---

View File

@ -2,12 +2,12 @@
id: 20260531-074258-tui-move-view-mode-state id: 20260531-074258-tui-move-view-mode-state
slug: tui-move-view-mode-state slug: tui-move-view-mode-state
title: TUI: move view mode state out of ui module title: TUI: move view mode state out of ui module
status: open status: closed
kind: task kind: task
priority: P2 priority: P2
labels: [tui, cleanup] labels: [tui, cleanup]
created_at: 2026-05-31T07:42:58Z created_at: 2026-05-31T07:42:58Z
updated_at: 2026-05-31T07:42:58Z updated_at: 2026-05-31T13:45:39Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -0,0 +1,20 @@
Moved single-Pod view/history mode state out of the render module.
Implementation:
- Added `crates/tui/src/view_mode.rs`.
- Moved `Mode` from `ui.rs` to `view_mode.rs` without changing variants or methods.
- Updated `app.rs`, `ui.rs`, and `tool.rs` imports to use `crate::view_mode::Mode`.
- Removed the `app.rs -> ui.rs` dependency caused solely by `Mode`.
- Kept render/key/command behavior unchanged.
Review:
- External reviewer `tui-view-mode-reviewer-20260531` approved implementation commit `bc31bfa`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p tui mode`
- `cargo test -p tui`
- `cargo check -p tui` (passed with existing dead-code warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `rg "crate::ui::Mode|ui::Mode" crates/tui/src || true` produced no active source hits.

View File

@ -0,0 +1,66 @@
<!-- event: create author: tickets.sh at: 2026-05-31T07:42:58Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-05-31T13:45:04Z status: approve -->
## Review: approve
External reviewer: `tui-view-mode-reviewer-20260531`
Reviewed implementation commit: `bc31bfa` (`tui: move view mode state`)
Verdict: approve
Summary:
- Moved the single-Pod history display mode type from `ui.rs` to `view_mode.rs`.
- Kept the type name `Mode` and preserved variants/methods (`Detail`, `Normal`, `Overview`, `cycle()`, `label()`).
- Updated `app.rs`, `ui.rs`, and `tool.rs` to import `crate::view_mode::Mode`.
- Removed the state-model dependency from `app.rs` to the render module for this type.
Requirements mapping:
- `Mode` now lives in a focused state-oriented module.
- `app.rs -> ui.rs` dependency caused by `Mode` is removed.
- Rendering/key/command behavior is unchanged by inspection because the enum and methods are a straight move.
- No broad `App` privatization, render-tree move, app split, crate/package rename, or unrelated runtime extraction was introduced.
- No active `crate::ui::Mode` / `ui::Mode` references remain under `crates/tui/src`.
Blockers: none.
Non-blocking follow-ups: none.
Validation adequacy:
- Coder validation covered fmt, focused/full TUI tests, TUI check, doctor, diff-check, and `ui::Mode` grep.
- Reviewer additionally performed read-only diff/reference checks and found no issues.
---
<!-- event: close author: hare at: 2026-05-31T13:45:39Z status: closed -->
## Closed
Moved single-Pod view/history mode state out of the render module.
Implementation:
- Added `crates/tui/src/view_mode.rs`.
- Moved `Mode` from `ui.rs` to `view_mode.rs` without changing variants or methods.
- Updated `app.rs`, `ui.rs`, and `tool.rs` imports to use `crate::view_mode::Mode`.
- Removed the `app.rs -> ui.rs` dependency caused solely by `Mode`.
- Kept render/key/command behavior unchanged.
Review:
- External reviewer `tui-view-mode-reviewer-20260531` approved implementation commit `bc31bfa`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p tui mode`
- `cargo test -p tui`
- `cargo check -p tui` (passed with existing dead-code warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `rg "crate::ui::Mode|ui::Mode" crates/tui/src || true` produced no active source hits.
---

View File

@ -2,12 +2,12 @@
id: 20260531-085959-eliminate-test-only-env-vars id: 20260531-085959-eliminate-test-only-env-vars
slug: eliminate-test-only-env-vars slug: eliminate-test-only-env-vars
title: Tests: eliminate test-only environment variables title: Tests: eliminate test-only environment variables
status: open status: closed
kind: task kind: task
priority: P2 priority: P2
labels: [test, env, cleanup] labels: [test, env, cleanup]
created_at: 2026-05-31T08:59:59Z created_at: 2026-05-31T08:59:59Z
updated_at: 2026-05-31T08:59:59Z updated_at: 2026-05-31T10:04:28Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -0,0 +1,18 @@
Removed test-only environment-variable usage from active code.
Implementation:
- Removed `INSOMNIA_TEST_*` Brave WebSearch test key generation/dependency.
- Split Brave search request execution so tests can inject an API key directly into a private helper.
- Preserved production behavior: WebSearch still reads configured `web.search.api_key_env` and fails closed for missing/empty values.
- Updated `docs/environment.md` so test-only env vars are not listed as supported surface.
Review:
- External reviewer `eliminate-test-env-vars-reviewer-20260531` approved implementation commit `e64a5595956c970b090cdce851cc962e92723a97`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p tools`
- `cargo check -p tools` (passed with unrelated existing `llm-worker` dead_code warning)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n "INSOMNIA_TEST" -- ':!work-items' || true` produced no active references.

View File

@ -0,0 +1,64 @@
<!-- event: create author: tickets.sh at: 2026-05-31T08:59:59Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-05-31T10:03:55Z status: approve -->
## Review: approve
External reviewer: `eliminate-test-env-vars-reviewer-20260531`
Reviewed implementation commit: `e64a5595956c970b090cdce851cc962e92723a97` (`test: remove test-only env vars`)
Verdict: approve
Summary:
- WebSearch tests no longer generate or depend on `INSOMNIA_TEST_*` API-key env names.
- The request/search implementation was split so tests can inject an API key directly into a private helper while production still reads the configured `web.search.api_key_env` and fails closed for missing/empty values.
- `docs/environment.md` no longer presents test-only env vars as a supported surface.
Requirements mapping:
- No active non-work-item `INSOMNIA_TEST` references remain.
- No replacement test-only env var was introduced.
- Credential env vars and `INSOMNIA_POD_COMMAND` were not removed by this ticket.
- WebSearch production behavior and network safety boundaries are preserved.
Blockers: none.
Non-blocking follow-up:
- A future public-path fail-closed test could guard missing/empty `api_key_env`, but this is not required for this ticket.
Validation adequacy:
- Coder validation covered fmt, tools tests/check, ticket doctor, diff check, and residual `INSOMNIA_TEST` grep.
- Reviewer performed read-only diff/source/docs/grep review and did not rerun tests.
---
<!-- event: close author: hare at: 2026-05-31T10:04:28Z status: closed -->
## Closed
Removed test-only environment-variable usage from active code.
Implementation:
- Removed `INSOMNIA_TEST_*` Brave WebSearch test key generation/dependency.
- Split Brave search request execution so tests can inject an API key directly into a private helper.
- Preserved production behavior: WebSearch still reads configured `web.search.api_key_env` and fails closed for missing/empty values.
- Updated `docs/environment.md` so test-only env vars are not listed as supported surface.
Review:
- External reviewer `eliminate-test-env-vars-reviewer-20260531` approved implementation commit `e64a5595956c970b090cdce851cc962e92723a97`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p tools`
- `cargo check -p tools` (passed with unrelated existing `llm-worker` dead_code warning)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n "INSOMNIA_TEST" -- ':!work-items' || true` produced no active references.
---

View File

@ -2,12 +2,12 @@
id: 20260531-085959-remove-insomnia-pod-command-env id: 20260531-085959-remove-insomnia-pod-command-env
slug: remove-insomnia-pod-command-env slug: remove-insomnia-pod-command-env
title: CLI: remove INSOMNIA_POD_COMMAND override title: CLI: remove INSOMNIA_POD_COMMAND override
status: open status: closed
kind: task kind: task
priority: P2 priority: P2
labels: [cli, pod, env] labels: [cli, pod, env]
created_at: 2026-05-31T08:59:59Z created_at: 2026-05-31T08:59:59Z
updated_at: 2026-05-31T08:59:59Z updated_at: 2026-05-31T10:12:03Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -0,0 +1,22 @@
Removed `INSOMNIA_POD_COMMAND` as an active runtime/config/test override.
Implementation:
- `PodRuntimeCommand::resolve()` now always uses the typed default: current executable plus the `pod` prefix argument.
- Removed override/env parsing and related tests from the `insomnia` helper crate.
- Updated SpawnPod tests to use typed `PodRuntimeCommand` injection instead of process-wide env mutation.
- Preserved production spawn/restore behavior, detached process handling, and ready/socket delivery behavior.
- Updated docs so no active supported surface documents the removed env override.
Review:
- External reviewer `remove-insomnia-pod-command-reviewer-20260531` approved implementation commit `c618fa6`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p insomnia`
- `cargo test -p client -p insomnia`
- `cargo test -p pod --lib discovery::tests`
- `cargo test -p pod --test spawn_pod_test`
- `cargo check -p tui -p pod -p client -p insomnia` (passed with unrelated existing warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n "INSOMNIA_POD_COMMAND" -- ':!work-items' || true` produced no active references.

View File

@ -0,0 +1,68 @@
<!-- event: create author: tickets.sh at: 2026-05-31T08:59:59Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-05-31T10:09:52Z status: approve -->
## Review: approve
External reviewer: `remove-insomnia-pod-command-reviewer-20260531`
Reviewed implementation commit: `c618fa6` (`cli: remove pod command env override`)
Verdict: approve
Summary:
- `INSOMNIA_POD_COMMAND` is removed as an active runtime/config/test override.
- Pod runtime launch remains typed through `PodRuntimeCommand`, defaulting to current executable plus the `pod` prefix argument.
- SpawnPod tests now use typed injection rather than process-wide env mutation.
- Production spawn/restore paths still use `PodRuntimeCommand::resolve()`, preserve detached process handling, and retain the ready/socket delivery behavior.
Requirements mapping:
- No active non-work-item `INSOMNIA_POD_COMMAND` references remain in the implementation branch.
- No replacement env var was introduced.
- No `insomnia-pod` binary/alias, Pod protocol/flag/profile semantic changes, or `tui` rename were introduced.
Blockers: none.
Non-blocking follow-up:
- Merge conflict in `docs/environment.md` is expected after `eliminate-test-only-env-vars`; integration should keep the newer generic test-only wording while preserving removal of explicit `INSOMNIA_POD_COMMAND` references.
Validation adequacy:
- Coder validation covered fmt, helper/client/pod tests, cargo check, doctor, diff check, and active-reference grep.
- Reviewer additionally reran targeted tests/checks in the implementation worktree and confirmed no active refs for `INSOMNIA_POD_COMMAND`, `POD_COMMAND_OVERRIDE_ENV`, `from_override_env`, or `executable_only` outside work items.
---
<!-- event: close author: hare at: 2026-05-31T10:12:03Z status: closed -->
## Closed
Removed `INSOMNIA_POD_COMMAND` as an active runtime/config/test override.
Implementation:
- `PodRuntimeCommand::resolve()` now always uses the typed default: current executable plus the `pod` prefix argument.
- Removed override/env parsing and related tests from the `insomnia` helper crate.
- Updated SpawnPod tests to use typed `PodRuntimeCommand` injection instead of process-wide env mutation.
- Preserved production spawn/restore behavior, detached process handling, and ready/socket delivery behavior.
- Updated docs so no active supported surface documents the removed env override.
Review:
- External reviewer `remove-insomnia-pod-command-reviewer-20260531` approved implementation commit `c618fa6`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p insomnia`
- `cargo test -p client -p insomnia`
- `cargo test -p pod --lib discovery::tests`
- `cargo test -p pod --test spawn_pod_test`
- `cargo check -p tui -p pod -p client -p insomnia` (passed with unrelated existing warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n "INSOMNIA_POD_COMMAND" -- ':!work-items' || true` produced no active references.
---

View File

@ -0,0 +1,57 @@
---
id: 20260531-104614-pure-path-fallback-tests
slug: pure-path-fallback-tests
title: Tests: make path fallback tests independent from process env
status: closed
kind: task
priority: P2
labels: [test, env, manifest, cleanup]
created_at: 2026-05-31T10:46:14Z
updated_at: 2026-05-31T10:54:49Z
assignee: null
legacy_ticket: null
---
## Background
The environment-variable cleanup removed test-only env surfaces such as `INSOMNIA_TEST_*` and the Pod runtime command env override. The next issue is that several tests still mutate process-global environment even when they only need to verify deterministic fallback/precedence logic.
The user clarified the intended direction:
- even env vars used by the real application do not need to be read from process env in most tests;
- tests should verify fallback/precedence behavior with direct inputs;
- do not introduce a `PathEnv`/`EnvSnapshot` abstraction or a shared test-support crate just to preserve process-env mutation;
- write the fallback helpers directly and narrowly where needed.
This ticket starts with path resolution, where the behavior is centralized and the env mutation is avoidable.
## Requirements
- In `manifest::paths`, separate fallback/precedence logic from process env reads enough that fallback tests can call pure helpers with direct `Option<PathBuf>`-style inputs.
- Prefer small per-key helpers over a general `PathEnv` struct/trait.
- Example shape: `resolve_config_dir_from_parts(...)`, `resolve_data_dir_from_parts(...)`, `resolve_runtime_dir_from_parts(...)`, or equivalent private helpers.
- Keep helper visibility private or `pub(crate)` only if needed by nearby tests.
- Keep runtime behavior unchanged:
- public path functions still read the same supported env vars in the same order;
- empty env values keep the current unset-equivalent behavior;
- resource lookup fallback behavior remains compatible with packaging/dev use.
- Convert path fallback tests that currently use `std::env::set_var` / `remove_var` to direct helper tests where possible.
- Do not add a new helper crate or broad env abstraction.
- Do not remove credential env behavior in this ticket; that belongs to `manifest-profile-encrypted-secrets`.
- Do not attempt to eliminate subprocess integration env setup if a spawned process still needs runtime isolation; report any remaining env mutation rather than forcing risky API changes.
## Non-goals
- Removing all env reads from production code.
- Removing provider credential env support.
- Redesigning runtime directory authority or Pod process startup.
- Introducing test-support / EnvSnapshot / trait-based environment abstraction.
- Changing documented path semantics.
## Acceptance criteria
- `manifest::paths` fallback/precedence tests no longer mutate process-global environment just to test fallback order.
- The fallback order documented in `docs/environment.md` still matches the code.
- Any remaining `set_var` / `remove_var` in touched tests is either eliminated or explicitly justified in the implementation report.
- No new environment variable surface is introduced.
- `cargo fmt --check`, `cargo test -p manifest paths`, relevant affected tests, `cargo check -p manifest`, `./tickets.sh doctor`, and `git diff --check` pass.

View File

@ -0,0 +1,21 @@
Made `manifest::paths` fallback tests independent from process-global environment mutation.
Implementation:
- Added narrow private per-key fallback helpers for config/data/runtime path resolution.
- Public path functions still read the same env vars and pass them in the same precedence order.
- Empty path env values still resolve as unset.
- Fallback-order tests now pass direct `Option<PathBuf>` inputs instead of using `std::env::set_var` / `remove_var`.
- No `PathEnv`, `EnvSnapshot`, env trait, test-support crate, or new env var surface was introduced.
- Credential env behavior and Pod runtime/process behavior were not changed.
Review:
- External reviewer `pure-path-fallback-reviewer-20260531` approved implementation commit `e232f54`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p manifest paths`
- `cargo test -p manifest`
- `cargo check -p manifest` (passed with unrelated existing `llm-worker` dead_code warning)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n -E 'set_var\(|remove_var\(' -- crates/manifest/src/paths.rs || true` produced no matches.

View File

@ -0,0 +1,131 @@
<!-- event: create author: tickets.sh at: 2026-05-31T10:46:14Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-31T10:46:51Z -->
## Plan
Preflight classification: implementation-ready.
Intent:
- Remove unnecessary process-global environment mutation from path fallback tests by making the fallback/precedence logic directly testable with explicit inputs.
Requirements:
- Start with `manifest::paths`, where path resolution is centralized and currently owns env fallback tests.
- Use small per-key pure helpers with direct optional path inputs. Do not introduce a general `PathEnv`, `EnvSnapshot`, env trait, or test-support crate.
- Keep public runtime behavior unchanged: the production wrappers still read the same env vars, ignore empty path env values as before, and resolve the same fallback order.
- Keep docs/environment.md behavior aligned with code.
Current code map:
- `crates/manifest/src/paths.rs`: core config/data/runtime/resource path resolution and env fallback tests using local `EnvGuard`.
- `docs/environment.md`: documented fallback order for home/config_dir/data_dir/runtime_dir/resource_dir.
- Other env mutations in `pod`/`pod-registry` are mostly subprocess/runtime-isolation integration setup and should not be force-fixed in this ticket.
- Provider credential env mutation belongs to `manifest-profile-encrypted-secrets`, not this ticket.
Critical risks:
- Accidentally changing path precedence while making tests pure.
- Creating an over-broad abstraction that keeps env mutation around indirectly.
- Treating spawned-process runtime isolation env setup as equivalent to pure fallback tests and breaking integration tests.
Intent packet for implementation:
Intent:
- Make `manifest::paths` fallback tests pure and independent from process env mutation.
Requirements:
- Extract narrow pure helper functions for fallback precedence.
- Convert fallback-order tests to call those helpers directly.
- Keep production path functions and documented behavior unchanged.
- Do not introduce a new crate, `PathEnv` struct, env trait, or test-only env var.
Invariants:
- Path authority remains in `manifest::paths`.
- No new environment-variable surface.
- No provider credential/env cleanup in this ticket.
- No Pod runtime/process model changes.
Non-goals:
- Eliminating all `set_var`/`remove_var` in the repository.
- Reworking resource packaging lookup beyond preserving behavior.
Escalate if:
- Removing env mutation requires changing public APIs or path semantics.
- Resource lookup behavior is ambiguous enough to need a product decision.
- Pod runtime integration tests appear to need broader runtime-dir API changes.
Validation:
- `cargo fmt --check`
- `cargo test -p manifest paths`
- relevant additional manifest tests if names differ
- `cargo check -p manifest`
- `./tickets.sh doctor`
- `git diff --check`
- report remaining `set_var`/`remove_var` in touched areas and why they remain, if any.
---
<!-- event: review author: hare at: 2026-05-31T10:54:18Z status: approve -->
## Review: approve
External reviewer: `pure-path-fallback-reviewer-20260531`
Reviewed implementation commit: `e232f54` (`test: make path fallback tests pure`)
Verdict: approve
Summary:
- `manifest::paths` fallback/precedence logic was split into private pure helpers.
- Tests now pass direct `Option<PathBuf>` inputs to verify fallback order rather than mutating process environment.
- No `PathEnv`/`EnvSnapshot`/trait/test-support crate was introduced.
- The change is limited to `crates/manifest/src/paths.rs`.
Requirements mapping:
- `config_dir`, `data_dir`, and `runtime_dir` public wrappers still read the same env keys and pass them in the existing precedence order.
- Empty env values still become `None` through the retained `env_path`/path conversion behavior.
- `docs/environment.md` fallback order matches the code.
- `git grep -n -E 'set_var\(|remove_var\(' -- crates/manifest/src/paths.rs` has no matches.
- No credential env behavior, Pod runtime/process behavior, or new env var surface was changed.
Blockers: none.
Non-blocking follow-ups: none.
Validation adequacy:
- Coder validation covered fmt, focused/full manifest tests, manifest check, doctor, diff check, and set_var/remove_var grep.
- Reviewer performed read-only diff/source/docs consistency review and grep/diff-check spot checks; no additional Cargo tests were rerun by reviewer.
---
<!-- event: close author: hare at: 2026-05-31T10:54:49Z status: closed -->
## Closed
Made `manifest::paths` fallback tests independent from process-global environment mutation.
Implementation:
- Added narrow private per-key fallback helpers for config/data/runtime path resolution.
- Public path functions still read the same env vars and pass them in the same precedence order.
- Empty path env values still resolve as unset.
- Fallback-order tests now pass direct `Option<PathBuf>` inputs instead of using `std::env::set_var` / `remove_var`.
- No `PathEnv`, `EnvSnapshot`, env trait, test-support crate, or new env var surface was introduced.
- Credential env behavior and Pod runtime/process behavior were not changed.
Review:
- External reviewer `pure-path-fallback-reviewer-20260531` approved implementation commit `e232f54`.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p manifest paths`
- `cargo test -p manifest`
- `cargo check -p manifest` (passed with unrelated existing `llm-worker` dead_code warning)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n -E 'set_var\(|remove_var\(' -- crates/manifest/src/paths.rs || true` produced no matches.
---

View File

@ -0,0 +1,53 @@
---
id: 20260531-110818-remove-resource-dir
slug: remove-resource-dir
title: Manifest: remove filesystem resource_dir dependency
status: closed
kind: task
priority: P2
labels: [manifest, profile, nix, env, cleanup]
created_at: 2026-05-31T11:08:18Z
updated_at: 2026-05-31T11:58:28Z
assignee: null
legacy_ticket: null
---
## Background
`manifest::paths::resource_dir()` and `INSOMNIA_RESOURCE_DIR` currently keep a filesystem resource lookup boundary for bundled assets. That boundary appears increasingly historical:
- prompts are already embedded through `include_dir!` in the Pod prompt loader;
- provider/model builtin catalogs are already embedded in the provider catalog path;
- the remaining important uses are builtin Lua profile discovery and a manifest-profile model-context lookup that still reads `resources/models/builtin.toml` through `resource_dir()`.
The desired direction is to remove the filesystem `resource_dir` dependency rather than preserve `INSOMNIA_RESOURCE_DIR` as a public/user-facing configuration surface.
## Requirements
- Investigate and then remove the need for `manifest::paths::resource_dir()` if feasible.
- Remove `INSOMNIA_RESOURCE_DIR` as an active supported environment variable once no production code needs it.
- Preserve current builtin behavior:
- builtin/default profiles remain available through normal profile selection;
- builtin provider/model metadata remains available;
- prompts and profile assets still resolve correctly in dev and installed/Nix builds.
- Prefer embedding builtin assets in Rust over runtime filesystem discovery where this does not create a worse design.
- If builtin Lua profiles need synthetic source labels or special handling for diagnostics/local requires, design that explicitly rather than keeping `resource_dir` by inertia.
- Update Nix packaging only after confirming resources no longer need to be installed at `share/insomnia/resources` for runtime behavior.
- Update `docs/environment.md` to remove `resource_dir` / `INSOMNIA_RESOURCE_DIR` from supported path keys if implementation removes it.
## Non-goals
- Changing user/project `config_dir` override semantics.
- Removing user profile files or project profile support.
- Changing profile selector semantics except where needed to replace builtin profile filesystem discovery.
- Reworking credential env handling.
- Reintroducing ambient manifest discovery.
## Acceptance criteria
- No production code depends on `manifest::paths::resource_dir()` for bundled asset lookup, or any remaining dependency is explicitly justified and documented in the ticket thread.
- `INSOMNIA_RESOURCE_DIR` is removed from active code/docs if `resource_dir()` is removed.
- Builtin default profile/profile registry tests pass without filesystem resource discovery.
- Builtin model/provider lookup behavior remains available and tested.
- Nix build/install checks still pass; installed package does not need runtime filesystem resources unless explicitly justified.
- `cargo fmt --check`, focused manifest/provider/pod/tui tests as relevant, `cargo check` for affected crates, `./tickets.sh doctor`, and `git diff --check` pass.

View File

@ -0,0 +1,31 @@
Removed runtime filesystem resource discovery for bundled assets and removed `INSOMNIA_RESOURCE_DIR` as an active configuration surface.
Implementation:
- Removed `RESOURCE_DIR_ENV`, `resource_dir()`, and `builtin_profiles_dir()` from `manifest::paths`.
- Embedded builtin Lua profile source (`default.lua`) and exposed builtin profile metadata without fake filesystem paths.
- Preserved user/project registry profile and explicit path profile filesystem semantics.
- Replaced manifest-side builtin model context lookup with an embedded `resources/models/builtin.toml` source, avoiding a `manifest -> provider` dependency.
- Removed installed runtime `share/insomnia/resources` packaging and checks from Nix package output.
- Updated environment/Nix/pod-factory docs so runtime resource directory and `INSOMNIA_RESOURCE_DIR` are no longer supported/documented surfaces.
Review:
- External reviewer `remove-resource-dir-reviewer-20260531` approved implementation commit `365ec8b7fad008ab36bdc4de3adadb3696739a07`.
- Reviewer found no blockers. A suggested non-blocking future regression test for embedded profile local `require` diagnostics was recorded in the review thread.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p manifest profile`
- `cargo test -p provider catalog`
- `cargo test -p pod prompt`
- `cargo check -p manifest -p provider -p pod -p tui`
- `./tickets.sh doctor`
- `git diff --check`
- `rg -n 'INSOMNIA_RESOURCE_DIR|RESOURCE_DIR_ENV|resource_dir\(|builtin_profiles_dir\(' crates docs package.nix || true` produced no active hits.
- `nix build .#insomnia`
- `test -x result/bin/insomnia`
- `test ! -e result/bin/insomnia-pod`
- `test ! -e result/share/insomnia/resources`
- `result/bin/insomnia pod --help`
Note:
- Initial post-merge validation hit `No space left on device` while linking. `cargo clean` freed build artifacts, after which Rust and Nix validation passed.

View File

@ -0,0 +1,179 @@
<!-- event: create author: tickets.sh at: 2026-05-31T11:08:18Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-31T11:28:41Z -->
## Plan
Read-only investigator: `resource-dir-removal-investigator-20260531`
Classification: implementation-ready
Conclusion:
- `manifest::paths::resource_dir()` can be removed.
- `INSOMNIA_RESOURCE_DIR` can be removed as an active supported environment variable after replacing the two remaining runtime filesystem resource dependencies.
- Builtin assets should be embedded rather than discovered through installed `share/insomnia/resources`.
Current code map:
- `crates/manifest/src/paths.rs`
- `RESOURCE_DIR_ENV = "INSOMNIA_RESOURCE_DIR"`
- `resource_dir()` checks env, installed `$prefix/share/insomnia/resources`, then source-tree `resources/`.
- `builtin_profiles_dir()` returns `resource_dir()/profiles`.
- `crates/manifest/src/profile.rs`
- builtin profile discovery still scans `paths::builtin_profiles_dir()`.
- `builtin_model_context_window()` still reads `resource_dir()/models/builtin.toml`.
- `crates/provider/src/catalog.rs`
- provider/model builtin catalogs are already embedded with `include_str!`.
- `crates/pod/src/prompt/loader.rs` and `catalog.rs`
- builtin prompts are already embedded with `include_dir!` / `include_str!`.
- `package.nix` still installs `resources` to `$out/share/insomnia/resources` and checks prompt dir existence.
- `docs/environment.md` and `docs/nix.md` still document runtime resource directory/package resources.
Embedded vs filesystem map:
- Already runtime-embedded:
- `resources/prompts/**`
- `resources/providers/builtin.toml`
- `resources/models/builtin.toml` in provider crate.
- Still runtime-filesystem-dependent:
- `resources/profiles/default.lua` through builtin profile directory scan.
- manifest-side model context lookup through `resource_dir()/models/builtin.toml`.
- Should remain filesystem-based:
- user/project `profiles.toml` and profile paths;
- config prompt/model/provider overrides;
- explicit `--manifest <path>` and explicit filesystem profile path selectors.
Recommended implementation:
1. Remove `RESOURCE_DIR_ENV`, `resource_dir()`, and `builtin_profiles_dir()` from `manifest::paths`.
2. Embed builtin Lua profiles explicitly in `manifest::profile`, starting with `resources/profiles/default.lua`.
3. Preserve profile selector semantics for `default`, `builtin:default`, user/project source-qualified selectors, explicit paths, and ambiguity failures.
4. Use synthetic builtin provenance/diagnostics rather than fake filesystem paths.
5. Keep host Lua modules unchanged. Do not support embedded local filesystem require by falling back to `resources/profiles`; current default profile does not need local require.
6. Replace manifest-side `builtin_model_context_window()` filesystem read with `include_str!("../../../resources/models/builtin.toml")`. Do not make `manifest` depend on `provider` because `provider -> manifest` already exists.
7. Remove Nix runtime install/check for `$out/share/insomnia/resources` after runtime code no longer needs it.
8. Remove `INSOMNIA_RESOURCE_DIR` / `resource_dir` docs from `docs/environment.md`; update Nix/profile docs where they describe installed runtime resources.
Critical risks:
- Changing serialized profile provenance shape for builtin profiles. This is acceptable if tests/docs are updated, but do not pretend embedded profiles have real paths.
- Embedded profile local `require`: current builtin default has none. If future builtin profiles need it, add an explicit embedded module map; do not retain filesystem fallback by inertia.
- Manifest/provider catalog dependency cycle: do not call provider builtin APIs from manifest in this ticket.
- Keep source-tree `resources/` in the Nix build source closure because compile-time `include_str!` / `include_dir!` still need it; only installed runtime resources should disappear.
Implementation-ready intent packet:
Intent:
- Remove runtime filesystem resource discovery for bundled assets and delete `INSOMNIA_RESOURCE_DIR` as an active configuration surface.
Requirements:
- Embed builtin Lua profile(s) and manifest-side builtin model context lookup.
- Preserve existing profile selection behavior and user/project profile semantics.
- Remove runtime `resource_dir` path API and Nix installed resources dependency.
- Update docs to reflect embedded builtin assets and remove `INSOMNIA_RESOURCE_DIR`.
Invariants:
- `config_dir` / user profile / project profile semantics remain unchanged.
- No ambient manifest discovery is restored.
- No `INSOMNIA_RESOURCE_DIR` compatibility path is kept.
- `manifest` must not depend on `provider`.
- Pod runtime remains separate and prompt embedding behavior remains intact.
Non-goals:
- Credential env cleanup.
- Reworking provider catalog ownership into a shared crate.
- Changing user/project config override paths.
Escalate if:
- Builtin profile embedding requires local filesystem require support beyond current default profile.
- A public API requires real builtin profile paths rather than provenance labels.
- Nix packaging unexpectedly needs installed resources for runtime behavior after embedding.
Validation:
- grep for `INSOMNIA_RESOURCE_DIR|RESOURCE_DIR_ENV|resource_dir\(|builtin_profiles_dir\(` in active code/docs/package files.
- `cargo fmt --check`
- `cargo test -p manifest profile`
- focused tests for builtin default profile and compact ratio known-model context
- `cargo test -p provider catalog`
- `cargo test -p pod prompt`
- `cargo check -p manifest -p provider -p pod -p tui`
- `nix build .#insomnia` and install-output checks: `bin/insomnia` exists, `bin/insomnia-pod` absent, `share/insomnia/resources` absent if removed, `insomnia pod --help` works.
- `./tickets.sh doctor`
- `git diff --check`
---
<!-- event: review author: hare at: 2026-05-31T11:54:28Z status: approve -->
## Review: approve
External reviewer: `remove-resource-dir-reviewer-20260531`
Reviewed implementation commit: `365ec8b7fad008ab36bdc4de3adadb3696739a07` (`manifest: embed builtin resources`)
Verdict: approve
Summary:
- Runtime filesystem resource discovery and `INSOMNIA_RESOURCE_DIR` active surface were removed.
- Builtin profile is embedded and represented as `builtin:default` without pretending to have a runtime filesystem path under `resources/profiles`.
- User/project/path profile semantics remain filesystem-backed.
- Builtin model context lookup is embedded on the manifest side without adding a `manifest -> provider` dependency.
- Nix package/docs no longer require installed runtime resources.
Requirements mapping:
- `INSOMNIA_RESOURCE_DIR`, `RESOURCE_DIR_ENV`, `resource_dir()`, and `builtin_profiles_dir()` have no active hits in `crates/`, `docs/`, or `package.nix`.
- Embedded builtin profiles do not silently support local filesystem `require` by falling back to resources; non-filesystem profile local modules produce the existing disabled diagnostic.
- `crates/pod/src/spawn/tool.rs` change is test/API fallout for `ProfileDiscovery::with_sources`, not a runtime/protocol behavior change.
- Runtime installed resources are no longer required; source `resources/` remains build-time input for embeds.
Blockers: none.
Non-blocking follow-ups:
- Add a narrow regression test for an embedded builtin-like profile attempting local `require("x")`, asserting the explicit disabled diagnostic.
- Rerun `nix build .#insomnia` in an environment with enough disk space before treating the ticket as fully release-validated; the final rerun is environment-blocked by disk full.
Validation adequacy:
- Rust validation and forbidden-symbol grep are well targeted.
- Final Nix validation remains pending due to `No space left on device`, not due to a code review finding.
---
<!-- event: close author: hare at: 2026-05-31T11:58:28Z status: closed -->
## Closed
Removed runtime filesystem resource discovery for bundled assets and removed `INSOMNIA_RESOURCE_DIR` as an active configuration surface.
Implementation:
- Removed `RESOURCE_DIR_ENV`, `resource_dir()`, and `builtin_profiles_dir()` from `manifest::paths`.
- Embedded builtin Lua profile source (`default.lua`) and exposed builtin profile metadata without fake filesystem paths.
- Preserved user/project registry profile and explicit path profile filesystem semantics.
- Replaced manifest-side builtin model context lookup with an embedded `resources/models/builtin.toml` source, avoiding a `manifest -> provider` dependency.
- Removed installed runtime `share/insomnia/resources` packaging and checks from Nix package output.
- Updated environment/Nix/pod-factory docs so runtime resource directory and `INSOMNIA_RESOURCE_DIR` are no longer supported/documented surfaces.
Review:
- External reviewer `remove-resource-dir-reviewer-20260531` approved implementation commit `365ec8b7fad008ab36bdc4de3adadb3696739a07`.
- Reviewer found no blockers. A suggested non-blocking future regression test for embedded profile local `require` diagnostics was recorded in the review thread.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p manifest profile`
- `cargo test -p provider catalog`
- `cargo test -p pod prompt`
- `cargo check -p manifest -p provider -p pod -p tui`
- `./tickets.sh doctor`
- `git diff --check`
- `rg -n 'INSOMNIA_RESOURCE_DIR|RESOURCE_DIR_ENV|resource_dir\(|builtin_profiles_dir\(' crates docs package.nix || true` produced no active hits.
- `nix build .#insomnia`
- `test -x result/bin/insomnia`
- `test ! -e result/bin/insomnia-pod`
- `test ! -e result/share/insomnia/resources`
- `result/bin/insomnia pod --help`
Note:
- Initial post-merge validation hit `No space left on device` while linking. `cargo clean` freed build artifacts, after which Rust and Nix validation passed.
---

View File

@ -0,0 +1,62 @@
---
id: 20260531-111956-insomnia-crate-cli-owner
slug: insomnia-crate-cli-owner
title: CLI: make insomnia crate own binary entrypoint and CLI dispatch
status: closed
kind: task
priority: P2
labels: [cli, tui, pod, architecture]
created_at: 2026-05-31T11:19:56Z
updated_at: 2026-05-31T13:20:02Z
assignee: null
legacy_ticket: null
---
## Background
The single-binary migration currently exposes the installed command as `insomnia`, but the binary target and CLI dispatch are still owned by the `tui` package (`cargo run -p tui -- ...`, `crates/tui/src/main.rs`). The helper crate now named `insomnia` was originally introduced for Pod runtime command resolution, which creates a responsibility mismatch.
The desired architecture is that the `insomnia` crate owns the product CLI and binary entrypoint. The `tui` and `pod` crates should be libraries with no `main.rs`, and CLI/tool subcommand routing should happen at the `insomnia` boundary.
## Requirements
- Move product CLI ownership to the `insomnia` crate:
- binary target `insomnia` belongs to package/crate `insomnia`;
- CLI argument parsing and top-level mode/subcommand dispatch live in `insomnia`;
- development entrypoint becomes `cargo run -p insomnia -- ...`.
- Make `tui` a library implementation crate rather than binary/CLI owner.
- `tui` should expose an API for launching the terminal UI with already-parsed options/configuration.
- `tui` should not own `insomnia pod ...` dispatch or headless CLI tool routing.
- Keep `pod` as a library runtime crate with no dependency on `insomnia`.
- `pod` may expose its runtime entrypoint/parser API for `insomnia pod ...`, but it must not know the product CLI crate.
- Move or relocate `PodRuntimeCommand` responsibility out of the current `insomnia` helper role if needed to avoid inverted dependencies.
- `pod -> insomnia` is not acceptable.
- Prefer passing the runtime command from the top-level CLI/orchestrating layer into TUI/client spawn paths.
- If a shared type is still needed, place it in a lower-level crate whose responsibility is process/client mechanics, not in the product CLI crate by accident.
- CLI tools/headless commands should be routed by `insomnia`, not by `tui`.
- Examples: `memory lint`, `pod ...`, future non-TUI commands.
- Preserve observable behavior:
- installed command remains `insomnia`;
- `insomnia -r`, `insomnia --multi`, `insomnia <podname>`, and `insomnia pod ...` keep their behavior;
- no `insomnia-pod` alias is reintroduced;
- Pod runtime remains a separate process.
- Do not add compatibility layers for `cargo run -p tui -- ...`; updating docs/dev commands is sufficient.
## Non-goals
- Renaming the user-facing command away from `insomnia`.
- Merging Pod runtime into the TUI process.
- Reintroducing `INSOMNIA_POD_COMMAND` or test-only env command overrides.
- Redesigning Pod protocol, profile semantics, or manifest semantics.
- Broad TUI module-layout refactors beyond what is necessary to make `tui` library-callable.
## Acceptance criteria
- `cargo run -p insomnia -- --help` and `cargo run -p insomnia -- pod --help` work as the main development entrypoints.
- Package `tui` no longer owns a binary `main.rs` entrypoint, or any remaining binary target is explicitly temporary and not the product CLI owner.
- No active code path requires `pod -> insomnia` or `tui -> insomnia` dependency.
- `insomnia` crate owns top-level CLI parsing/dispatch for TUI launch, Pod runtime launch, and headless CLI tools.
- Pod spawn/restore still launches runtime subprocesses as `insomnia pod ...` through typed command injection from the top-level owner.
- Nix/package outputs still expose only `bin/insomnia`.
- Current docs/dev instructions no longer tell developers to use `cargo run -p tui -- ...` as the primary product CLI path.
- Focused CLI/parser/spawn tests, `cargo fmt --check`, relevant `cargo check`/`cargo test`, `nix build .#insomnia` if packaging changed, `./tickets.sh doctor`, and `git diff --check` pass.

View File

@ -0,0 +1,39 @@
Moved product CLI/binary ownership from `tui` to `insomnia`.
Implementation:
- Moved `PodRuntimeCommand` from the transitional `insomnia` helper role into `client`, so lower crates no longer depend on the product CLI crate.
- Made `tui` a library implementation crate and exposed launch APIs for already-parsed modes/options.
- Added the `insomnia` package binary entrypoint and moved top-level CLI parsing/dispatch there.
- Routed `insomnia pod ...` through `pod::entrypoint` from the `insomnia` crate.
- Routed `insomnia memory lint ...` from the `insomnia` crate.
- Kept normal TUI launch behavior for resume, multi-Pod dashboard, Pod name, `--pod`, and `--session` modes.
- Updated packaging so `package.nix` builds package `insomnia` and still exposes only `bin/insomnia`.
- Updated active development/docs references away from `cargo run -p tui -- ...`.
Review:
- External reviewer `insomnia-cli-owner-reviewer-20260531` initially requested changes for a parser regression around `--resume` combined with Pod selection.
- Fix commit `37281b6` restored mutual exclusion for `-r --pod`, `--pod -r`, and `-r <podname>`, and improved `--multi --pod` diagnostics.
- Reviewer approved after re-review.
Validation after merge:
- `cargo fmt --check`
- `cargo check -p client -p pod -p tui -p insomnia`
- `cargo test -p client`
- `cargo test -p pod`
- `cargo test -p tui`
- `cargo test -p insomnia`
- `cargo run -p insomnia -- --help`
- `cargo run -p insomnia -- pod --help`
- `cargo run -p insomnia -- memory lint --help`
- invalid/conflict CLI smoke tests for `--session not-a-uuid`, `-r --pod`, `--pod -r`, and `-r <podname>`
- dependency checks confirming `client`, `pod`, and `tui` do not depend on `insomnia`, and `tui` does not depend on `pod`
- `nix build .#insomnia`
- `test -x ./result/bin/insomnia`
- `test ! -e ./result/bin/insomnia-pod`
- `./result/bin/insomnia pod --help`
- `./tickets.sh doctor`
- `git diff --check`
- `rg "cargo run -p tui" docs README.md crates package.nix flake.nix || true` produced no active hits.
Notes:
- Validation emitted pre-existing dead-code warnings in `llm-worker`/`tui` but passed.

View File

@ -0,0 +1,193 @@
<!-- event: create author: tickets.sh at: 2026-05-31T11:19:56Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-31T12:42:22Z -->
## Plan
Read-only investigator: `insomnia-cli-owner-investigator-20260531`
Classification: implementation-ready
Conclusion:
- Proceed with this ticket as one implementation ticket, but implement in ordered internal phases: move `PodRuntimeCommand` to `client`, make `tui` library-callable, move product CLI/binary ownership to `insomnia`, then update packaging/docs.
Current code/dependency map:
- `crates/tui` currently owns the product binary target:
- `crates/tui/Cargo.toml` has `[[bin]] name = "insomnia" path = "src/main.rs"`.
- `crates/tui/src/main.rs` owns top-level CLI parsing, `insomnia pod ...` dispatch, `insomnia memory lint`, and TUI launch.
- `crates/insomnia` currently has no binary and mostly owns `PodRuntimeCommand` only.
- `crates/pod` is already library-only and exposes `pod::entrypoint::run_cli_from`.
- `client`, `pod`, and tests depend on `insomnia` only for `PodRuntimeCommand`, creating the wrong dependency direction for the desired architecture.
- `package.nix` builds `-p tui` even though the installed command is `insomnia`.
Recommended architecture:
```text
insomnia -> tui
insomnia -> pod
insomnia -> client
tui -> client
pod -> client
client -> no insomnia
pod -> no insomnia
tui -> no insomnia
```
Implementation plan:
1. Move `PodRuntimeCommand` from `insomnia` to `client`.
- Prefer `client::runtime_command` with `pub use` from `client`.
- Update `client`, `pod`, and tests to use `client::PodRuntimeCommand`.
- Remove `insomnia` dependency from lower crates.
2. Make `tui` a library implementation crate.
- Add `crates/tui/src/lib.rs`.
- Move TUI module declarations and TUI launch code behind public library API.
- Expose narrow API such as `LaunchOptions`, `LaunchMode`, `launch(...)`.
- Keep terminal setup/teardown in `tui` because it owns terminal behavior.
- Remove product CLI parsing and `pod`/`memory lint` dispatch from `tui`.
3. Make `insomnia` own product CLI/binary.
- Add `crates/insomnia/src/main.rs`.
- Move top-level CLI parser/dispatch from `tui` to `insomnia`.
- Route `pod` to `pod::entrypoint::run_cli_from("insomnia pod", args)`.
- Route `memory lint` from `insomnia`, not `tui`.
- Route normal TUI modes to `tui::launch(...)` with a typed runtime command.
4. Update packaging/docs.
- Remove `[[bin]] name = "insomnia"` from `tui`.
- Make `package.nix` build `-p insomnia`.
- Update active docs/dev commands from `cargo run -p tui -- ...` to `cargo run -p insomnia -- ...`.
Important constraints:
- Do not introduce `pod -> insomnia` or `tui -> insomnia`.
- Do not keep compatibility for `cargo run -p tui -- ...`.
- Do not reintroduce `insomnia-pod`, `INSOMNIA_POD_COMMAND`, or the separate `INSOMNIA_POD_RUNTIME_COMMAND`; that dev override is tracked by `dev-pod-runtime-command-env`.
- Do not change Pod protocol/profile/manifest semantics.
- Avoid broad TUI refactoring beyond making the crate library-callable.
Critical risks:
- `tui/src/main.rs` is large and mixes CLI, terminal lifecycle, event loop, key handling, and tests. Keep changes mechanical and avoid unrelated module cleanup.
- Parser tests should move to `insomnia`; TUI behavior tests should remain with `tui`.
- `memory_lint` is headless product CLI code and should not remain routed through TUI.
- `--help` behavior needs explicit handling because the old parser mostly ignored unknown flags.
Validation:
- `cargo fmt --check`
- `cargo check -p client -p pod -p tui -p insomnia`
- `cargo test -p client`
- `cargo test -p pod`
- `cargo test -p tui`
- `cargo test -p insomnia`
- CLI smoke:
- `cargo run -p insomnia -- --help`
- `cargo run -p insomnia -- pod --help`
- `cargo run -p insomnia -- memory lint --help`
- `cargo run -p insomnia -- --session not-a-uuid`
- Dependency ownership checks:
- `cargo tree -p client -e no-dev --depth 1`
- `cargo tree -p pod -e no-dev --depth 1`
- `cargo tree -p tui -e no-dev --depth 1`
- `cargo tree -p insomnia -e no-dev --depth 1`
- If packaging changed:
- `nix build .#insomnia`
- `./result/bin/insomnia pod --help`
- `test -x ./result/bin/insomnia`
- `test ! -e ./result/bin/insomnia-pod`
- `./tickets.sh doctor`
- `git diff --check`
- `rg "cargo run -p tui" docs README.md crates package.nix flake.nix`
---
<!-- event: review author: hare at: 2026-05-31T13:17:15Z status: approve -->
## Review: approve
External reviewer: `insomnia-cli-owner-reviewer-20260531`
Reviewed implementation commits:
- `22d974a` (`cli: move product entrypoint to insomnia`)
- `37281b6` (`cli: reject resume with pod selection`)
Verdict: approve
Summary:
- Product binary and top-level CLI dispatch moved from the `tui` package to the `insomnia` package.
- `tui` is now a library implementation crate and no longer owns `pod`/`memory lint` top-level dispatch.
- `PodRuntimeCommand` moved to `client`, removing the wrong lower-crate dependency on the product CLI crate.
- `pod` remains library-only and does not depend on `insomnia`.
- Packaging now builds package `insomnia` for the installed `bin/insomnia` output.
Initial review blocker:
- `--resume` combined with `--pod` or positional Pod name selection initially stopped erroring.
Fix:
- Commit `37281b6` restored mutual exclusion:
- `insomnia -r --pod agent` errors.
- `insomnia --pod agent -r` errors.
- `insomnia -r agent` errors.
- `insomnia --multi --pod agent` reports `--multi and --pod are mutually exclusive`.
Requirements mapping:
- `insomnia` owns binary target and top-level CLI dispatch.
- `tui`, `pod`, and `client` do not depend on `insomnia`.
- `tui` does not depend on `pod`.
- `insomnia pod ...`, `insomnia memory lint ...`, and normal TUI launch modes are preserved.
- No `insomnia-pod`, `INSOMNIA_POD_COMMAND`, or `INSOMNIA_POD_RUNTIME_COMMAND` was introduced.
- No Pod protocol/profile/manifest semantic change was found.
Blockers: none.
Validation adequacy:
- Coder ran Rust tests/checks, CLI smoke, dependency ownership checks, Nix build/install smoke, doctor, diff-check, and active `cargo run -p tui` grep.
- Reviewer reran focused parser tests and manually confirmed conflict diagnostics through the built binary after the fix.
---
<!-- event: close author: hare at: 2026-05-31T13:20:02Z status: closed -->
## Closed
Moved product CLI/binary ownership from `tui` to `insomnia`.
Implementation:
- Moved `PodRuntimeCommand` from the transitional `insomnia` helper role into `client`, so lower crates no longer depend on the product CLI crate.
- Made `tui` a library implementation crate and exposed launch APIs for already-parsed modes/options.
- Added the `insomnia` package binary entrypoint and moved top-level CLI parsing/dispatch there.
- Routed `insomnia pod ...` through `pod::entrypoint` from the `insomnia` crate.
- Routed `insomnia memory lint ...` from the `insomnia` crate.
- Kept normal TUI launch behavior for resume, multi-Pod dashboard, Pod name, `--pod`, and `--session` modes.
- Updated packaging so `package.nix` builds package `insomnia` and still exposes only `bin/insomnia`.
- Updated active development/docs references away from `cargo run -p tui -- ...`.
Review:
- External reviewer `insomnia-cli-owner-reviewer-20260531` initially requested changes for a parser regression around `--resume` combined with Pod selection.
- Fix commit `37281b6` restored mutual exclusion for `-r --pod`, `--pod -r`, and `-r <podname>`, and improved `--multi --pod` diagnostics.
- Reviewer approved after re-review.
Validation after merge:
- `cargo fmt --check`
- `cargo check -p client -p pod -p tui -p insomnia`
- `cargo test -p client`
- `cargo test -p pod`
- `cargo test -p tui`
- `cargo test -p insomnia`
- `cargo run -p insomnia -- --help`
- `cargo run -p insomnia -- pod --help`
- `cargo run -p insomnia -- memory lint --help`
- invalid/conflict CLI smoke tests for `--session not-a-uuid`, `-r --pod`, `--pod -r`, and `-r <podname>`
- dependency checks confirming `client`, `pod`, and `tui` do not depend on `insomnia`, and `tui` does not depend on `pod`
- `nix build .#insomnia`
- `test -x ./result/bin/insomnia`
- `test ! -e ./result/bin/insomnia-pod`
- `./result/bin/insomnia pod --help`
- `./tickets.sh doctor`
- `git diff --check`
- `rg "cargo run -p tui" docs README.md crates package.nix flake.nix || true` produced no active hits.
Notes:
- Validation emitted pre-existing dead-code warnings in `llm-worker`/`tui` but passed.
---

View File

@ -0,0 +1,57 @@
---
id: 20260531-124040-dev-pod-runtime-command-env
slug: dev-pod-runtime-command-env
title: Dev: add Pod runtime executable override env
status: closed
kind: task
priority: P2
labels: [cli, pod, env, dev]
created_at: 2026-05-31T12:40:40Z
updated_at: 2026-05-31T20:41:56Z
assignee: null
legacy_ticket: null
---
## Background
During dogfooding, long-running TUI/Pod processes can keep executing an old `target/debug/insomnia` inode after `cargo build` or `cargo clean` replaces/removes the binary. On Linux, `std::env::current_exe()` then returns a path like:
```text
/home/.../target/debug/insomnia (deleted)
```
Current Pod spawn/restore paths use `current_exe() + ["pod"]` as the runtime command, so a long-running process can try to spawn `.../insomnia (deleted) pod` and fail with `No such file or directory`.
This is primarily a development/dogfooding problem. The previous general-purpose `INSOMNIA_POD_COMMAND` override was intentionally removed; do not restore that semantics. Instead, add a narrow development escape hatch that replaces only the executable used in the standard `insomnia pod ...` runtime command.
## Requirements
- Add a narrowly scoped development override environment variable, tentatively `INSOMNIA_POD_RUNTIME_COMMAND`.
- Semantics:
- if unset/empty, default remains `current_exe() + ["pod"]`;
- if set, the value is the executable path used instead of `current_exe()`;
- the `pod` prefix argument is still automatically added;
- the value is not shell-parsed and cannot include arguments.
- Keep this as a development escape hatch, not normal user configuration.
- Update diagnostics so a failed spawn shows the resolved runtime command clearly enough to debug deleted-path issues.
- Document the variable in `docs/environment.md` under a clearly development-only section.
- Do not reintroduce `INSOMNIA_POD_COMMAND` or its old executable-without-prefix semantics.
- Do not change Pod runtime flags/profile/manifest/protocol semantics.
- This ticket may be implemented before or folded into `insomnia-crate-cli-owner`, but must not create `pod -> insomnia` or `tui -> insomnia` architectural coupling beyond the current transitional state.
## Non-goals
- Making environment variables a general configuration mechanism.
- Adding shell-string command parsing.
- Supporting wrapper commands with arguments.
- Solving all hot-reload/self-rebuild lifecycle issues.
- Reintroducing the removed `insomnia-pod` binary/alias.
## Acceptance criteria
- Setting `INSOMNIA_POD_RUNTIME_COMMAND=/path/to/insomnia` causes spawn/restore paths to execute `/path/to/insomnia pod ...`.
- Unset or empty `INSOMNIA_POD_RUNTIME_COMMAND` preserves the default `current_exe() + ["pod"]` behavior.
- Tests cover override, empty-as-unset, and no shell parsing/argument splitting semantics.
- Active code/docs do not reference the old `INSOMNIA_POD_COMMAND` name except historical work items.
- `docs/environment.md` explains this as a development-only escape hatch for dogfooding/rebuild deleted-exe cases.
- `cargo fmt --check`, focused tests for the runtime command helper/spawn paths, `cargo check` for affected crates, `./tickets.sh doctor`, and `git diff --check` pass.

View File

@ -0,0 +1,23 @@
Added `INSOMNIA_POD_RUNTIME_COMMAND` as a narrow development escape hatch for dogfooding/self-rebuild cases where `current_exe()` can point at a deleted debug binary.
Implementation:
- Added the override in `client::PodRuntimeCommand`.
- Unset/empty values preserve the default `current_exe() + ["pod"]` behavior.
- Non-empty values replace only the executable path and still automatically receive the `pod` prefix argument.
- The value is not shell-parsed and is not argument-split.
- Spawn/restore failure diagnostics include the resolved runtime command.
- Documented the variable in `docs/environment.md` as development-only, not normal user configuration.
- Did not reintroduce `INSOMNIA_POD_COMMAND` or old executable-without-prefix semantics.
Review:
- External reviewer `dev-pod-runtime-env-reviewer-20260531` approved implementation commit `0031953ed352ba7fae9e798b6aeee1e8ea080816`.
- Reviewer noted a non-blocking future improvement: display formatting for runtime commands could quote argv pieces with spaces more clearly.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p client runtime_command`
- `cargo test -p client`
- `cargo check -p client -p pod -p tui -p insomnia` (passed with existing dead-code warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n "INSOMNIA_POD_COMMAND" -- ':!work-items' || true` produced no active references.

View File

@ -0,0 +1,72 @@
<!-- event: create author: tickets.sh at: 2026-05-31T12:40:40Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-05-31T20:41:28Z status: approve -->
## Review: approve
External reviewer: `dev-pod-runtime-env-reviewer-20260531`
Reviewed implementation commit: `0031953ed352ba7fae9e798b6aeee1e8ea080816` (`dev: add pod runtime command override`)
Verdict: approve
Summary:
- Added `INSOMNIA_POD_RUNTIME_COMMAND` as a narrow development-only executable override for the standard `insomnia pod ...` runtime command.
- Unset/empty values preserve `current_exe() + ["pod"]`.
- Non-empty values replace only the executable path; the `pod` prefix argument is still automatically added.
- The value is passed as a single `PathBuf`/program and is not shell-parsed or argument-split.
- Diagnostics for failed client spawn/restore include the resolved runtime command.
Requirements mapping:
- Override implementation lives in `client::PodRuntimeCommand`, preserving the post-CLI-owner dependency boundary.
- Spawn/restore paths continue to use typed command construction with `Command::new(program)` and prefix args.
- `docs/environment.md` documents the env var as a development-only escape hatch, not normal configuration.
- Old `INSOMNIA_POD_COMMAND` was not reintroduced and has no active non-work-item refs.
- No Pod runtime flag/profile/manifest/protocol semantic changes were found.
Blockers: none.
Non-blocking follow-up:
- `PodRuntimeCommand::Display` joins argv-like pieces with spaces, so diagnostics can be visually ambiguous when an executable path contains spaces. It is sufficient for this ticket, but a future quoted/structured argv display would be clearer.
Validation adequacy:
- Coder validation covered fmt, focused/full client tests, affected crate check, doctor, diff-check, and old env-name grep.
- Reviewer performed read-only diff/source/docs/grep review and did not rerun Cargo tests.
---
<!-- event: close author: hare at: 2026-05-31T20:41:56Z status: closed -->
## Closed
Added `INSOMNIA_POD_RUNTIME_COMMAND` as a narrow development escape hatch for dogfooding/self-rebuild cases where `current_exe()` can point at a deleted debug binary.
Implementation:
- Added the override in `client::PodRuntimeCommand`.
- Unset/empty values preserve the default `current_exe() + ["pod"]` behavior.
- Non-empty values replace only the executable path and still automatically receive the `pod` prefix argument.
- The value is not shell-parsed and is not argument-split.
- Spawn/restore failure diagnostics include the resolved runtime command.
- Documented the variable in `docs/environment.md` as development-only, not normal user configuration.
- Did not reintroduce `INSOMNIA_POD_COMMAND` or old executable-without-prefix semantics.
Review:
- External reviewer `dev-pod-runtime-env-reviewer-20260531` approved implementation commit `0031953ed352ba7fae9e798b6aeee1e8ea080816`.
- Reviewer noted a non-blocking future improvement: display formatting for runtime commands could quote argv pieces with spaces more clearly.
Validation after merge:
- `cargo fmt --check`
- `cargo test -p client runtime_command`
- `cargo test -p client`
- `cargo check -p client -p pod -p tui -p insomnia` (passed with existing dead-code warnings)
- `./tickets.sh doctor`
- `git diff --check`
- `git grep -n "INSOMNIA_POD_COMMAND" -- ':!work-items' || true` produced no active references.
---

View File

@ -1,64 +1,155 @@
--- ---
id: 20260529-145355-manifest-profile-encrypted-secrets id: 20260529-145355-manifest-profile-encrypted-secrets
slug: manifest-profile-encrypted-secrets slug: manifest-profile-encrypted-secrets
title: Encrypted secret store for manifest profiles title: Manifest/Profile: local key-value secret store
status: open status: open
kind: feature kind: feature
priority: P2 priority: P2
labels: [manifest, profiles, secrets, security] labels: [manifest, profiles, secrets, security, cli, tui]
created_at: 2026-05-29T14:53:55Z created_at: 2026-05-29T14:53:55Z
updated_at: 2026-05-29T14:53:55Z updated_at: 2026-05-31T21:23:46Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---
## Background ## Background
WebSearch/WebFetch made API keys more visible as a UX problem: `WebSearch` currently expects `web.search.api_key_env`, so users must export `BRAVE_SEARCH_API_KEY` before starting the Pod/TUI process. That is inconvenient for long-lived Pods, profile switching, and per-project/provider configuration. Credential configuration still relies on process environment variables in important paths:
This should not be solved by adding `.env` loading as an implicit side effect. `.env` files are easy to leak into projects, do not solve profile-specific credential selection cleanly, and still expose secrets through process environments. Instead, when manifest profiles are designed/implemented, add a first-class encrypted secret store that manifests/profiles can reference. - provider API keys use `AuthRef::ApiKey { env, file }` and provider-default `INSOMNIA_API_KEY_*` names;
- WebSearch currently uses `web.search.api_key_env`;
- normal runtime intentionally does not load `.env` files.
Related work item: `work-items/open/20260527-000022-manifest-profiles/item.md`. This should not be solved by implicit `.env` loading. `.env` files are easy to leak into projects, do not solve profile-specific credential selection cleanly, and still expose secrets through process environments.
The desired replacement is a local key-value secret store plus explicit references from manifest/profile/tool configuration.
The security target is intentionally modest. This is not a high-assurance password manager. The goal is to avoid casual plaintext exposure and generic environment-variable scraping, not to defend against a local attacker with the user's account or process memory access.
## Intent
Implement a provider-independent local key-value secret store and use it as the normal credential path for provider and WebSearch credentials.
The logical store model is just:
```text
{
"anthropic/default" = "sk-..."
"web/brave/default" = "..."
}
```
The store must not know that a key is Anthropic, Brave, OpenAI, or any other provider-specific kind. Provider/model/tool configuration chooses which key to reference.
## Requirements ## Requirements
- Design a typed secret reference format for manifest/profile fields that need credentials. ### Store model
- Add a new encrypted-store reference form, e.g. `api_key_secret = "brave.search.default"` or a more general `SecretRef` enum.
- Existing env references such as `api_key_env = "BRAVE_SEARCH_API_KEY"` may be supported only as a migration/compatibility input during the transition; the target state is to remove credential environment-variable configuration rather than keep it as a normal fallback. - Add a local secret/key store that maps a validated string id to a secret string value.
- Secret references must be explicit in resolved config; do not silently read arbitrary `.env` files. - Keep the user-visible logical schema provider-independent: `id -> value`.
- Add an encrypted local secret store suitable for API keys/tokens. - Do not add provider-specific slots, credential kinds, or required metadata to the store schema.
- Store secrets outside tracked project files by default, under the user data/config directory. - Technical envelope fields needed for versioning/nonce/ciphertext/checksum are allowed, but they must not become user-facing semantic metadata.
- Use authenticated encryption and atomic writes. - Store data outside the repository, under the user data directory, e.g. `<data_dir>/secrets/store.json` or equivalent.
- Do not log plaintext secrets, include them in session logs, expose them to model context, or return them through normal tool output. - Use atomic writes.
- Keep encrypted blobs out of git-managed work-items/memory/session records. - Validate ids:
- Integrate with manifest profiles. - reject empty ids;
- Profiles should be able to select different secret names for different roles/providers, e.g. Orchestrator/Coder/Researcher or web search provider variants. - reject path traversal / absolute-path-like ids;
- Profile resolution should validate that referenced secrets exist or produce a clear startup/tool diagnostic. - reject control characters;
- A profile switch must not require restarting the shell just to change API keys. - bound length;
- Provide a small CLI/TUI management surface. - allow a conservative useful set such as ASCII alnum plus `._/-`.
- Add/update/list/delete secrets without printing plaintext by default.
- Support non-interactive set from stdin for scripts. ### Obfuscation / encryption stance
- Show references and metadata, not secret values.
- Consider migration helpers from existing env-var based configuration, but keep migration optional. - Apply lightweight encryption or obfuscation at rest so the file is not a casual plaintext key dump.
- Update credential consumers. - Do not claim strong local security guarantees.
- WebSearch should use encrypted secret refs instead of requiring env vars. - Do not introduce OS keychain dependency or interactive passphrase UX in this ticket.
- Provider API keys/tokens and future hosted/search credentials should use the same mechanism. - Do not store plaintext values in logs, work items, session history, diagnostics, or normal command output.
- Remove env-var credential configuration from the normal supported path once encrypted secret refs and migration diagnostics exist. - Decryption/decoding failures must fail closed and name only the key id, not the value.
- Security and UX constraints.
- Fail closed when a referenced secret is missing or cannot be decrypted. ### `insomnia keys` TUI management
- Diagnostics should name the missing reference, not the secret value.
- Do not add hidden context injection or history mutation for secret resolution. - Add `insomnia keys` as an interactive TUI key manager.
- Document the threat model and limitations, including OS account access and backup implications. - The product CLI owner (`insomnia` crate) routes the subcommand.
- Use the TUI implementation crate for the terminal screen if practical.
- Minimum UI features:
- list key ids;
- add/set a key;
- delete a key with confirmation;
- quit.
- Do not display plaintext values in the list or normal screen output.
- During add/set, mask the value input or otherwise avoid echoing plaintext.
- Scriptable commands such as `insomnia keys set <id> --stdin` may be added if convenient, but the required user surface is the TUI manager.
### Config references / consumers
- Use explicit secret references from configuration.
- Existing `AuthRef::SecretRef { ref_ }` in manifest model should resolve through the new store for provider API keys.
- WebSearch must gain an explicit secret reference path so Brave search can be configured without `BRAVE_SEARCH_API_KEY` / `api_key_env`.
- Prefer a generic auth/secret-ref shape if it stays small.
- A focused `api_key_secret = "web/brave/default"` field is acceptable if it avoids a broad schema redesign.
- Secret refs are resolved at the consumer/runtime boundary only; resolved config/debug output must not contain plaintext.
- The store must not implicitly choose default keys based on provider name. No ambient lookup like "anthropic automatically reads anthropic/default" unless the profile/config explicitly references it.
### Env credential removal
- Do not load `.env` files.
- Do not add new credential environment variables.
- Do not keep migration/backward-compatibility behavior for credential env config in the normal profile path.
- Remove credential env configuration from normal provider/WebSearch use as part of this ticket.
- Docs and diagnostics should point users to `insomnia keys` + secret refs as the credential path.
### Codex OAuth relationship
- Codex OAuth is not part of this key-value secret store in this ticket.
- Current Codex OAuth intentionally interoperates with Codex CLI's `auth.json` file and refresh behavior; that file contains a structured token bundle, not a single provider API key string.
- Do not store or refresh Codex OAuth token bundles through the key-value store as part of this ticket.
- Do not change `CODEX_HOME` / `$HOME/.codex` lookup behavior in this ticket.
- A future Insomnia-owned Codex login/token store could be designed separately if needed, but it should be a dedicated OAuth token-store design, not an implicit use of the simple key-value API-key store.
## Phases within this ticket
1. Core store
- key-value store API;
- id validation;
- lightweight encrypted/obfuscated file format;
- atomic load/save;
- focused tests.
2. `insomnia keys` TUI manager
- list/add/delete;
- masked input;
- no plaintext display.
3. Provider integration
- implement provider `AuthRef::SecretRef` resolution through the store;
- keep plaintext in memory only;
- fail closed on missing/invalid/decode failures.
4. WebSearch integration
- add a secret-ref credential path;
- make Brave search usable without env credentials.
5. Docs and env removal
- update `docs/environment.md` and manifest/profile docs;
- document the modest security target honestly;
- point users to `insomnia keys` and secret refs as the credential path;
- remove credential env configuration from normal provider/WebSearch docs and code paths.
## Non-goals
- A high-assurance password manager.
- OS keychain integration.
- Passphrase prompt UX.
- Provider-specific secret-store schema.
- Automatic provider-name-to-secret-id lookup.
- Loading `.env` files.
- Changing Codex OAuth behavior. Codex OAuth remains an external structured token-source integration in this ticket.
- Reworking model/provider catalog ownership.
## Acceptance criteria ## Acceptance criteria
- Manifest/profile schema has a typed credential reference for encrypted secret-store entries; env-var credential inputs are at most transitional migration inputs, not the final supported configuration path. - A user can run `insomnia keys` and manage key ids interactively.
- Encrypted secret-store files are created outside the repository by default and use authenticated encryption with atomic update behavior. - The store persists key-value entries under the user data directory without plaintext values in the on-disk file.
- A user can add/list/delete a Brave Search API key in the secret store and configure `WebSearch` to use it without exporting an environment variable. - Store id validation rejects unsafe ids.
- Resolved configuration and diagnostics never display plaintext secrets. - Provider `AuthRef::SecretRef` resolves through the store and does not print/serialize plaintext.
- Missing/decryption-failed secrets produce clear fail-closed errors. - WebSearch can use a configured secret ref without exporting an environment variable.
- Existing env-var based credential configuration is either removed or produces an explicit migration diagnostic after encrypted secret references are available. - Missing key, invalid id, unreadable store, and decode/decrypt failure produce clear fail-closed errors naming only the key id.
- Documentation explains how profiles reference secrets, how to manage them, and why credential env vars are no longer the normal path. - `docs/environment.md` no longer presents credential env vars as the normal path, removes normal provider/WebSearch credential env configuration, and documents the limited protection goal.
- Focused tests cover config parsing/resolution, missing secret diagnostics, no-plaintext serialization/logging paths, and WebSearch secret resolution. - Focused tests cover store round-trip, id validation, decode failure, provider secret-ref resolution, WebSearch secret-ref resolution, and no-plaintext debug/serialization paths where applicable.
- `cargo fmt --check` - `cargo fmt --check`, relevant crate tests/checks, `./tickets.sh doctor`, and `git diff --check` pass.
- Relevant manifest/provider/tools/pod tests pass.

View File

@ -4,4 +4,203 @@
Created by tickets.sh create. Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-31T20:58:00Z -->
## Plan
Preflight/detailing status: requirements-sync-needed before implementation.
This ticket is the remaining credential/env cleanup boundary. It should not go directly to coding until the secret-store key-management decision is settled. The current code already has some typed-reference groundwork, but no runtime secret store exists.
Current code map:
- `crates/manifest/src/model.rs`
- `AuthRef::SecretRef { ref_ }` already exists in the manifest model and serializes as `auth = { kind = "secret_ref", ref = "..." }`.
- `AuthRef::ApiKey { env, file }` still documents and models env/file API-key sources.
- `SchemeKind::default_env_var()` still provides `INSOMNIA_API_KEY_*` defaults.
- `crates/provider/src/lib.rs`
- `AuthRef::ApiKey` resolves env first, then `auth.file`.
- `AuthRef::SecretRef` currently errors with "secret store references are not implemented yet".
- `crates/tools/src/web.rs`
- Brave WebSearch currently requires `web.search.api_key_env` and reads process env directly.
- `docs/environment.md`
- credential env vars are documented as migration compatibility, not the target supported configuration path.
Proposed target architecture:
- Add a small secret-store boundary independent from `manifest`, `provider`, and `tools` cycles.
- Candidate crate name: `secret-store` or `secrets`.
- Responsibilities: secret id validation, encrypted store read/write, metadata listing, redacted diagnostics, and test-only in-memory/key fixtures.
- Non-responsibilities: provider-specific auth semantics, profile selection, tool networking.
- Keep manifest/profile config as references only.
- Model auth should use the existing `AuthRef::SecretRef { ref_ }` shape rather than adding another model-auth syntax.
- WebSearch needs an explicit secret reference field, e.g. `web.search.api_key_secret = "web/brave/default"` or a typed `web.search.auth = { kind = "secret_ref", ref = "..." }`. Prefer the latter if it avoids another provider-specific one-off, but choose based on minimal config churn.
- Secret IDs should be logical names, not paths.
- Allow a conservative character set such as `[A-Za-z0-9._/-]`.
- Reject empty ids, absolute/path-traversal-like ids, control characters, and extremely long ids.
- IDs are diagnostics-safe; values are not.
- Secret values are resolved only at consumer/runtime boundary.
- Resolved manifests/profiles may contain secret refs, not plaintext.
- Provider/WebSearch consumers receive plaintext only in memory and must not serialize/log it.
Key-management decision required before implementation:
- Do not store an encryption key next to the encrypted store; that provides obfuscation, not meaningful encryption.
- Preferred direction to evaluate: OS keychain/credential manager as the wrapping-key source where available.
- Linux Secret Service / macOS Keychain / Windows Credential Manager through a maintained crate such as `keyring`, if acceptable for dependencies/packaging/headless use.
- If no OS key provider is available, fail closed with clear diagnostics rather than silently writing plaintext or adjacent keys.
- Tests should use an explicit test-only key provider/in-memory store without process-env mutation.
- If a passphrase fallback is desired later, make it explicit interactive/CLI UX; do not read passphrases from ambient env.
Suggested store layout:
- Store encrypted blobs outside the repository, under the user data directory, e.g. `<data_dir>/secrets/store.json` or `<data_dir>/secrets/<id>.json`.
- Use atomic write + fsync where the project already has or can add an atomic-write helper.
- Store metadata needed for listing without plaintext values:
- id
- created_at / updated_at
- optional description/provider/kind
- encryption metadata: algorithm, nonce, key provider/version
- Do not put encrypted blobs under work-items, memory, session logs, project `.insomnia/`, or generated reports.
Encryption requirements:
- Authenticated encryption only (AEAD), e.g. XChaCha20-Poly1305 or AES-GCM depending on dependency choice.
- Unique nonce per encryption.
- Include associated data that binds ciphertext to secret id and store metadata version.
- Decryption/auth failure is a fail-closed error naming the secret id only.
CLI/TUI management scope:
- Initial scope can be CLI-first under `insomnia secrets ...`; TUI management can be follow-up unless UX is trivial.
- Minimum CLI:
- `insomnia secrets set <id> --stdin [--description ...]`
- `insomnia secrets list`
- `insomnia secrets delete <id>`
- optionally `insomnia secrets rename <old> <new>` later, not required for MVP.
- Do not print plaintext by default. Avoid adding `show` unless protected by an explicit `--reveal` decision in a later ticket.
- Non-interactive `set --stdin` is required so scripts can load keys without shell env exports.
Consumer migration plan:
1. Implement secret store + model `AuthRef::SecretRef` resolution in `provider`.
2. Add WebSearch secret reference support and tests.
3. Add CLI management commands.
4. Update docs and examples to use secret refs as the normal path.
5. Convert env-var credential paths into migration diagnostics or compatibility-only one-file/debug behavior, then remove from normal profile path.
Important migration constraints:
- Do not load `.env` files.
- Do not keep env as a normal fallback once secret refs are available.
- If env inputs remain temporarily, diagnostics should say they are migration compatibility and point to `insomnia secrets set ...` / profile secret refs.
- Avoid changing Codex OAuth in this ticket unless a clear secret-store integration is needed; `CODEX_HOME` remains external compatibility.
Acceptance criteria additions:
- `AuthRef::SecretRef` can resolve through the encrypted store for provider API keys without exposing plaintext in resolved config/debug output.
- WebSearch can use a secret ref without `BRAVE_SEARCH_API_KEY` or `web.search.api_key_env`.
- CLI can add/list/delete a secret without printing plaintext.
- Missing secret, invalid id, unavailable key provider, and decryption/auth failure produce clear fail-closed errors naming only the reference.
- Tests cover:
- secret id validation;
- encrypted round-trip with test key provider;
- wrong-key/auth-failure diagnostics;
- provider `AuthRef::SecretRef` resolution;
- WebSearch secret ref resolution;
- no plaintext in `Debug`/serialization paths checked by focused assertions.
Recommended next step:
- Create a short spike/preflight sub-ticket specifically for key provider/dependency choice (`keyring` vs explicit passphrase vs other OS-backed provider). Once that decision is recorded, the implementation ticket can be split into:
1. secret-store crate + CLI management;
2. provider `AuthRef::SecretRef` integration;
3. WebSearch secret ref integration and env migration docs.
---
<!-- event: decision author: hare at: 2026-05-31T21:04:45Z -->
## Decision
Decision: keep the secret store as a provider-independent key-value store.
User decision:
- The secret store path should be completely limited to a simple key-value store.
- Do not hard-code provider-specific slots or provider-specific semantics into the store.
- Do not require metadata in the store schema.
- Conceptual model is:
```text
{
"anthropic/default" = "sk-..."
}
```
- Provider/model/tool configuration is responsible for choosing which secret key to reference.
- The store does not know whether a value is an Anthropic key, Brave key, OpenAI key, token, or anything else.
- Any provider-aware UX should be a higher-level helper that writes ordinary key-value entries and/or config references; it must not change the store schema.
Security stance:
- Use light encryption/obfuscation at rest if practical, but do not claim strong security guarantees.
- The goal is to avoid casual plaintext exposure in files, logs, work items, and accidental grep/cat output, not to defend against a local attacker with access to the user account.
- Avoid complicated key-management requirements such as OS keychain dependency as a prerequisite for this ticket unless a later explicit decision changes the security target.
- Documentation and diagnostics should be honest: this is an obfuscated/encrypted local key-value store with limited protection, not a high-assurance secret manager.
Implications for implementation planning:
- Remove the previous requirement for metadata such as provider/kind/description/created_at/updated_at unless the implementation needs internal versioning/encryption fields.
- Store format may still need technical envelope fields for version/nonce/ciphertext/checksum, but the user-visible logical schema is only `id -> value`.
- Secret id validation remains useful because ids are referenced from manifest/profile/tool config and diagnostics.
- Provider/WebSearch integration should resolve `secret_ref` by direct key lookup only.
---
<!-- event: plan author: hare at: 2026-05-31T21:19:29Z -->
## Plan
Preflight update: implementation-ready as a single phased ticket.
Finalized intent:
- Build a provider-independent local key-value secret store and wire it into provider/WebSearch credential resolution.
- Add `insomnia keys` as the required interactive TUI management surface.
- Keep the security claim modest: avoid casual plaintext exposure and generic env scraping; do not claim strong local security.
Settled decisions:
- Store model is user-visible `id -> value`; no provider-specific slots and no required metadata.
- Store may use technical envelope fields for version/nonce/ciphertext/checksum, but those are implementation details.
- No OS keychain, passphrase UX, or high-assurance key-management dependency in this ticket.
- No automatic provider-name-to-secret-id lookup. Config must explicitly reference a key id.
- This is one ticket with phases, not separate tickets.
Suggested implementation order:
1. Add a focused secret store crate/module with id validation, obfuscated/encrypted persistence, atomic writes, and tests.
2. Add `insomnia keys` TUI manager for list/add/delete with masked input.
3. Implement provider `AuthRef::SecretRef` resolution through the store.
4. Add WebSearch secret-ref configuration path.
5. Update docs and migration messaging.
Critical risks for coder/reviewer:
- Accidentally turning the store into provider-aware schema.
- Displaying plaintext in Debug/errors/TUI list output.
- Overstating security guarantees in docs.
- Adding ambient defaults that recreate the env-var problem in secret-store form.
- Expanding into OS keychain/passphrase design despite the settled modest threat model.
Validation should include focused tests for id validation, store round-trip, decode failure, provider secret-ref resolution, WebSearch secret-ref resolution, and no plaintext in display/debug paths where applicable.
---
<!-- event: decision author: hare at: 2026-05-31T21:23:46Z -->
## Decision
Decision update:
- Migration/backward compatibility for credential environment variables is not required.
- The implementation should remove credential env configuration from the normal provider/WebSearch path rather than keep env fallback as a compatibility layer.
- Docs should point to `insomnia keys` + explicit secret refs as the credential path.
Codex OAuth relationship:
- Codex OAuth should not be connected to this key-value API-key store in this ticket.
- The current Codex integration interoperates with Codex CLI's structured `auth.json` token bundle and refresh behavior.
- That is a different credential shape than `id -> secret string` API keys.
- Keep `CODEX_HOME` / `$HOME/.codex` behavior unchanged for this ticket.
- If Insomnia later owns Codex login/token storage, design it as a separate OAuth token-store feature, not as an implicit use of the simple key-value store.
--- ---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-31T07:42:58Z -->
## Created
Created by tickets.sh create.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-31T07:42:58Z -->
## Created
Created by tickets.sh create.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-31T07:42:58Z -->
## Created
Created by tickets.sh create.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-31T08:59:59Z -->
## Created
Created by tickets.sh create.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-31T08:59:59Z -->
## Created
Created by tickets.sh create.
---