feat: Created basic install_remote functions

This commit is contained in:
2026-05-22 15:33:37 -06:00
parent 484b18ef16
commit b5fc633454
5 changed files with 316 additions and 10 deletions
+10 -1
View File
@@ -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<AssetCategory>,
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
#[arg(long, value_name = "GIT_URL")]
pub install_from: Option<String>,
/// Restrict --install-from to a single asset category
#[arg(long, value_name = "CATEGORY", value_enum, requires = "install_from")]
pub filter: Option<InstallFilter>,
/// 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,
+255
View File
@@ -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<InstallFilter>, 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 <git-url> [--filter <{}>] [--force]",
InstallFilter::NAMES.join("|")
)
})?;
let mut filter: Option<InstallFilter> = 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> {
InstallFilter::parse(name).with_context(|| {
format!(
"Unknown filter '{name}'. Valid values: {}",
InstallFilter::NAMES.join(", ")
)
})
}
fn parse_url_with_ref(input: &str) -> Result<(String, Option<String>)> {
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<TempRepoDir> {
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<OsString>) -> 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());
}
}
+27
View File
@@ -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<Self> {
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()),
+4
View File
@@ -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;
+20 -9
View File
@@ -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 <git-url>",
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")