feat: add manifest profile discovery

This commit is contained in:
Keisuke Hirata 2026-05-30 02:18:42 +09:00
parent 06c778a725
commit ee7147b355
No known key found for this signature in database
8 changed files with 822 additions and 63 deletions

View File

@ -27,11 +27,11 @@ pub struct SpawnConfig {
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub pod_name: String,
/// Optional Nix profile path. When present the child is launched with
/// Optional Nix profile selector. When present the child is launched with
/// `--profile` and the TOML overlay is not passed; the Pod name is supplied
/// through `--profile-pod-name` so profile evaluation stays separate from
/// manifest layer merging and from `--pod` restore semantics.
pub profile_path: Option<PathBuf>,
pub profile: Option<String>,
/// `--overlay` で pod に渡す TOML 文字列。
pub overlay_toml: String,
/// pod の current_dir。
@ -117,16 +117,16 @@ where
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
.process_group(0);
if let Some(profile_path) = &config.profile_path {
if let Some(profile) = &config.profile {
command
.arg("--profile")
.arg(profile_path)
.arg(profile)
.arg("--profile-pod-name")
.arg(&config.pod_name);
} else {
command.arg("--overlay").arg(&config.overlay_toml);
}
if config.resume_by_pod_name && config.profile_path.is_none() {
if config.resume_by_pod_name && config.profile.is_none() {
command.arg("--pod").arg(&config.pod_name);
}
if let Some(id) = config.resume_from {

View File

@ -18,8 +18,9 @@ pub use paths::{
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
};
pub use profile::{
NixProfileResolver, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileSelector,
ProfileSource, ResolvedProfile, resolve_profile_artifact,
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
ResolvedProfile, resolve_profile_artifact,
};
pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError, SharedScope};

View File

@ -33,6 +33,9 @@ use std::path::PathBuf;
/// auto-discovered user manifest path.
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
/// Environment variable that points at installed project resources.
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
/// `prompts/` などが置かれる。
pub fn config_dir() -> Option<PathBuf> {
@ -114,6 +117,32 @@ pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts"))
}
/// Root resource directory used for bundled prompts/Nix support files.
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 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("nix").join("profiles"))
}
/// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> {
Some(config_dir()?.join("prompts.toml"))
@ -192,6 +221,7 @@ mod tests {
"INSOMNIA_DATA_DIR",
"INSOMNIA_RUNTIME_DIR",
"INSOMNIA_USER_MANIFEST",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",

View File

@ -4,34 +4,316 @@
//! resolved artifact. Rust consumes the evaluated JSON artifact directly and
//! validates it into the existing [`crate::PodManifest`] runtime contract.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::{PodManifest, PodManifestConfig, ResolveError};
use crate::{PodManifest, PodManifestConfig, ResolveError, paths};
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1";
/// Registry source for discovered profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileRegistrySource {
Builtin,
User,
Project,
}
impl ProfileRegistrySource {
pub fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::User => "user",
Self::Project => "project",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw {
"builtin" => Some(Self::Builtin),
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
}
impl std::fmt::Display for ProfileRegistrySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// User selection of a profile source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSelector {
/// A local Nix expression evaluated with `nix eval --json --file <path>`.
Path { path: PathBuf },
/// A named profile discovered from builtin/user/project registries.
Named {
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<ProfileRegistrySource>,
name: String,
},
/// The effective default from the discovered profile registry.
Default,
}
impl ProfileSelector {
pub fn path(path: impl Into<PathBuf>) -> Self {
Self::Path { path: path.into() }
}
pub fn named(name: impl Into<String>) -> Self {
Self::Named {
source: None,
name: name.into(),
}
}
pub fn source_named(source: ProfileRegistrySource, name: impl Into<String>) -> Self {
Self::Named {
source: Some(source),
name: name.into(),
}
}
/// Parse the CLI/TUI `--profile` argument.
///
/// `path:<path>` always selects an explicit path. `builtin:<name>`,
/// `user:<name>`, and `project:<name>` require the given registry source.
/// Unqualified path-like values (containing `/`, starting with `.`, or ending
/// in `.nix`) remain compatible with the original explicit-path flow; other
/// values are discovered names. `default` asks discovery for the effective
/// default profile.
pub fn parse_cli(raw: &str) -> Self {
if raw == "default" {
return Self::Default;
}
if let Some(path) = raw.strip_prefix("path:") {
return Self::path(path);
}
if let Some((prefix, name)) = raw.split_once(':') {
if let Some(source) = ProfileRegistrySource::parse(prefix) {
return Self::source_named(source, name);
}
}
if raw.contains('/') || raw.starts_with('.') || raw.ends_with(".nix") {
Self::path(raw)
} else {
Self::named(raw)
}
}
pub fn display_label(&self) -> String {
match self {
Self::Path { path } => path.display().to_string(),
Self::Named { source, name } => match source {
Some(source) => format!("{source}:{name}"),
None => name.clone(),
},
Self::Default => "default".to_string(),
}
}
}
/// Profile source recorded with a resolved artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSource {
Path { path: PathBuf },
Path {
path: PathBuf,
},
Registry {
source: ProfileRegistrySource,
name: String,
path: PathBuf,
},
}
/// One profile discovered from a registry source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileRegistryEntry {
pub source: ProfileRegistrySource,
pub name: String,
pub path: PathBuf,
pub description: Option<String>,
pub is_default: bool,
}
impl ProfileRegistryEntry {
pub fn qualified_name(&self) -> String {
format!("{}:{}", self.source, self.name)
}
}
/// Discovered profile registry. User/project manifests contribute only profile
/// discovery metadata (entries, aliases, defaults); those files are not merged
/// into the selected profile's runtime manifest.
#[derive(Debug, Clone, Default)]
pub struct ProfileRegistry {
entries: Vec<ProfileRegistryEntry>,
aliases: Vec<ProfileAlias>,
default: Option<ProfileDefault>,
}
impl ProfileRegistry {
pub fn entries(&self) -> &[ProfileRegistryEntry] {
&self.entries
}
pub fn default_entry(&self) -> Result<&ProfileRegistryEntry, ProfileError> {
let default = self
.default
.as_ref()
.ok_or(ProfileError::NoDefaultProfile)?;
self.select_named(default.source, &default.name)
}
pub fn select(
&self,
selector: &ProfileSelector,
) -> Result<&ProfileRegistryEntry, ProfileError> {
match selector {
ProfileSelector::Path { .. } => Err(ProfileError::InvalidArtifact(
"path selectors are not registry entries".to_string(),
)),
ProfileSelector::Default => self.default_entry(),
ProfileSelector::Named { source, name } => self.select_named(*source, name),
}
}
fn select_named(
&self,
source: Option<ProfileRegistrySource>,
name: &str,
) -> Result<&ProfileRegistryEntry, ProfileError> {
let alias_matches: Vec<_> = self
.aliases
.iter()
.filter(|alias| alias.name == name && source.is_none_or(|s| s == alias.source))
.collect();
match alias_matches.as_slice() {
[alias] => return self.select_named(alias.target_source, &alias.target_name),
[] => {}
_ => {
return Err(ProfileError::AmbiguousProfileName {
name: name.to_string(),
matches: alias_matches
.iter()
.map(|alias| format!("{}:{}", alias.source, alias.name))
.collect(),
});
}
}
let matches: Vec<_> = self
.entries
.iter()
.filter(|entry| entry.name == name && source.is_none_or(|s| s == entry.source))
.collect();
match matches.as_slice() {
[entry] => Ok(*entry),
[] => Err(ProfileError::ProfileNotFound {
selector: match source {
Some(source) => format!("{source}:{name}"),
None => name.to_string(),
},
}),
_ => Err(ProfileError::AmbiguousProfileName {
name: name.to_string(),
matches: matches.iter().map(|entry| entry.qualified_name()).collect(),
}),
}
}
fn push_entry(&mut self, entry: ProfileRegistryEntry) {
self.entries.push(entry);
}
fn push_alias(&mut self, alias: ProfileAlias) {
self.aliases.push(alias);
}
fn set_default(&mut self, default: ProfileDefault) {
self.default = Some(default);
}
fn mark_default_flags(&mut self) {
let Some(default) = self.default.clone() else {
return;
};
let Some(source) = default.source else {
return;
};
for entry in &mut self.entries {
entry.is_default = entry.source == source && entry.name == default.name;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileAlias {
source: ProfileRegistrySource,
name: String,
target_source: Option<ProfileRegistrySource>,
target_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileDefault {
source: Option<ProfileRegistrySource>,
name: String,
}
/// Filesystem-backed profile discovery.
#[derive(Debug, Clone)]
pub struct ProfileDiscovery {
builtin_dir: Option<PathBuf>,
user_manifest: Option<PathBuf>,
project_manifest: Option<PathBuf>,
}
impl ProfileDiscovery {
pub fn for_cwd(cwd: &Path) -> Self {
Self {
builtin_dir: paths::builtin_profiles_dir(),
user_manifest: paths::user_manifest_path_with_env_override(),
project_manifest: crate::find_project_manifest_from(cwd),
}
}
pub fn with_sources(
builtin_dir: Option<PathBuf>,
user_manifest: Option<PathBuf>,
project_manifest: Option<PathBuf>,
) -> Self {
Self {
builtin_dir,
user_manifest,
project_manifest,
}
}
pub fn discover(&self) -> Result<ProfileRegistry, ProfileError> {
let mut registry = ProfileRegistry::default();
if let Some(dir) = &self.builtin_dir {
discover_profile_dir(&mut registry, ProfileRegistrySource::Builtin, dir)?;
}
if let Some(path) = &self.user_manifest {
load_profile_config_manifest(&mut registry, ProfileRegistrySource::User, path)?;
}
if let Some(path) = &self.project_manifest {
load_profile_config_manifest(&mut registry, ProfileRegistrySource::Project, path)?;
}
registry.mark_default_flags();
Ok(registry)
}
}
/// Metadata optionally emitted by `mkProfile`.
@ -93,11 +375,36 @@ impl NixProfileResolver {
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
match selector {
ProfileSelector::Path { path } => self.resolve_path(path),
ProfileSelector::Path { path } => self.resolve_path(
path,
ProfileSource::Path {
path: absolutize(path)?,
},
),
ProfileSelector::Named { .. } | ProfileSelector::Default => {
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
path: PathBuf::from("."),
source,
})?;
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
let entry = registry.select(selector)?.clone();
self.resolve_path(
&entry.path,
ProfileSource::Registry {
source: entry.source,
name: entry.name,
path: absolutize(&entry.path)?,
},
)
}
}
}
fn resolve_path(&self, path: &Path) -> Result<ResolvedProfile, ProfileError> {
fn resolve_path(
&self,
path: &Path,
source: ProfileSource,
) -> Result<ResolvedProfile, ProfileError> {
let absolute_path = absolutize(path)?;
let base_dir = absolute_path
.parent()
@ -137,17 +444,11 @@ impl NixProfileResolver {
let raw_artifact: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse {
path: absolute_path.clone(),
path: absolute_path,
source,
})?;
resolve_profile_artifact(
ProfileSource::Path {
path: absolute_path,
},
&base_dir,
raw_artifact,
)
resolve_profile_artifact(source, &base_dir, raw_artifact)
}
}
@ -210,6 +511,150 @@ impl ProfileEnvelope {
}
}
#[derive(Debug, Default, Deserialize)]
struct ProfileConfigDocument {
#[serde(default)]
profiles: Option<ProfilesConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct ProfilesConfig {
#[serde(default)]
default: Option<String>,
#[serde(default, alias = "entries")]
profile: BTreeMap<String, ProfileEntryConfig>,
#[serde(default, alias = "aliases")]
alias: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ProfileEntryConfig {
Path(String),
Table {
path: PathBuf,
#[serde(default)]
description: Option<String>,
},
}
impl ProfileEntryConfig {
fn into_parts(self) -> (PathBuf, Option<String>) {
match self {
Self::Path(path) => (PathBuf::from(path), None),
Self::Table { path, description } => (path, description),
}
}
}
fn load_profile_config_manifest(
registry: &mut ProfileRegistry,
source: ProfileRegistrySource,
path: &Path,
) -> Result<(), ProfileError> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path).map_err(|source| ProfileError::ConfigRead {
path: path.to_path_buf(),
source,
})?;
let document: ProfileConfigDocument =
toml::from_str(&content).map_err(|source| ProfileError::ConfigParse {
path: path.to_path_buf(),
source,
})?;
let Some(config) = document.profiles else {
return Ok(());
};
let base = path.parent().unwrap_or_else(|| Path::new("."));
for (name, entry_config) in config.profile {
let (entry_path, description) = entry_config.into_parts();
registry.push_entry(ProfileRegistryEntry {
source,
name,
path: join_if_relative(base, &entry_path),
description,
is_default: false,
});
}
for (name, target) in config.alias {
let (target_source, target_name) = parse_profile_ref(&target);
registry.push_alias(ProfileAlias {
source,
name,
target_source,
target_name,
});
}
if let Some(default) = config.default {
let (default_source, default_name) = parse_profile_ref(&default);
registry.set_default(ProfileDefault {
source: default_source.or(Some(source)),
name: default_name,
});
}
Ok(())
}
fn discover_profile_dir(
registry: &mut ProfileRegistry,
source: ProfileRegistrySource,
dir: &Path,
) -> Result<(), ProfileError> {
if !dir.is_dir() {
return Ok(());
}
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("nix") {
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.nix");
if profile.is_file() {
if 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) {
if let Some((prefix, name)) = raw.split_once(':') {
if let Some(source) = ProfileRegistrySource::parse(prefix) {
return (Some(source), name.to_string());
}
}
(None, raw.to_string())
}
fn extract_manifest_value(raw: &serde_json::Value) -> Result<serde_json::Value, ProfileError> {
match raw {
serde_json::Value::Object(map) => {
@ -241,7 +686,15 @@ fn absolutize(path: &Path) -> Result<PathBuf, ProfileError> {
}
}
/// Errors raised while evaluating and validating a profile.
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
/// Errors raised while evaluating, discovering, and validating a profile.
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("invalid profile path {}: {message}", .path.display())]
@ -271,6 +724,29 @@ pub enum ProfileError {
source: serde_json::Error,
},
#[error("failed to read profile registry config {}: {source}", .path.display())]
ConfigRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse profile registry config {}: {source}", .path.display())]
ConfigParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("no default profile is configured")]
NoDefaultProfile,
#[error("profile not found: {selector}")]
ProfileNotFound { selector: String },
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
AmbiguousProfileName { name: String, matches: Vec<String> },
#[error("failed to decode profile artifact envelope: {source}")]
ArtifactShape {
#[source]
@ -300,6 +776,7 @@ pub enum ProfileError {
mod tests {
use super::*;
use crate::{AuthRef, Permission, SchemeKind};
use tempfile::TempDir;
fn artifact() -> serde_json::Value {
serde_json::json!({
@ -425,4 +902,121 @@ mod tests {
assert!(err.to_string().contains("requires the `nix` command"));
assert!(err.to_string().contains("--manifest"));
}
#[test]
fn parse_cli_preserves_paths_and_source_qualified_names() {
assert!(matches!(
ProfileSelector::parse_cli("./coder.nix"),
ProfileSelector::Path { .. }
));
assert_eq!(
ProfileSelector::parse_cli("project:coder"),
ProfileSelector::source_named(ProfileRegistrySource::Project, "coder")
);
assert_eq!(
ProfileSelector::parse_cli("coder"),
ProfileSelector::named("coder")
);
assert_eq!(
ProfileSelector::parse_cli("default"),
ProfileSelector::Default
);
}
#[test]
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
let tmp = TempDir::new().unwrap();
let user_manifest = tmp.path().join("user.toml");
let project_dir = tmp.path().join("project/.insomnia");
std::fs::create_dir_all(&project_dir).unwrap();
let project_manifest = project_dir.join("manifest.toml");
std::fs::write(
&user_manifest,
r#"
[profiles]
default = "coder"
[profiles.profile]
coder = "profiles/user-coder.nix"
"#,
)
.unwrap();
std::fs::write(
&project_manifest,
r#"
[profiles]
default = "project:coder"
[profiles.profile.coder]
path = "profiles/project-coder.nix"
description = "Project coder"
"#,
)
.unwrap();
let registry =
ProfileDiscovery::with_sources(None, Some(user_manifest), Some(project_manifest))
.discover()
.unwrap();
let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Project);
assert_eq!(default.name, "coder");
assert!(default.path.ends_with("profiles/project-coder.nix"));
}
#[test]
fn unqualified_ambiguous_names_fail_closed() {
let mut registry = ProfileRegistry::default();
registry.push_entry(ProfileRegistryEntry {
source: ProfileRegistrySource::User,
name: "coder".to_string(),
path: PathBuf::from("/user/coder.nix"),
description: None,
is_default: false,
});
registry.push_entry(ProfileRegistryEntry {
source: ProfileRegistrySource::Project,
name: "coder".to_string(),
path: PathBuf::from("/project/coder.nix"),
description: None,
is_default: false,
});
let err = registry
.select(&ProfileSelector::named("coder"))
.unwrap_err();
assert!(matches!(err, ProfileError::AmbiguousProfileName { .. }));
let selected = registry
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Project,
"coder",
))
.unwrap();
assert_eq!(selected.path, PathBuf::from("/project/coder.nix"));
}
#[test]
fn aliases_resolve_within_their_source() {
let mut registry = ProfileRegistry::default();
registry.push_entry(ProfileRegistryEntry {
source: ProfileRegistrySource::Project,
name: "coder".to_string(),
path: PathBuf::from("/project/coder.nix"),
description: None,
is_default: false,
});
registry.push_alias(ProfileAlias {
source: ProfileRegistrySource::Project,
name: "default-coder".to_string(),
target_source: Some(ProfileRegistrySource::Project),
target_name: "coder".to_string(),
});
let selected = registry
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Project,
"default-coder",
))
.unwrap();
assert_eq!(selected.name, "coder");
}
}

View File

@ -13,14 +13,15 @@ use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
about = "Spawn a Pod process from manifest layers or a single manifest file"
)]
struct Cli {
/// Nix profile to evaluate with `nix eval --json --file <PATH>`.
/// Profiles are resolved artifacts, not manifest-cascade layers.
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// discovered profile name, `default`, or a source-qualified name such as
/// `project:coder`.
#[arg(
long,
value_name = "PATH",
value_name = "PROFILE",
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"]
)]
profile: Option<PathBuf>,
profile: Option<String>,
/// Pod name override for a freshly-created profile Pod. This does not use
/// `--pod` restore semantics, so it must not attach/restore existing Pod
@ -97,10 +98,11 @@ fn resolve_manifest_with_user_manifest_env_and_profile_loader<F>(
load_profile_fn: F,
) -> Result<(PodManifest, PromptLoader), String>
where
F: FnOnce(&Path, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
{
if let Some(path) = &cli.profile {
return load_profile_fn(path, cli.profile_pod_name.as_deref());
if let Some(profile) = &cli.profile {
let selector = ProfileSelector::parse_cli(profile);
return load_profile_fn(&selector, cli.profile_pod_name.as_deref());
}
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
@ -122,13 +124,16 @@ where
}
fn load_profile(
path: &Path,
selector: &ProfileSelector,
pod_name_override: Option<&str>,
) -> Result<(PodManifest, PromptLoader), String> {
let resolver = NixProfileResolver::new();
let mut resolved = resolver
.resolve(&ProfileSelector::path(path.to_path_buf()))
.map_err(|e| format!("failed to resolve profile {}: {e}", path.display()))?;
let mut resolved = resolver.resolve(selector).map_err(|e| {
format!(
"failed to resolve profile {}: {e}",
selector.display_label()
)
})?;
if let Some(pod_name) = pod_name_override {
resolved.manifest.pod.name = pod_name.to_string();
}
@ -468,9 +473,9 @@ permission = "write"
let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli,
Some(OsString::from("non-existent-user-manifest.toml")),
|path, pod_name| {
|selector, pod_name| {
called = true;
assert_eq!(path, profile.as_path());
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(pod_name, Some("from-profile-name"));
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
@ -488,6 +493,45 @@ permission = "write"
assert!(loader.workspace_dir().is_none());
}
#[test]
fn profile_accepts_source_qualified_discovered_name() {
let tmp = TempDir::new().unwrap();
let cli = Cli::try_parse_from([
"insomnia-pod",
"--profile",
"project:coder",
"--profile-pod-name",
"from-profile-name",
])
.unwrap();
let mut called = false;
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli,
None,
|selector, pod_name| {
called = true;
assert_eq!(
selector,
&ProfileSelector::source_named(
manifest::ProfileRegistrySource::Project,
"coder"
)
);
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
if let Some(pod_name) = pod_name {
manifest.pod.name = pod_name.to_string();
}
Ok((manifest, PromptLoader::builtins_only()))
},
)
.unwrap();
assert!(called);
assert_eq!(manifest.pod.name, "from-profile-name");
}
#[test]
fn manifest_allows_empty_user_manifest_env() {
let tmp = TempDir::new().unwrap();

View File

@ -61,7 +61,7 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
#[derive(Debug)]
enum Mode {
Spawn {
profile_path: Option<PathBuf>,
profile: Option<String>,
},
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
@ -113,7 +113,7 @@ where
let mut multi = false;
let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = None;
let mut profile_path: Option<PathBuf> = 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;
@ -148,7 +148,7 @@ where
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--profile"))?;
profile_path = Some(PathBuf::from(raw));
profile = Some(raw.clone());
i += 2;
}
"--socket" => {
@ -197,7 +197,7 @@ where
"--multi and --socket are mutually exclusive",
));
}
if profile_path.is_some() {
if profile.is_some() {
return Err(ParseError::Conflict(
"--multi and --profile are mutually exclusive",
));
@ -220,7 +220,7 @@ where
"--pod and --resume are mutually exclusive",
));
}
if profile_path.is_some()
if profile.is_some()
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
{
return Err(ParseError::Conflict(
@ -246,7 +246,7 @@ where
socket_override,
});
}
Ok(Mode::Spawn { profile_path })
Ok(Mode::Spawn { profile })
}
#[tokio::main]
@ -270,7 +270,7 @@ async fn main() -> ExitCode {
}
let result = match mode {
Mode::Spawn { profile_path } => run_spawn(None, profile_path).await,
Mode::Spawn { profile } => run_spawn(None, profile).await,
Mode::PodName {
pod_name,
socket_override,
@ -473,9 +473,9 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
async fn run_spawn(
resume_from: Option<SegmentId>,
profile_path: Option<PathBuf>,
profile: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from, profile_path).await? {
let ready = match spawn::run(resume_from, profile).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
@ -1182,8 +1182,8 @@ mod tests {
#[test]
fn parse_profile_spawn_mode() {
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
Mode::Spawn { profile_path } => {
assert_eq!(profile_path, Some(PathBuf::from("/profiles/coder.nix")));
Mode::Spawn { profile } => {
assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
}
_ => panic!("expected Spawn mode"),
}

View File

@ -20,8 +20,8 @@ use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{
PodManifestConfig, ScopeConfig, find_project_manifest_from, load_layer, user_manifest_path,
user_manifest_path_from_env,
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
user_manifest_path, user_manifest_path_from_env,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
@ -93,11 +93,18 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// passes `--session <id>` to the spawned `insomnia-pod` child.
pub async fn run(
resume_from: Option<SegmentId>,
profile_path: Option<PathBuf>,
profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let scope_origin = match profile_path.as_ref() {
Some(path) => ScopeOrigin::FromProfile(path.clone()),
let selected_profile = profile
.map(|selector| ProfileSelection {
label: selector.clone(),
selector,
is_default: false,
})
.or(defaults.default_profile);
let scope_origin = match selected_profile.as_ref() {
Some(profile) => ScopeOrigin::FromProfile(profile.label.clone()),
None => defaults.scope_origin,
};
@ -112,7 +119,7 @@ pub async fn run(
resume_from,
resume_by_pod_name: false,
resume_scope: None,
profile_path,
profile: selected_profile,
};
let mut terminal = make_inline_terminal()?;
@ -212,6 +219,14 @@ struct SpawnDefaults {
cascade_has_scope: bool,
scope_origin: ScopeOrigin,
default_name: String,
default_profile: Option<ProfileSelection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileSelection {
selector: String,
label: String,
is_default: bool,
}
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
@ -260,11 +275,24 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string());
let default_profile = default_profile_selection(&cwd);
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
default_name,
default_profile,
})
}
fn default_profile_selection(cwd: &std::path::Path) -> Option<ProfileSelection> {
let registry = ProfileDiscovery::for_cwd(cwd).discover().ok()?;
let entry = registry.default_entry().ok()?;
Some(ProfileSelection {
selector: entry.qualified_name(),
label: format!("{} (default)", entry.name),
is_default: true,
})
}
@ -287,7 +315,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
profile_path: None,
profile: None,
}
}
@ -361,7 +389,10 @@ async fn wait_for_ready(
let config = SpawnConfig {
pod_name: form.name.clone(),
profile_path: form.profile_path.clone(),
profile: form
.profile
.as_ref()
.map(|profile| profile.selector.clone()),
overlay_toml: overlay_toml.to_string(),
cwd,
resume_from: form.resume_from,
@ -438,7 +469,7 @@ enum ScopeOrigin {
FromUser,
FromProject,
CwdDefault,
FromProfile(PathBuf),
FromProfile(String),
}
struct Form {
@ -476,7 +507,7 @@ struct Form {
/// Optional Nix profile passed to `insomnia-pod --profile` for fresh spawns.
/// This is not used for resume/attach flows because those must restore Pod
/// state rather than re-evaluate a profile source.
profile_path: Option<PathBuf>,
profile: Option<ProfileSelection>,
}
impl Form {
@ -608,13 +639,10 @@ fn context_line(form: &Form) -> Line<'_> {
),
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
]),
ScopeOrigin::FromProfile(ref path) => Line::from(vec![
ScopeOrigin::FromProfile(ref label) => Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(
path.display().to_string(),
Style::default().fg(Color::Green),
),
Span::styled(label.as_str(), Style::default().fg(Color::Green)),
Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)),
]),
}
@ -663,7 +691,7 @@ mod tests {
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile_path: None,
profile: None,
}
}
@ -674,6 +702,7 @@ mod tests {
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProject,
default_name: "ignored".to_string(),
default_profile: None,
};
let f = form_for_pod_name("agent".to_string(), defaults);
@ -774,6 +803,29 @@ permission = "write"
);
}
#[test]
fn default_profile_selection_uses_project_registry_default() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let insomnia = project.join(".insomnia");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("manifest.toml"),
r#"
[profiles]
default = "coder"
[profiles.profile]
coder = "profiles/coder.nix"
"#,
)
.unwrap();
let selected = default_profile_selection(&project).unwrap();
assert_eq!(selected.selector, "project:coder");
assert_eq!(selected.label, "coder (default)");
assert!(selected.is_default);
}
#[test]
fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("", false);

