From 625730cb0a219cc768e220eada159ad98b9cc26b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 03:54:56 +0900 Subject: [PATCH] feat: use builtin profile by default --- crates/manifest/src/profile.rs | 89 +++++++++++- crates/pod/src/main.rs | 214 ++++++++++++++++------------- crates/tui/src/spawn.rs | 195 ++++++-------------------- docs/manifest-profiles.md | 12 +- docs/manifest.toml | 20 +-- docs/nix.md | 4 +- docs/pod-factory.md | 24 ++-- resources/nix/profiles/default.nix | 40 ++++++ 8 files changed, 315 insertions(+), 283 deletions(-) create mode 100644 resources/nix/profiles/default.nix diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 6886031a..1bc3c820 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{PodManifest, PodManifestConfig, ResolveError, paths}; const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1"; +const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; /// Registry source for discovered profiles. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -221,6 +222,24 @@ impl ProfileRegistry { self.default = Some(default); } + fn set_builtin_default_if_available(&mut self) { + if self.default.is_some() { + return; + } + if self + .select_named( + Some(ProfileRegistrySource::Builtin), + BUILTIN_DEFAULT_PROFILE_NAME, + ) + .is_ok() + { + self.default = Some(ProfileDefault { + source: Some(ProfileRegistrySource::Builtin), + name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(), + }); + } + } + fn mark_default_flags(&mut self) { let Some(default) = self.default.clone() else { return; @@ -282,6 +301,7 @@ impl ProfileDiscovery { if let Some(path) = &self.project_config { load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?; } + registry.set_builtin_default_if_available(); registry.mark_default_flags(); Ok(registry) } @@ -323,12 +343,14 @@ pub struct ResolvedProfile { #[derive(Debug, Clone)] pub struct NixProfileResolver { nix_bin: PathBuf, + workspace_base: Option, } impl Default for NixProfileResolver { fn default() -> Self { Self { nix_bin: PathBuf::from("nix"), + workspace_base: None, } } } @@ -341,9 +363,15 @@ impl NixProfileResolver { pub fn with_nix_bin(nix_bin: impl Into) -> Self { Self { nix_bin: nix_bin.into(), + workspace_base: None, } } + pub fn with_workspace_base(mut self, workspace_base: impl Into) -> Self { + self.workspace_base = Some(workspace_base.into()); + self + } + pub fn resolve(&self, selector: &ProfileSelector) -> Result { match selector { ProfileSelector::Path { path } => self.resolve_path( @@ -351,6 +379,7 @@ impl NixProfileResolver { ProfileSource::Path { path: absolutize(path)?, }, + None, ), ProfileSelector::Named { .. } | ProfileSelector::Default => { let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { @@ -359,6 +388,11 @@ impl NixProfileResolver { })?; let registry = ProfileDiscovery::for_cwd(&cwd).discover()?; let entry = registry.select(selector)?.clone(); + let artifact_base = if entry.source == ProfileRegistrySource::Builtin { + Some(absolutize(self.workspace_base.as_deref().unwrap_or(&cwd))?) + } else { + None + }; self.resolve_path( &entry.path, ProfileSource::Registry { @@ -366,6 +400,7 @@ impl NixProfileResolver { name: entry.name, path: absolutize(&entry.path)?, }, + artifact_base, ) } } @@ -375,15 +410,17 @@ impl NixProfileResolver { &self, path: &Path, source: ProfileSource, + manifest_base_override: Option, ) -> Result { let absolute_path = absolutize(path)?; - let base_dir = absolute_path + let file_base_dir = absolute_path .parent() .map(Path::to_path_buf) .ok_or_else(|| ProfileError::InvalidPath { path: absolute_path.clone(), message: "profile path has no parent directory".to_string(), })?; + let base_dir = manifest_base_override.unwrap_or(file_base_dir); let output = Command::new(&self.nix_bin) .arg("eval") @@ -948,6 +985,56 @@ mod tests { ); } + #[test] + fn builtin_default_profile_is_registered_as_default() { + let registry = ProfileDiscovery::with_sources(paths::builtin_profiles_dir(), None, None) + .discover() + .unwrap(); + let default = registry.default_entry().unwrap(); + assert_eq!(default.source, ProfileRegistrySource::Builtin); + assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME); + assert!(default.is_default); + assert!(default.path.ends_with("resources/nix/profiles/default.nix")); + } + + #[cfg(unix)] + #[test] + fn builtin_profile_relative_paths_resolve_against_workspace_base() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let profile_path = tmp.path().join("default.nix"); + std::fs::write(&profile_path, "{}").unwrap(); + let nix_bin = tmp.path().join("fake-nix"); + std::fs::write( + &nix_bin, + r#"#!/bin/sh +cat <<'JSON' +{"profile":{"format":"insomnia.nix-profile.v1","name":"default"},"manifest":{"pod":{"name":"default"},"model":{"scheme":"anthropic","model_id":"claude-sonnet-4-20250514"},"scope":{"allow":[{"target":".","permission":"write"}]}}} +JSON +"#, + ) + .unwrap(); + let mut perms = std::fs::metadata(&nix_bin).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&nix_bin, perms).unwrap(); + let resolved = NixProfileResolver::with_nix_bin(&nix_bin) + .resolve_path( + &profile_path, + ProfileSource::Registry { + source: ProfileRegistrySource::Builtin, + name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(), + path: profile_path.clone(), + }, + Some(workspace.clone()), + ) + .unwrap(); + + assert_eq!(resolved.manifest.scope.allow[0].target, workspace); + } + #[test] fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index e1d581ae..b277c50d 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -4,13 +4,13 @@ use std::process::ExitCode; use clap::Parser; use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths}; -use pod::{Pod, PodController, PodFactory, PromptLoader}; +use pod::{Pod, PodController, PromptLoader}; use session_store::{FsStore, PodMetadataStore, SegmentId, Store}; #[derive(Debug, Parser)] #[command( name = "insomnia-pod", - about = "Spawn a Pod process from manifest layers or a single manifest file" + about = "Spawn a Pod process from a Nix profile or a single manifest file" )] struct Cli { /// Nix profile to evaluate. Accepts an explicit path, `path:`, a @@ -29,19 +29,20 @@ struct Cli { #[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])] profile_pod_name: Option, - /// Manifest TOML to use directly, without loading user, project, or - /// overlay layers. + /// Manifest TOML to use directly as a one-file compatibility/debug input. + /// This bypasses profile discovery but still applies builtin defaults and + /// the same required-field validation boundary. #[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])] manifest: Option, - /// Start the project-manifest walk from this directory. When - /// omitted, the factory walks up from the current working - /// directory looking for `.insomnia/manifest.toml`. + /// Deprecated manifest-cascade project root flag. Ambient project/user + /// manifest discovery has been removed; configure/select a profile instead. #[arg(long, value_name = "PATH")] project: Option, - /// Inline TOML string applied as the highest-priority overlay - /// layer. Example: `--overlay 'pod.name = "dbg"'`. + /// Inline TOML override applied to the implicit default profile. This is + /// retained for launchers that need to supply restore/session-time values; + /// it does not re-enable user/project manifest discovery. #[arg(long, value_name = "TOML")] overlay: Option, @@ -94,7 +95,7 @@ fn resolve_manifest_with_user_manifest_env( fn resolve_manifest_with_user_manifest_env_and_profile_loader( cli: &Cli, - user_manifest_env: Option, + _user_manifest_env: Option, load_profile_fn: F, ) -> Result<(PodManifest, PromptLoader), String> where @@ -105,29 +106,31 @@ where return load_profile_fn(&selector, cli.profile_pod_name.as_deref()); } - let user_manifest = paths::user_manifest_path_from_env(user_manifest_env); - if let Some(path) = &cli.manifest { - if user_manifest.is_some() { - return Err(format!( - "--manifest cannot be used when {} is set", - paths::USER_MANIFEST_ENV - )); - } return load_single_manifest(path, cli.pod.as_deref()); } - let factory = build_factory_with_user_manifest_path(cli, user_manifest)?; - factory - .resolve() - .map_err(|e| format!("failed to resolve manifest cascade: {e}")) + if cli.project.is_some() { + return Err( + "--project is no longer supported; normal startup uses profile discovery/default, \ + and --manifest is the only one-file manifest mode" + .to_string(), + ); + } + + let selector = ProfileSelector::Default; + let (manifest, loader) = load_profile_fn(&selector, None)?; + let manifest = apply_cli_overrides_to_manifest(manifest, cli)?; + Ok((manifest, loader)) } fn load_profile( selector: &ProfileSelector, pod_name_override: Option<&str>, ) -> Result<(PodManifest, PromptLoader), String> { - let resolver = NixProfileResolver::new(); + let cwd = std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory for profile: {e}"))?; + let resolver = NixProfileResolver::new().with_workspace_base(cwd); let mut resolved = resolver.resolve(selector).map_err(|e| { format!( "failed to resolve profile {}: {e}", @@ -146,28 +149,32 @@ fn load_single_manifest( ) -> Result<(PodManifest, PromptLoader), String> { let toml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; - let manifest = match pod_name_override { - Some(pod_name) => match PodManifest::from_toml(&toml) { - Ok(mut manifest) => { - manifest.pod.name = pod_name.to_string(); - manifest - } - Err(_) => { - let base = PodManifestConfig::from_toml(&toml) - .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?; - let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name)) - .expect("pod name overlay TOML is generated"); - PodManifest::try_from(base.merge(overlay)).map_err(|e| { - format!( - "failed to resolve manifest {} with --pod: {e}", - path.display() - ) - })? - } - }, - None => PodManifest::from_toml(&toml) - .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?, + let absolute_path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory: {e}"))? + .join(path) }; + let base_dir = absolute_path.parent().ok_or_else(|| { + format!( + "manifest path {} has no parent directory", + absolute_path.display() + ) + })?; + let mut config = PodManifestConfig::builtin_defaults().merge( + PodManifestConfig::from_toml(&toml) + .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? + .resolve_paths(base_dir), + ); + if let Some(pod_name) = pod_name_override { + config = config.merge( + PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name)) + .expect("pod name overlay TOML is generated"), + ); + } + let manifest = PodManifest::try_from(config) + .map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?; Ok((manifest, PromptLoader::builtins_only())) } @@ -179,43 +186,33 @@ fn pod_name_overlay_toml(pod_name: &str) -> String { toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail") } -fn build_factory_with_user_manifest_path( +fn manifest_to_config(manifest: &PodManifest) -> Result { + let value = serde_json::to_value(manifest) + .map_err(|e| format!("failed to serialise resolved manifest for overlay: {e}"))?; + serde_json::from_value(value) + .map_err(|e| format!("failed to convert resolved manifest for overlay: {e}")) +} + +fn apply_cli_overrides_to_manifest( + mut manifest: PodManifest, cli: &Cli, - user_manifest: Option, -) -> Result { - let mut factory = PodFactory::new(); - - factory = match user_manifest { - Some(path) => factory - .with_user_manifest(path) - .map_err(|e| format!("failed to load user manifest: {e}"))?, - None => factory - .with_user_manifest_auto() - .map_err(|e| format!("failed to auto-load user manifest: {e}"))?, - }; - - factory = match &cli.project { - Some(path) => factory - .with_project_manifest_from(path) - .map_err(|e| format!("failed to load project manifest: {e}"))?, - None => factory - .with_project_manifest_auto() - .map_err(|e| format!("failed to auto-load project manifest: {e}"))?, - }; - +) -> Result { + let profile = manifest.profile.clone(); if let Some(overlay) = cli.overlay.as_deref() { - factory = factory - .with_overlay_toml(overlay) - .map_err(|e| format!("failed to parse overlay TOML: {e}"))?; + let base_dir = std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory for overlay: {e}"))?; + let overlay = PodManifestConfig::from_toml(overlay) + .map_err(|e| format!("failed to parse overlay TOML: {e}"))? + .resolve_paths(&base_dir); + let config = manifest_to_config(&manifest)?.merge(overlay); + manifest = PodManifest::try_from(config) + .map_err(|e| format!("failed to resolve default profile overlay: {e}"))?; + manifest.profile = profile; } - if let Some(pod_name) = cli.pod.as_deref() { - factory = factory - .with_overlay_toml(&pod_name_overlay_toml(pod_name)) - .map_err(|e| format!("failed to apply --pod overlay: {e}"))?; + manifest.pod.name = pod_name.to_string(); } - - Ok(factory) + Ok(manifest) } #[tokio::main] @@ -442,18 +439,20 @@ permission = "write" } #[test] - fn manifest_conflicts_with_user_manifest_env_when_env_is_non_empty() { + fn manifest_ignores_non_empty_user_manifest_env() { let tmp = TempDir::new().unwrap(); let manifest = tmp.path().join("manifest.toml"); write(&manifest, &manifest_toml("single", tmp.path())); let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()]) .unwrap(); - let err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml"))) - .unwrap_err(); + let (manifest, loader) = + resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml"))) + .unwrap(); - assert!(err.contains("--manifest cannot be used")); - assert!(err.contains(paths::USER_MANIFEST_ENV)); + assert_eq!(manifest.pod.name, "single"); + assert!(loader.user_dir().is_none()); + assert!(loader.workspace_dir().is_none()); } #[test] @@ -549,26 +548,38 @@ permission = "write" } #[test] - fn user_manifest_env_overrides_auto_user_manifest_path() { + fn normal_startup_uses_default_profile_and_ignores_user_manifest_env() { let tmp = TempDir::new().unwrap(); - let user_manifest = tmp.path().join("custom-user.toml"); - write(&user_manifest, &manifest_toml("from-env", tmp.path())); - let no_project_root = tmp.path().join("no-project"); - std::fs::create_dir_all(&no_project_root).unwrap(); - let cli = Cli::try_parse_from([ - "insomnia-pod", - "--project", - no_project_root.to_str().unwrap(), - ]) - .unwrap(); + let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap(); + let mut called = false; - let (manifest, _loader) = resolve_manifest_with_user_manifest_env( + let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( &cli, - Some(user_manifest.as_os_str().to_os_string()), + Some(OsString::from("ignored-user-manifest.toml")), + |selector, pod_name| { + called = true; + assert_eq!(selector, &ProfileSelector::Default); + assert_eq!(pod_name, None); + let manifest = + PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) + .unwrap(); + Ok((manifest, PromptLoader::builtins_only())) + }, ) .unwrap(); - assert_eq!(manifest.pod.name, "from-env"); + assert!(called); + assert_eq!(manifest.pod.name, "from-default-profile"); + } + + #[test] + fn project_flag_no_longer_enables_ambient_manifest_cascade() { + let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap(); + let err = resolve_manifest_with_user_manifest_env_and_profile_loader(&cli, None, |_, _| { + panic!("default profile loader must not run when deprecated --project is present") + }) + .unwrap_err(); + assert!(err.contains("--project is no longer supported")); } #[test] @@ -605,7 +616,17 @@ permission = "write" let manifest = tmp.path().join("manifest.toml"); write( &manifest, - &manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""), + r#" +[pod] + +[model] +scheme = "anthropic" +model_id = "test-model" + +[[scope.allow]] +target = "." +permission = "write" +"#, ); let cli = Cli::try_parse_from([ "insomnia-pod", @@ -619,6 +640,7 @@ permission = "write" let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); assert_eq!(manifest.pod.name, "from-flag"); + assert_eq!(manifest.scope.allow[0].target, tmp.path()); } #[test] diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 9d70df26..199bc0bf 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -1,29 +1,23 @@ //! Inline-viewport "spawn Pod and attach" UX. //! //! Rendered at the user's current cursor position when `insomnia` is invoked -//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml` -//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile -//! choices, prompts for the Pod's name, and on confirmation -//! launches the `insomnia-pod` binary as an independent process with a freshly built -//! overlay (name + cwd scope when no project manifest exists). Once -//! the process reports its socket via the `INSOMNIA-READY` stderr line, -//! the dialog hands control back so main can switch the terminal to -//! alternate-screen mode. +//! with no positional argument. Discovers `.insomnia/profiles.toml` profile +//! choices plus bundled profiles, defaults to the builtin profile, prompts for +//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an +//! independent process. Once the process reports its socket via the +//! `INSOMNIA-READY` stderr line, the dialog hands control back so main can +//! switch the terminal to alternate-screen mode. //! //! The viewport's last frame stays in the terminal's scrollback so the //! user has a record of what was spawned (or why a spawn failed). -use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; use client::{SpawnConfig, spawn_pod}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; -use manifest::{ - PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer, - user_manifest_path, user_manifest_path_from_env, -}; +use manifest::{ProfileDiscovery, ScopeConfig}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout}; @@ -97,7 +91,11 @@ pub async fn run( profile: Option, ) -> Result { let defaults = load_spawn_defaults()?; - let mut profile_choices = defaults.profile_choices; + let mut profile_choices = if resume_from.is_some() { + Vec::new() + } else { + defaults.profile_choices + }; let profile_index = initial_profile_index( &mut profile_choices, profile.as_deref(), @@ -232,42 +230,6 @@ struct ProfileChoice { fn load_spawn_defaults() -> Result { let cwd = std::env::current_dir().map_err(SpawnError::Io)?; - // Run the same merge pod itself uses, then read what's missing off the - // result. We only look at `scope.allow` here — `pod.name` is an - // instance-level identifier and is supplied by the dialog or `--pod`. - // TUI must pre-read the same user manifest path that the pod CLI will use, - // including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall - // back to the auto-discovered path. - let user_layer = user_manifest_path_for_spawn( - std::env::var_os(manifest::paths::USER_MANIFEST_ENV), - user_manifest_path(), - ) - .filter(|p| p.is_file()) - .and_then(|p| load_layer(&p).ok()); - let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok()); - - let mut cascade = PodManifestConfig::builtin_defaults(); - for layer in [user_layer.as_ref(), project_layer.as_ref()] - .into_iter() - .flatten() - { - cascade = cascade.merge(layer.clone()); - } - let cascade_has_scope = !cascade.scope.allow.is_empty(); - - let scope_origin = match ( - project_layer - .as_ref() - .is_some_and(|l| !l.scope.allow.is_empty()), - user_layer - .as_ref() - .is_some_and(|l| !l.scope.allow.is_empty()), - ) { - (true, _) => ScopeOrigin::FromProject, - (false, true) => ScopeOrigin::FromUser, - (false, false) => ScopeOrigin::CwdDefault, - }; - let default_name = cwd .file_name() .and_then(|s| s.to_str()) @@ -279,8 +241,8 @@ fn load_spawn_defaults() -> Result { Ok(SpawnDefaults { cwd, - cascade_has_scope, - scope_origin, + cascade_has_scope: true, + scope_origin: ScopeOrigin::FromProfile, default_name, default_profile_index, profile_choices, @@ -288,16 +250,11 @@ fn load_spawn_defaults() -> Result { } fn profile_choices_for_cwd(cwd: &Path) -> (Vec, usize) { - let mut choices = vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }]; - let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else { - return (choices, 0); + return (Vec::new(), 0); }; + let mut choices = Vec::new(); for entry in registry.entries() { let mut label = entry.qualified_name(); if entry.is_default { @@ -343,13 +300,6 @@ fn initial_profile_index( choices.len() - 1 } -fn user_manifest_path_for_spawn( - env_value: Option, - default_user_manifest: Option, -) -> Option { - user_manifest_path_from_env(env_value).or(default_user_manifest) -} - fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { Form { cwd: defaults.cwd, @@ -362,11 +312,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { resume_from: None, resume_by_pod_name: true, resume_scope: None, - profile_choices: vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }], + profile_choices: Vec::new(), profile_index: 0, } } @@ -519,16 +465,14 @@ enum MessageKind { } enum ScopeOrigin { - FromUser, - FromProject, + FromProfile, CwdDefault, } struct Form { cwd: PathBuf, - /// True when at least one cascade layer (user or project manifest) - /// already declares `scope.allow`. Drives whether the overlay - /// should add a cwd-write rule. + /// True when the launch source already supplies `scope.allow`. + /// Drives whether the compatibility overlay should add a cwd-write rule. cascade_has_scope: bool, /// Display label for the scope row in the dialog. scope_origin: ScopeOrigin, @@ -648,7 +592,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) { let layout = Layout::vertical([ Constraint::Length(1), // title Constraint::Length(1), // name field - Constraint::Length(1), // context (manifest or scope default) + Constraint::Length(1), // context (profile or scope default) Constraint::Length(1), // hint Constraint::Length(1), // message Constraint::Length(1), // spacer @@ -716,15 +660,10 @@ fn context_line(form: &Form) -> Line<'_> { } match form.scope_origin { - ScopeOrigin::FromProject => Line::from(vec![ + ScopeOrigin::FromProfile => Line::from(vec![ Span::raw(" "), Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled("from project manifest", Style::default().fg(Color::Green)), - ]), - ScopeOrigin::FromUser => Line::from(vec![ - Span::raw(" "), - Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled("from user manifest", Style::default().fg(Color::Green)), + Span::styled("from default profile", Style::default().fg(Color::Green)), ]), ScopeOrigin::CwdDefault => Line::from(vec![ Span::raw(" "), @@ -767,7 +706,7 @@ mod tests { cwd: PathBuf::from("/work/example"), cascade_has_scope, scope_origin: if cascade_has_scope { - ScopeOrigin::FromProject + ScopeOrigin::FromProfile } else { ScopeOrigin::CwdDefault }, @@ -778,11 +717,7 @@ mod tests { resume_from: None, resume_by_pod_name: false, resume_scope: None, - profile_choices: vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }], + profile_choices: Vec::new(), profile_index: 0, } } @@ -792,14 +727,10 @@ mod tests { let defaults = SpawnDefaults { cwd: PathBuf::from("/work/example"), cascade_has_scope: true, - scope_origin: ScopeOrigin::FromProject, + scope_origin: ScopeOrigin::FromProfile, default_name: "ignored".to_string(), default_profile_index: 0, - profile_choices: vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }], + profile_choices: Vec::new(), }; let f = form_for_pod_name("agent".to_string(), defaults); @@ -860,46 +791,6 @@ mod tests { assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child")); } - #[test] - fn cascade_merge_detects_scope_from_any_layer() { - let user = PodManifestConfig::from_toml( - r#" -[[scope.allow]] -target = "/from-user" -permission = "write" -"#, - ) - .unwrap(); - let mut cascade = PodManifestConfig::builtin_defaults(); - cascade = cascade.merge(user); - assert!(!cascade.scope.allow.is_empty()); - - let empty_cascade = PodManifestConfig::builtin_defaults(); - assert!(empty_cascade.scope.allow.is_empty()); - } - - #[test] - fn user_manifest_path_for_spawn_prefers_non_empty_env_override() { - assert_eq!( - user_manifest_path_for_spawn( - Some(OsString::from("/tmp/override.toml")), - Some(PathBuf::from("/default/manifest.toml")), - ), - Some(PathBuf::from("/tmp/override.toml")), - ); - } - - #[test] - fn user_manifest_path_for_spawn_treats_empty_env_as_unset() { - assert_eq!( - user_manifest_path_for_spawn( - Some(OsString::from("")), - Some(PathBuf::from("/default/manifest.toml")), - ), - Some(PathBuf::from("/default/manifest.toml")), - ); - } - #[test] fn profile_choices_use_project_registry_default() { let temp = tempfile::tempdir().unwrap(); @@ -925,7 +816,7 @@ coder = "profiles/coder.nix" } #[test] - fn profile_choices_include_no_profile_source_labels_and_default_marker() { + fn profile_choices_include_builtin_and_project_default_marker() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let insomnia = project.join(".insomnia"); @@ -942,22 +833,17 @@ description = "Project coder" .unwrap(); let (choices, default_index) = profile_choices_for_cwd(&project); - assert_eq!(choices[0].selector, None); - assert_eq!(choices[0].label, "manifest cascade"); + assert_eq!(choices[0].selector.as_deref(), Some("builtin:default")); + assert_eq!(choices[0].label, "builtin:default"); assert_eq!(default_index, 1); assert_eq!(choices[1].selector.as_deref(), Some("project:coder")); assert_eq!(choices[1].label, "project:coder (default) — Project coder"); } #[test] - fn profile_cycle_selects_profiles_and_can_opt_out_of_default() { + fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() { let mut form = form("coder", true); form.profile_choices = vec![ - ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }, ProfileChoice { selector: Some("project:coder".to_string()), label: "project:coder (default)".to_string(), @@ -969,7 +855,7 @@ description = "Project coder" is_default: false, }, ]; - form.profile_index = 1; + form.profile_index = 0; assert_eq!( form.selected_profile_selector().as_deref(), @@ -981,7 +867,10 @@ description = "Project coder" Some("user:reviewer") ); form.cycle_profile_next(); - assert_eq!(form.selected_profile_selector(), None); + assert_eq!( + form.selected_profile_selector().as_deref(), + Some("project:coder") + ); form.cycle_profile_prev(); assert_eq!( form.selected_profile_selector().as_deref(), @@ -991,15 +880,11 @@ description = "Project coder" #[test] fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() { - let mut choices = vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }]; + let mut choices = Vec::new(); let selected = initial_profile_index(&mut choices, Some("coder"), 0); - assert_eq!(selected, 1); - assert_eq!(choices[1].selector.as_deref(), Some("coder")); - assert_eq!(choices[1].label, "coder"); + assert_eq!(selected, 0); + assert_eq!(choices[0].selector.as_deref(), Some("coder")); + assert_eq!(choices[0].label, "coder"); } #[test] diff --git a/docs/manifest-profiles.md b/docs/manifest-profiles.md index 74bbc692..20405b33 100644 --- a/docs/manifest-profiles.md +++ b/docs/manifest-profiles.md @@ -61,7 +61,7 @@ path = "profiles/coder.nix" description = "Project coding assistant" ``` -Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. Later defaults override earlier defaults, so a project default wins over a user default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed: +Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. The bundled `builtin:default` profile is the fallback default when no user/project registry declares another default. Later defaults override earlier defaults, so a project default wins over a user default, and either wins over the builtin default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed: ```sh insomnia --profile coder # fails if both user:coder and project:coder exist @@ -69,7 +69,13 @@ insomnia --profile project:coder # source-qualified selection insomnia --profile default # selected registry default ``` -The fresh-spawn TUI also uses discovery. If a default profile is configured, the new Pod dialog shows a selectable source-qualified profile row such as `profile: project:coder (default)`. `Tab`/`Down` cycles forward through discovered profiles, `Shift-Tab`/`Up` cycles backward, and the `manifest cascade` choice opts out of a default profile for that spawn. Passing `insomnia --profile ` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged. +The fresh-spawn TUI also uses discovery. The new Pod dialog defaults to the selected registry default, normally `builtin:default` unless a user/project registry overrides it. `Tab`/`Down` cycles forward through discovered profiles and `Shift-Tab`/`Up` cycles backward; there is no ambient manifest-cascade opt-out. Passing `insomnia --profile ` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged. + +## One-file manifests + +`insomnia-pod --manifest ` remains as an explicit compatibility/debug path. It reads exactly that TOML file, resolves relative paths against the file's parent directory, merges builtin defaults, and validates through the same `PodManifestConfig -> PodManifest` boundary as profile artifacts. It does not load user or project `manifest.toml` files and conflicts with `--profile`. + +Ambient user/project `manifest.toml` cascade startup has been removed. Normal fresh spawns use profile discovery/default selection, with `profiles.toml` acting only as a profile registry/default selector. ## Artifact contract @@ -79,7 +85,7 @@ A profile should evaluate to one of: - `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }` - a raw manifest/config object for debug/test paths. -The `manifest`/`config` object uses the same field names as the existing manifest config. Relative paths are resolved against the directory containing the profile file, then builtin defaults are merged and validation produces the runtime `PodManifest`. +The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`. Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like: diff --git a/docs/manifest.toml b/docs/manifest.toml index 30e0a01e..716adbf6 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -3,17 +3,17 @@ # ============================================================================ # Pod の宣言的設定 (`PodManifest` / `PodManifestConfig`)。 # -# カスケード層は下から順に -# 1. builtin defaults (`manifest::defaults`) -# 2. user manifest (`/manifest.toml`) -# 3. project manifest (cwd から上方向に探す `.insomnia/manifest.toml`) -# 4. programmatic overlay (呼び出し側が差し込む) -# 上の層が同名フィールドを上書き、scope rule と skills.directories は -# 累積マージ、tool_output.per_tool は key 単位でマージ。 +# このファイル形式は低レベル runtime manifest。通常起動は profile discovery/default +# (`profiles.toml` と bundled builtin profile) から manifest を生成する。 +# `insomnia-pod --manifest ` の one-file compatibility/debug mode では、 +# 指定した TOML 1 枚に builtin defaults を merge し、required validation を行う。 +# user/project `manifest.toml` を暗黙に merge する通常起動 cascade は使わない。 # -# パス解決: 相対パスは「その層の manifest ファイルが置かれているディレクトリ」 -# を base に絶対パスへ解決される (overlay 層は cwd)。マージは絶対化済みの -# 値同士で行われる。 +# `PodManifestConfig` の merge 規則: 上の層が同名フィールドを上書き、scope rule と +# skills.directories は累積マージ、tool_output.per_tool は key 単位でマージ。 +# +# パス解決: `--manifest ` では相対パスはその manifest ファイルの親ディレクトリ +# を base に絶対パスへ解決される。profile artifact でも同じ validation 境界を通る。 # # 凡例: # - 必須 … 値が無いと resolve エラー diff --git a/docs/nix.md b/docs/nix.md index e167ab81..4c491ff4 100644 --- a/docs/nix.md +++ b/docs/nix.md @@ -43,11 +43,11 @@ The Nix package does not put user configuration, sessions, sockets, or other mut | Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback | | --- | --- | --- | --- | -| User config (`manifest.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` | +| 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` | -`INSOMNIA_USER_MANIFEST=` can still be used to select an explicit user manifest for the Pod CLI cascade path. Project manifests are still discovered from `.insomnia/manifest.toml` under the current workspace unless a CLI mode documents otherwise. +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 ` remains a one-file compatibility/debug input. `INSOMNIA_USER_MANIFEST` and ambient `.insomnia/manifest.toml` discovery are not part of normal Pod/TUI startup. ## Validation diff --git a/docs/pod-factory.md b/docs/pod-factory.md index 7fda3282..09da786c 100644 --- a/docs/pod-factory.md +++ b/docs/pod-factory.md @@ -337,42 +337,34 @@ import-map 形式のプレフィックスで指定する: ## `insomnia-pod` CLI -`insomnia-pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する。 +`insomnia-pod` の通常起動は profile discovery/default から runtime manifest を作る。user/project `manifest.toml` の ambient cascade は通常起動では使わない。 ``` -insomnia-pod [--project ] [--overlay ] [-s/--store ] [--session ] +insomnia-pod [--profile ] [--profile-pod-name ] [-s/--store ] [--session ] ``` | フラグ | 説明 | |---|---| -| `--project ` | プロジェクト manifest 探索の起点。省略時は cwd から上方向に `.insomnia/manifest.toml` を探索 | -| `--overlay ` | 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'worker.instruction = "$user/foo"'`) | +| `--profile ` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default(通常は `builtin:default`) | +| `--profile-pod-name ` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き | +| `--overlay ` | TUI/launcher compatibility 用の inline override。user/project manifest discovery は行わない | | `-s, --store ` | セッション永続化ディレクトリ(デフォルト: `/sessions/`、`manifest::paths` で解決) | | `--session ` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する | -user manifest は CLI フラグではなく、以下の規則で解決する。 - -| 入力 | 挙動 | -|---|---| -| `INSOMNIA_USER_MANIFEST=` | 指定 path を user manifest として読む。ファイル不在や parse error は起動エラー | -| `INSOMNIA_USER_MANIFEST=` | 空文字列は未指定扱い | -| env 未指定 | `manifest::paths::user_manifest_path()` で自動探索し、存在すれば読む | - -単一ファイルだけで起動したい場合は cascade を使わず、`--manifest` を指定する。 +単一ファイルだけで起動したい場合は `--manifest` を指定する。 ``` insomnia-pod --manifest [-s/--store ] [--session ] ``` -`--manifest` は指定 TOML 1 枚だけを `PodManifest::from_toml` で読み、user / project / overlay layer は一切読まない。したがって `--project`、`--overlay`、非空の `INSOMNIA_USER_MANIFEST` とは併用不可。 +`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project`、`--overlay` とは併用不可。 spawn 子 Pod 用の内部フラグとして `--adopt` と `--callback ` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。 Pod の作業ディレクトリは `insomnia-pod` 起動時の cwd が直接使われる。別ディレクトリで 動かしたい場合は `cd && insomnia-pod ...` のように外側で `cd` してから起動する。 -引数無しで起動すると、cwd + `manifest::paths` の自動解決だけで動く最小構成になる -(overlay 無し、プロジェクトに `.insomnia/manifest.toml` があればそれを使う)。 +引数無しで起動すると、profile registry default(通常は bundled `builtin:default`)で起動する。 --- diff --git a/resources/nix/profiles/default.nix b/resources/nix/profiles/default.nix new file mode 100644 index 00000000..6c5bcd0a --- /dev/null +++ b/resources/nix/profiles/default.nix @@ -0,0 +1,40 @@ +let + insomnia = import ../profile-lib.nix {}; +in +insomnia.mkProfile { + name = "default"; + description = "Bundled default Insomnia coding profile"; + manifest = insomnia.mkManifest { + pod.name = "insomnia"; + + scope.allow = [ + { target = "."; permission = "write"; recursive = true; } + ]; + + session.record_event_trace = true; + + worker.reasoning = "high"; + + model.ref = "codex-oauth/gpt-5.5"; + + compaction = { + threshold = 200000; + request_threshold = 240000; + worker_context_max_tokens = 100000; + }; + + memory = { + extract_threshold = 50000; + consolidation_threshold_files = 5; + consolidation_threshold_bytes = 50000; + }; + + web = { + enabled = true; + search = { + provider = "brave"; + api_key_env = "BRAVE_SEARCH_API_KEY"; + }; + }; + }; +}