From 0311d5e07d3ba970466259c67188b9c9fb80cea8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 May 2026 15:33:37 -0600 Subject: [PATCH] feat: Created basic install_remote functions --- src/cli/mod.rs | 11 +- src/config/install_remote.rs | 255 +++++++++++++++++++++++++++++++++++ src/config/mod.rs | 27 ++++ src/main.rs | 4 + src/repl/mod.rs | 29 ++-- 5 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 src/config/install_remote.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0557269..0fb8e24 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,7 +4,7 @@ use crate::cli::completer::{ ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer, role_completer, secrets_completer, session_completer, }; -use crate::config::AssetCategory; +use crate::config::{AssetCategory, InstallFilter}; use anyhow::{Context, Result}; use clap::ValueHint; use clap::{Parser, crate_authors, crate_description, crate_version}; @@ -86,6 +86,15 @@ pub struct Cli { /// Reinstall bundled assets, overwriting any local changes #[arg(long, value_name = "CATEGORY", value_enum)] pub install: Option, + /// Install assets from a remote git repository (URL may be suffixed with #) + #[arg(long, value_name = "GIT_URL")] + pub install_from: Option, + /// Restrict --install-from to a single asset category + #[arg(long, value_name = "CATEGORY", value_enum, requires = "install_from")] + pub filter: Option, + /// Overwrite all conflicts without prompting (used with --install-from) + #[arg(long, requires = "install_from")] + pub install_force: bool, /// Sync models updates #[arg(long)] pub sync_models: bool, diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs new file mode 100644 index 0000000..a3e2f13 --- /dev/null +++ b/src/config/install_remote.rs @@ -0,0 +1,255 @@ +use anyhow::{Context, Result, bail}; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::InstallFilter; +use crate::utils; + +pub fn install_remote(git_url: &str, filter: Option, force: bool) -> Result<()> { + let (url, reference) = parse_url_with_ref(git_url)?; + let temp = clone_to_temp(&url, reference.as_deref())?; + println!("Cloned {git_url} to {}", temp.path().display()); + print_repo_tree(temp.path())?; + let _ = (force, filter); + Ok(()) +} + +pub fn install_remote_from_repl_args(args: &str) -> Result<()> { + let tokens = shell_words::split(args) + .with_context(|| format!("failed to parse '.install remote' args: {args}"))?; + + let mut iter = tokens.into_iter(); + let url = iter.next().with_context(|| { + format!( + "Usage: .install remote [--filter <{}>] [--force]", + InstallFilter::NAMES.join("|") + ) + })?; + + let mut filter: Option = None; + let mut force = false; + + while let Some(tok) = iter.next() { + match tok.as_str() { + "--force" => force = true, + "--filter" => { + let val = iter.next().with_context(|| { + format!( + "--filter requires a value (one of: {})", + InstallFilter::NAMES.join(", ") + ) + })?; + filter = Some(parse_filter(&val)?); + } + s if s.starts_with("--filter=") => { + filter = Some(parse_filter(&s["--filter=".len()..])?); + } + other => bail!("Unexpected argument to '.install remote': {other}"), + } + } + + install_remote(&url, filter, force) +} + +fn parse_filter(name: &str) -> Result { + InstallFilter::parse(name).with_context(|| { + format!( + "Unknown filter '{name}'. Valid values: {}", + InstallFilter::NAMES.join(", ") + ) + }) +} + +fn parse_url_with_ref(input: &str) -> Result<(String, Option)> { + match input.rsplit_once('#') { + Some((url, refspec)) if !url.is_empty() => { + if refspec.is_empty() { + bail!("Empty ref after '#' in URL: {input}"); + } + if refspec.contains("..") { + bail!("Invalid ref '{refspec}': cannot contain '..'"); + } + if refspec.starts_with('-') { + bail!( + "Invalid ref '{refspec}': cannot start with '-' \ + (would be parsed by git as a CLI flag)" + ); + } + if !refspec + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+')) + { + bail!("Invalid ref '{refspec}': only [A-Za-z0-9._/+-] characters allowed"); + } + Ok((url.to_string(), Some(refspec.to_string()))) + } + _ => Ok((input.to_string(), None)), + } +} + +struct TempRepoDir { + path: PathBuf, +} + +impl TempRepoDir { + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempRepoDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { + let dest = utils::temp_file("loki-remote-install-", ""); + let dest_arg: OsString = dest.as_os_str().into(); + + let is_sha = reference + .map(|r| r.len() >= 4 && r.len() <= 40 && r.chars().all(|c| c.is_ascii_hexdigit())) + .unwrap_or(false); + + match reference { + Some(r) if !is_sha => { + run_git(vec![ + "clone".into(), + "--depth".into(), + "1".into(), + "--branch".into(), + r.into(), + url.into(), + dest_arg, + ])?; + } + Some(r) => { + run_git(vec!["clone".into(), url.into(), dest_arg.clone()])?; + run_git(vec!["-C".into(), dest_arg, "checkout".into(), r.into()])?; + } + None => { + run_git(vec![ + "clone".into(), + "--depth".into(), + "1".into(), + url.into(), + dest_arg, + ])?; + } + } + + Ok(TempRepoDir { path: dest }) +} + +fn run_git(args: Vec) -> Result<()> { + let output = duct::cmd("git", &args) + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .context("failed to spawn git (is it installed and on PATH?)")?; + + if !output.status.success() { + let combined = String::from_utf8_lossy(&output.stdout); + bail!("git failed: {}", combined.trim()); + } + + Ok(()) +} + +fn print_repo_tree(root: &Path) -> Result<()> { + println!("Repository contents ({}):", root.display()); + print_children(root, "") +} + +fn print_children(dir: &Path, prefix: &str) -> Result<()> { + let mut entries: Vec<_> = fs::read_dir(dir) + .with_context(|| format!("failed to read {}", dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.file_name() != OsStr::new(".git")) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let n = entries.len(); + for (i, entry) in entries.iter().enumerate() { + let is_last = i == n - 1; + let connector = if is_last { "└── " } else { "├── " }; + let name = entry.file_name().to_string_lossy().to_string(); + println!("{prefix}{connector}{name}"); + + if entry.file_type()?.is_dir() { + let extension = if is_last { " " } else { "│ " }; + let new_prefix = format!("{prefix}{extension}"); + print_children(&entry.path(), &new_prefix)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_url_no_ref() { + let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git").unwrap(); + + assert_eq!(url, "https://github.com/foo/bar.git"); + assert_eq!(r, None); + } + + #[test] + fn parse_url_with_branch_ref() { + let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#main").unwrap(); + + assert_eq!(url, "https://github.com/foo/bar.git"); + assert_eq!(r.as_deref(), Some("main")); + } + + #[test] + fn parse_url_with_tag_ref() { + let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#v1.2.3").unwrap(); + + assert_eq!(url, "https://github.com/foo/bar.git"); + assert_eq!(r.as_deref(), Some("v1.2.3")); + } + + #[test] + fn parse_url_with_sha_ref() { + let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#abc1234").unwrap(); + + assert_eq!(url, "https://github.com/foo/bar.git"); + assert_eq!(r.as_deref(), Some("abc1234")); + } + + #[test] + fn parse_url_with_slash_in_ref() { + let (url, r) = parse_url_with_ref("git@github.com:foo/bar.git#release/v2").unwrap(); + + assert_eq!(url, "git@github.com:foo/bar.git"); + assert_eq!(r.as_deref(), Some("release/v2")); + } + + #[test] + fn parse_url_rejects_empty_ref() { + assert!(parse_url_with_ref("https://github.com/foo/bar.git#").is_err()); + } + + #[test] + fn parse_url_rejects_dotdot() { + assert!(parse_url_with_ref("https://github.com/foo/bar.git#foo..bar").is_err()); + } + + #[test] + fn parse_url_rejects_leading_dash_argument_injection() { + assert!(parse_url_with_ref("https://github.com/foo/bar.git#-evil").is_err()); + } + + #[test] + fn parse_url_rejects_shell_metachars() { + assert!(parse_url_with_ref("https://github.com/foo/bar.git#foo bar").is_err()); + assert!(parse_url_with_ref("https://github.com/foo/bar.git#$inject").is_err()); + assert!(parse_url_with_ref("https://github.com/foo/bar.git#;rm -rf /").is_err()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index b7ef083..683ab17 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,7 @@ mod agent; mod app_config; mod app_state; mod input; +mod install_remote; mod macros; mod mcp_factory; pub(crate) mod paths; @@ -22,6 +23,7 @@ pub use self::app_config::AppConfig; #[allow(unused_imports)] pub use self::app_state::AppState; pub use self::input::Input; +pub use self::install_remote::{install_remote, install_remote_from_repl_args}; #[allow(unused_imports)] pub use self::request_context::{RenderMode, RequestContext}; pub use self::role::{ @@ -274,6 +276,31 @@ impl AssetCategory { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum InstallFilter { + Agents, + Roles, + Macros, + Functions, + #[value(name = "mcp_config")] + McpConfig, +} + +impl InstallFilter { + pub const NAMES: [&'static str; 5] = ["agents", "roles", "macros", "functions", "mcp_config"]; + + pub fn parse(name: &str) -> Option { + match name { + "agents" => Some(Self::Agents), + "roles" => Some(Self::Roles), + "macros" => Some(Self::Macros), + "functions" => Some(Self::Functions), + "mcp_config" => Some(Self::McpConfig), + _ => None, + } + } +} + pub fn install_assets(category: AssetCategory) -> Result<()> { let (label, target) = match category { AssetCategory::Agents => ("agents", paths::agents_data_dir()), diff --git a/src/main.rs b/src/main.rs index d776e18..ed38323 100644 --- a/src/main.rs +++ b/src/main.rs @@ -156,6 +156,10 @@ async fn run( return config::install_assets(category); } + if let Some(url) = cli.install_from.as_deref() { + return config::install_remote(url, cli.filter, cli.install_force); + } + if cli.sync_models { let url = ctx.app.config.sync_models_url(); return sync_models(&url, abort_signal.clone()).await; diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 2a79500..2e586bb 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -538,16 +538,27 @@ pub async fn run_repl_command( } } } - ".install" => match args.map(str::trim) { - Some(name) if !name.is_empty() => match AssetCategory::parse(name) { - Some(category) => config::install_assets(category)?, - None => println!( - "Unknown asset category '{name}'. Valid categories: {}", - AssetCategory::NAMES.join(", ") + ".install" => { + let trimmed = args.map(str::trim).unwrap_or(""); + let mut parts = trimmed.splitn(2, char::is_whitespace); + match parts.next() { + Some("remote") => { + let rest = parts.next().unwrap_or("").trim(); + config::install_remote_from_repl_args(rest)?; + } + Some(name) if !name.is_empty() => match AssetCategory::parse(name) { + Some(category) => config::install_assets(category)?, + None => println!( + "Unknown asset category '{name}'. Valid categories: {}", + AssetCategory::NAMES.join(", ") + ), + }, + _ => println!( + "Usage: .install <{}> | .install remote ", + AssetCategory::NAMES.join("|") ), - }, - _ => println!("Usage: .install <{}>", AssetCategory::NAMES.join("|")), - }, + } + } ".update" => { if ctx.macro_flag { bail!("Cannot perform this operation because you are in a macro")