From 531c633b9319847ab4f7e6d526c51c075b0cd1c3 Mon Sep 17 00:00:00 2001 From: pasteley Date: Wed, 25 Mar 2026 01:14:58 +0100 Subject: [PATCH] feat: eval option Signed-off-by: pasteley --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 24 +++++++ src/cmd/activation.rs | 43 ++++++++++++ src/cmd/context.rs | 22 ++---- src/cmd/eval.rs | 154 ++++++++++++++++++++++++++++++++++++++++++ src/cmd/meta.rs | 8 +++ src/cmd/mod.rs | 8 ++- src/cmd/namespace.rs | 26 ++----- src/main.rs | 10 ++- src/shell/mod.rs | 3 +- 11 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 src/cmd/activation.rs create mode 100644 src/cmd/eval.rs diff --git a/Cargo.lock b/Cargo.lock index 3e3b437c..be87b044 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1268,6 +1268,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shlex", "signal-hook 0.4.4", "skim", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 41842715..f3b0f047 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ libc = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +shlex = "1" signal-hook = "0.4" tempfile = "3" which = "8" diff --git a/README.md b/README.md index 8e66cd89..482ae065 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,30 @@ Selectable menus will be available when using `kubie ctx` and `kubie ns`. * `kubie info depth` print depth of recursive contexts * `kubie update` will check the latest kubie version and update your local installation if needed +### Eval mode (experimental) + +`kubie ctx --eval` outputs `export` statements instead of spawning a new shell. Evaluating that output +switches your current shell into the selected kubie context with less startup overhead, which is +useful for shell key bindings, wrapper functions or non-interactive scripts. + +```bash +eval "$(kubie ctx --eval my-context)" +``` + +Command wrapper: + +```bash +kctx() { + eval "$(kubie ctx --eval "$@")" +} +``` + +```fish +function kctx + eval (kubie ctx --eval $argv) +end +``` + ## Settings You can customize kubie's behavior with the `~/.kube/kubie.yaml` file. The settings available and their defaults are available below. diff --git a/src/cmd/activation.rs b/src/cmd/activation.rs new file mode 100644 index 00000000..c6d4ba73 --- /dev/null +++ b/src/cmd/activation.rs @@ -0,0 +1,43 @@ +use anyhow::Result; + +use crate::kubeconfig::{self, KubeConfig}; +use crate::session::Session; +use crate::settings::Settings; +use crate::shell::spawn_shell; + +use super::eval; + +#[derive(Debug)] +pub enum ActivationMode { + Eval, + Spawn, + Switch, +} + +impl ActivationMode { + pub fn resolve(eval: bool, recursive: bool, is_active: bool) -> Self { + if eval { + ActivationMode::Eval + } else if is_active && !recursive { + ActivationMode::Switch + } else { + ActivationMode::Spawn + } + } + + pub fn activate(self, settings: &Settings, config: KubeConfig, session: &Session) -> Result<()> { + match self { + ActivationMode::Eval => eval::emit_session(settings, &config, session), + ActivationMode::Spawn => { + spawn_shell(settings, config, session)?; + Ok(()) + } + ActivationMode::Switch => { + let path = kubeconfig::get_kubeconfig_path()?; + config.write_to_file(path.as_path())?; + session.save(None)?; + Ok(()) + } + } + } +} diff --git a/src/cmd/context.rs b/src/cmd/context.rs index c140d5c3..52b61124 100644 --- a/src/cmd/context.rs +++ b/src/cmd/context.rs @@ -1,20 +1,18 @@ use anyhow::Result; -use crate::cmd::{select_or_list_context, SelectResult}; +use crate::cmd::{select_or_list_context, ActivationMode, SelectResult}; use crate::kubeconfig::{self, Installed}; use crate::kubectl; use crate::session::Session; use crate::settings::Settings; -use crate::shell::spawn_shell; use crate::state::State; -use crate::vars; fn enter_context( settings: &Settings, - installed: Installed, + installed: &Installed, context_name: &str, namespace_name: Option<&str>, - recursive: bool, + mode: ActivationMode, ) -> Result<()> { let state = State::load()?; let mut session = Session::load()?; @@ -50,15 +48,7 @@ fn enter_context( } } - if vars::is_kubie_active() && !recursive { - let path = kubeconfig::get_kubeconfig_path()?; - kubeconfig.write_to_file(path.as_path())?; - session.save(None)?; - } else { - spawn_shell(settings, kubeconfig, &session)?; - } - - Ok(()) + mode.activate(settings, kubeconfig, &session) } pub fn context( @@ -66,7 +56,7 @@ pub fn context( context_name: Option, namespace_name: Option, kubeconfigs: Vec, - recursive: bool, + mode: ActivationMode, ) -> Result<()> { let mut installed = if kubeconfigs.is_empty() { kubeconfig::get_installed_contexts(settings)? @@ -82,5 +72,5 @@ pub fn context( }, }; - enter_context(settings, installed, &context_name, namespace_name.as_deref(), recursive) + enter_context(settings, &installed, &context_name, namespace_name.as_deref(), mode) } diff --git a/src/cmd/eval.rs b/src/cmd/eval.rs new file mode 100644 index 00000000..e9a77693 --- /dev/null +++ b/src/cmd/eval.rs @@ -0,0 +1,154 @@ +use std::env; +use std::fmt::Display; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +use crate::kubeconfig::KubeConfig; +use crate::session::Session; +use crate::settings::Settings; +use crate::shell::{detect_shell, ShellKind}; +use crate::vars; + +// Distinct from spawn_shell's own prefix so is_managed_file never confuses the two. +const CONFIG_PREFIX: &str = "kubie-eval-config-"; +const SESSION_PREFIX: &str = "kubie-eval-session-"; + +pub(super) fn emit_session(settings: &Settings, config: &KubeConfig, session: &Session) -> Result<()> { + let shell = detect_eval_shell(settings)?; + + let (config_path, session_path) = match existing_eval_paths() { + // Reuse an existing --eval pair in place rather than minting a new one. + Some((config_path, session_path)) => { + config.write_to_file(&config_path)?; + session.save(Some(&session_path))?; + (config_path, session_path) + } + None => create_eval_files(config, session)?, + }; + + let depth = if vars::is_kubie_active() { vars::get_depth() } else { 1 }; + let config_path = config_path.display().to_string(); + let session_path = session_path.display().to_string(); + + emit_vars(shell, &config_path, &session_path, depth); + + Ok(()) +} + +fn create_eval_files(config: &KubeConfig, session: &Session) -> Result<(PathBuf, PathBuf)> { + let temp_config_file = tempfile::Builder::new() + .prefix(CONFIG_PREFIX) + .suffix(".yaml") + .tempfile()?; + config.write_to_file(temp_config_file.path())?; + + let temp_session_file = tempfile::Builder::new() + .prefix(SESSION_PREFIX) + .suffix(".json") + .tempfile()?; + session.save(Some(temp_session_file.path()))?; + + let config_path = temp_config_file.into_temp_path().keep()?; + let session_path = temp_session_file.into_temp_path().keep()?; + + Ok((config_path, session_path)) +} + +/// Paths of the kubeconfig/session pair from a previous `--eval` call, if still active. +fn existing_eval_paths() -> Option<(PathBuf, PathBuf)> { + if !vars::is_kubie_active() { + return None; + } + + let config_path = PathBuf::from(env::var_os("KUBIE_KUBECONFIG")?); + let session_path = PathBuf::from(env::var_os("KUBIE_SESSION")?); + + if is_managed_file(&config_path, CONFIG_PREFIX) && is_managed_file(&session_path, SESSION_PREFIX) { + Some((config_path, session_path)) + } else { + None + } +} + +fn is_managed_file(path: &Path, prefix: &str) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(prefix)) +} + +fn detect_eval_shell(settings: &Settings) -> Result { + let shell = match &settings.shell { + Some(s) => ShellKind::from_str(s).ok_or_else(|| anyhow!("Invalid shell setting: {}", s))?, + None => detect_shell()?, + }; + + match shell { + ShellKind::Bash | ShellKind::Zsh | ShellKind::Fish => Ok(shell), + _ => Err(anyhow!( + "--eval is not supported for this shell. Supported: bash, zsh, fish." + )), + } +} + +fn emit_vars(shell: ShellKind, config_path: &str, session_path: &str, depth: impl Display) { + println!("{}", render_vars(shell, config_path, session_path, depth).join("\n")); +} + +fn render_vars(shell: ShellKind, config_path: &str, session_path: &str, depth: impl Display) -> Vec { + vec![ + format_var(shell, "KUBECONFIG", config_path), + format_var(shell, "KUBIE_ACTIVE", "1"), + format_var(shell, "KUBIE_DEPTH", depth), + format_var(shell, "KUBIE_KUBECONFIG", config_path), + format_var(shell, "KUBIE_SESSION", session_path), + ] +} + +fn format_var(shell: ShellKind, key: &str, value: impl Display) -> String { + let value_str = value.to_string(); + let quoted = shlex::try_quote(&value_str).unwrap_or(std::borrow::Cow::Borrowed(&value_str)); + match shell { + ShellKind::Bash | ShellKind::Zsh => format!("export {}={};", key, quoted), + ShellKind::Fish => format!("set -gx {} {};", key, quoted), + _ => unreachable!(), + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{is_managed_file, render_vars, ShellKind, CONFIG_PREFIX}; + + #[test] + fn test_is_managed_file_accepts_matching_prefix() { + let path = Path::new("/tmp").join(format!("{CONFIG_PREFIX}abc123.yaml")); + assert!(is_managed_file(&path, CONFIG_PREFIX)); + } + + #[test] + fn test_is_managed_file_rejects_other_prefix() { + let path = Path::new("/tmp").join("kubie-config123.yaml"); + assert!(!is_managed_file(&path, CONFIG_PREFIX)); + } + + #[test] + fn test_bash_output_uses_export_syntax() { + let script = render_vars(ShellKind::Bash, "/tmp/config.yaml", "/tmp/session.json", 1).join("\n"); + + assert!(script.contains("export KUBECONFIG=/tmp/config.yaml;")); + assert!(script.contains("export KUBIE_ACTIVE=1;")); + assert!(script.contains("export KUBIE_DEPTH=1;")); + assert!(script.contains("export KUBIE_SESSION=/tmp/session.json;")); + } + + #[test] + fn test_fish_output_uses_set_gx_syntax() { + let script = render_vars(ShellKind::Fish, "/tmp/config.yaml", "/tmp/session.json", 2).join("\n"); + + assert!(script.contains("set -gx KUBECONFIG /tmp/config.yaml;")); + assert!(script.contains("set -gx KUBIE_DEPTH 2;")); + assert!(script.contains("set -gx KUBIE_SESSION /tmp/session.json;")); + } +} diff --git a/src/cmd/meta.rs b/src/cmd/meta.rs index 5f39abe0..23877b75 100644 --- a/src/cmd/meta.rs +++ b/src/cmd/meta.rs @@ -22,6 +22,10 @@ pub enum Kubie { #[clap(short = 'r', long = "recursive")] recursive: bool, + /// Outputs shell statements to be eval'd in the current shell. + #[clap(long = "eval", conflicts_with = "recursive")] + eval: bool, + /// Name of the context to enter. Use '-' to switch back to the previous context. context_name: Option, }, @@ -34,6 +38,10 @@ pub enum Kubie { #[clap(short = 'r', long = "recursive")] recursive: bool, + /// Outputs shell statements to be eval'd in the current shell. + #[clap(long = "eval", conflicts_with = "recursive")] + eval: bool, + /// Unsets the namespace in the currently active context. #[clap(short = 'u', long = "unset")] unset: bool, diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 006da4b9..2824c8a7 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,8 +6,10 @@ use crate::kubeconfig::Installed; use crate::kubectl; use crate::settings::Fzf; +mod activation; pub mod context; pub mod delete; +pub(crate) mod eval; pub mod edit; pub mod exec; pub mod export; @@ -18,6 +20,8 @@ pub mod namespace; #[cfg(feature = "update")] pub mod update; +pub use activation::ActivationMode; + pub enum SelectResult { Cancelled, Listed, @@ -35,7 +39,7 @@ pub fn select_or_list_context(fzf: &Fzf, installed: &mut Installed) -> Result>) -> R bail!("No namespaces found"); } - if io::stdout().is_terminal() { + if io::stdin().is_terminal() { // NOTE: skim shows the list of namespaces in reverse order namespaces.reverse(); match crate::skim::select(fzf, namespaces)? { diff --git a/src/cmd/namespace.rs b/src/cmd/namespace.rs index 1d0f62c8..d3079440 100644 --- a/src/cmd/namespace.rs +++ b/src/cmd/namespace.rs @@ -1,26 +1,20 @@ use anyhow::{anyhow, Context, Result}; -use crate::cmd::{select_or_list_namespace, SelectResult}; +use crate::cmd::{select_or_list_namespace, ActivationMode, SelectResult}; use crate::kubeconfig; use crate::kubectl; use crate::session::Session; use crate::settings::{Settings, ValidateNamespacesBehavior}; -use crate::shell::spawn_shell; use crate::state::State; use crate::vars; -pub fn namespace( - settings: &Settings, - namespace_name: Option, - recursive: bool, - unset: bool, -) -> Result<()> { +pub fn namespace(settings: &Settings, namespace_name: Option, mode: ActivationMode, unset: bool) -> Result<()> { vars::ensure_kubie_active()?; let mut session = Session::load().context("Could not load session file")?; if namespace_name.is_none() && unset { - return enter_namespace(settings, &mut session, recursive, None); + return enter_namespace(settings, &mut session, mode, None); } let namespace_name = match namespace_name { @@ -63,13 +57,13 @@ pub fn namespace( }, }; - enter_namespace(settings, &mut session, recursive, namespace_name) + enter_namespace(settings, &mut session, mode, namespace_name) } fn enter_namespace( settings: &Settings, session: &mut Session, - recursive: bool, + mode: ActivationMode, namespace_name: Option, ) -> Result<()> { let mut config = kubeconfig::get_current_config()?; @@ -90,13 +84,5 @@ fn enter_namespace( // Update the history, add the context and namespace to it. session.add_history_entry(context_name, namespace_name); - if recursive { - spawn_shell(settings, config, session)?; - } else { - let config_file = kubeconfig::get_kubeconfig_path()?; - config.write_to_file(config_file.as_path())?; - session.save(None)?; - } - - Ok(()) + mode.activate(settings, config, session) } diff --git a/src/main.rs b/src/main.rs index 7253a092..8e41d6ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ use anyhow::Result; use clap::Parser; +use cmd::ActivationMode; use cmd::meta::Kubie; use settings::Settings; +use vars::is_kubie_active; mod cmd; mod ioutil; @@ -26,21 +28,25 @@ fn main() -> Result<()> { context_name, kubeconfigs, recursive, + eval, } => { + let mode = ActivationMode::resolve(eval, recursive, is_kubie_active()); cmd::context::context( &settings, context_name, namespace_name, kubeconfigs, - recursive, + mode, )?; } Kubie::Namespace { namespace_name, recursive, + eval, unset, } => { - cmd::namespace::namespace(&settings, namespace_name, recursive, unset)?; + let mode = ActivationMode::resolve(eval, recursive, is_kubie_active()); + cmd::namespace::namespace(&settings, namespace_name, mode, unset)?; } Kubie::Info(info) => { cmd::info::info(info)?; diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 1cbe66d6..566e7672 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -4,7 +4,8 @@ use std::process::Command; use anyhow::{anyhow, Result}; -use self::detect::{detect, ShellKind}; +pub use self::detect::{detect as detect_shell, ShellKind}; +use self::detect::detect; use crate::kubeconfig::KubeConfig; use crate::session::Session; use crate::settings::Settings;