View File

@ -2,7 +2,7 @@
Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline.
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers at runtime.
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers into the selected runtime manifest.
## Minimal profile
@ -27,7 +27,7 @@ insomnia.mkProfile {
}
```
Run it with:
Run an explicit path with:
```sh
insomnia-pod --profile ./coder.nix
@ -35,7 +35,45 @@ insomnia-pod --profile ./coder.nix
insomnia --profile ./coder.nix
```
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume should restore saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
`--profile` accepts an explicit path, `path:<path>`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.nix` preserve the original explicit-path behavior.
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
## Profile discovery
Profile discovery is separate from runtime manifest merging. User/project TOML files may declare profile registry metadata under `[profiles]`, but those files are not merged into the Nix profile artifact.
Example project config at `.insomnia/manifest.toml`:
```toml
[profiles]
default = "coder"
[profiles.profile]
coder = "profiles/coder.nix"
reviewer = "profiles/reviewer.nix"
[profiles.alias]
work = "project:coder"
```
Table entries can carry descriptions:
```toml
[profiles.profile.coder]
path = "profiles/coder.nix"
description = "Project coding assistant"
```
Relative registry paths are resolved against the TOML file that declares them. Discovery checks builtin profiles, then the user manifest, then the nearest project manifest. Later defaults override earlier defaults, so a project default wins over a user default. Unqualified ambiguous names fail closed:
```sh
insomnia --profile coder # fails if both user:coder and project:coder exist
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 `profile: coder (default)` and spawns with the source-qualified selector. Passing `insomnia --profile <selector>` opens the same new Pod dialog with that selector shown and leaves Pod-name editing unchanged.
## Artifact contract