feat: Created basic install_remote functions
This commit is contained in:
+10
-1
@@ -4,7 +4,7 @@ use crate::cli::completer::{
|
|||||||
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
|
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
|
||||||
role_completer, secrets_completer, session_completer,
|
role_completer, secrets_completer, session_completer,
|
||||||
};
|
};
|
||||||
use crate::config::AssetCategory;
|
use crate::config::{AssetCategory, InstallFilter};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::ValueHint;
|
use clap::ValueHint;
|
||||||
use clap::{Parser, crate_authors, crate_description, crate_version};
|
use clap::{Parser, crate_authors, crate_description, crate_version};
|
||||||
@@ -86,6 +86,15 @@ pub struct Cli {
|
|||||||
/// Reinstall bundled assets, overwriting any local changes
|
/// Reinstall bundled assets, overwriting any local changes
|
||||||
#[arg(long, value_name = "CATEGORY", value_enum)]
|
#[arg(long, value_name = "CATEGORY", value_enum)]
|
||||||
pub install: Option<AssetCategory>,
|
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
|
/// Sync models updates
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub sync_models: bool,
|
pub sync_models: bool,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ mod agent;
|
|||||||
mod app_config;
|
mod app_config;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod install_remote;
|
||||||
mod macros;
|
mod macros;
|
||||||
mod mcp_factory;
|
mod mcp_factory;
|
||||||
pub(crate) mod paths;
|
pub(crate) mod paths;
|
||||||
@@ -22,6 +23,7 @@ pub use self::app_config::AppConfig;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
pub use self::input::Input;
|
pub use self::input::Input;
|
||||||
|
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::request_context::{RenderMode, RequestContext};
|
pub use self::request_context::{RenderMode, RequestContext};
|
||||||
pub use self::role::{
|
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<()> {
|
pub fn install_assets(category: AssetCategory) -> Result<()> {
|
||||||
let (label, target) = match category {
|
let (label, target) = match category {
|
||||||
AssetCategory::Agents => ("agents", paths::agents_data_dir()),
|
AssetCategory::Agents => ("agents", paths::agents_data_dir()),
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ async fn run(
|
|||||||
return config::install_assets(category);
|
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 {
|
if cli.sync_models {
|
||||||
let url = ctx.app.config.sync_models_url();
|
let url = ctx.app.config.sync_models_url();
|
||||||
return sync_models(&url, abort_signal.clone()).await;
|
return sync_models(&url, abort_signal.clone()).await;
|
||||||
|
|||||||
+20
-9
@@ -538,16 +538,27 @@ pub async fn run_repl_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
".install" => match args.map(str::trim) {
|
".install" => {
|
||||||
Some(name) if !name.is_empty() => match AssetCategory::parse(name) {
|
let trimmed = args.map(str::trim).unwrap_or("");
|
||||||
Some(category) => config::install_assets(category)?,
|
let mut parts = trimmed.splitn(2, char::is_whitespace);
|
||||||
None => println!(
|
match parts.next() {
|
||||||
"Unknown asset category '{name}'. Valid categories: {}",
|
Some("remote") => {
|
||||||
AssetCategory::NAMES.join(", ")
|
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" => {
|
".update" => {
|
||||||
if ctx.macro_flag {
|
if ctx.macro_flag {
|
||||||
bail!("Cannot perform this operation because you are in a macro")
|
bail!("Cannot perform this operation because you are in a macro")
|
||||||
|
|||||||
Reference in New Issue
Block a user