From 0be94d8b8739492e549854ed6e97fd9b410794cd Mon Sep 17 00:00:00 2001 From: Micheal Smith Date: Thu, 11 Dec 2025 05:28:14 -0600 Subject: [PATCH] Added ability to execute commands in a specified directory. --- setup.rs | 1 + src/chat.rs | 72 +++++++++++++++++++++++++++++++++++++-------- src/lib.rs | 1 + src/setup.rs | 39 ++++++++++++++++++++---- tests/setup_test.rs | 2 +- 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 setup.rs diff --git a/setup.rs b/setup.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/setup.rs @@ -0,0 +1 @@ + diff --git a/src/chat.rs b/src/chat.rs index ac23693..03fee37 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -12,13 +12,15 @@ use irc::client::prelude::{Client, Command, Config as IRCConfig, Message}; use tokio::sync::mpsc; use tracing::{Level, event, instrument}; -use crate::{Event, EventManager, LLMHandle, plugin}; +use crate::{CommandDir, Event, EventManager, LLMHandle, plugin}; /// Chat struct that is used to interact with IRC chat. #[derive(Debug)] pub struct Chat { /// The actual IRC [`irc::client`](client). client: Client, + /// Handle to the directory that *may* contain command scripts. + command_dir: Option, /// Event manager for handling plugin interaction. event_manager: Arc, /// Handle for whichever LLM is being used. @@ -52,10 +54,17 @@ impl Chat { ..IRCConfig::default() }; + let commands_dir = if let Ok(path) = settings.get_string("command-path") { + Some(CommandDir::new(path)) + } else { + None + }; + event!(Level::INFO, "IRC connection starting..."); Ok(Chat { client: Client::from_config(config).await?, + command_dir: commands_dir, llm_handle: handle.clone(), event_manager: manager, }) @@ -111,24 +120,63 @@ impl Chat { // Only handle PRIVMSG for now. if let Command::PRIVMSG(channel, msg) = &message.command { - // Just preserve the original behavior for now. - if msg.starts_with("!gem") { - let mut llm_response = self.llm_handle.send_request(msg).await?; + // Check it's a command. + if let Some((cmd, args)) = command_str(msg) { + // Command handling time. - event!(Level::INFO, "Asked: {message}"); - event!(Level::INFO, "Response: {llm_response}"); + match cmd { + // Just preserve the original behavior for now. + "!gem" => { + let mut llm_response = self.llm_handle.send_request(msg).await?; - // Keep responses to one line for now. - llm_response.retain(|c| c != '\n' && c != '\r'); + event!(Level::INFO, "Asked: {message}"); + event!(Level::INFO, "Response: {llm_response}"); - // TODO: Make this configurable. - llm_response.truncate(500); + // Keep responses to one line for now. + llm_response.retain(|c| c != '\n' && c != '\r'); - event!(Level::INFO, "Sending {llm_response} to channel {channel}"); - self.client.send_privmsg(channel, llm_response)?; + // TODO: Make this configurable. + llm_response.truncate(500); + + event!(Level::INFO, "Sending {llm_response} to channel {channel}"); + self.client.send_privmsg(channel, llm_response)?; + } + + _ => { + if let Some(cmd_dir) = &self.command_dir { + // Strip '!' + let cmd_name = &cmd[1..]; + match cmd_dir.run_command(cmd_name, args).await { + Ok(res) => { + let output = std::str::from_utf8(&res)?.to_string(); + self.client.send_privmsg(channel, output)?; + } + Err(e) => { + // Log the error but don't crash, and maybe don't even tell the + // user unless we're sure it + // was meant to be a command? + // For now, let's just log it. + event!( + Level::DEBUG, + "Command execution failed or not found: {}", + e + ); + } + } + } + } + } } } Ok(()) } } + +fn command_str(cmd: &str) -> Option<(&str, &str)> { + if !cmd.starts_with('!') { + return None; + } + + Some(cmd.split_once(' ').unwrap_or((cmd, ""))) +} diff --git a/src/lib.rs b/src/lib.rs index 1a6492a..5c77fa1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod qna; pub mod setup; pub use chat::Chat; +pub use command::CommandDir; pub use event::Event; pub use event_manager::EventManager; pub use qna::LLMHandle; diff --git a/src/setup.rs b/src/setup.rs index 5f7df13..9542db5 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -3,11 +3,14 @@ //! Both command line, and configuration file options are handled here. use clap::Parser; -use color_eyre::{Result, eyre::WrapErr}; +use color_eyre::{ + Result, + eyre::{OptionExt, WrapErr}, +}; use config::Config; -use directories::ProjectDirs; +use directories::{BaseDirs, ProjectDirs}; use std::path::PathBuf; -use tracing::{info, instrument}; +use tracing::{Level, event, info, instrument}; // TODO: use [clap(long, short, help_heading = Some(section))] /// Struct of potential arguments. @@ -28,7 +31,7 @@ pub struct Args { /// Root directory for file based command structure. #[arg(long)] - pub command_dir: Option, + pub command_path: Option, #[arg(long)] /// Instructions to the model on how to behave. @@ -92,6 +95,27 @@ pub async fn init() -> Result { Ok(Setup { config: settings }) } +/// Resolves a path, expanding `~` to the home directory. +/// +/// If the path does not start with `~`, it is returned as is. +pub fn resolve_path(path_str: &str) -> Result { + event!(Level::WARN, "resolve_path called with {path_str}"); + if let Some(stripped) = path_str.strip_prefix("~") { + let base_dirs = BaseDirs::new().ok_or_eyre("Unable to expand '~'.")?; + event!( + Level::DEBUG, + "home_dir() decided on {}", + base_dirs.home_dir().display() + ); + let relative = stripped + .strip_prefix(std::path::MAIN_SEPARATOR_STR) + .unwrap_or(stripped); + return Ok(base_dirs.home_dir().join(relative)); + } + + Ok(PathBuf::from(path_str)) +} + /// Create a configuration object from arguments. /// /// This is exposed for testing purposes. @@ -117,7 +141,12 @@ pub fn make_config(args: Args) -> Result { .set_override_option("api-key", args.api_key.clone())? .set_override_option("base-url", args.base_url.clone())? .set_override_option("chroot-dir", args.chroot_dir.clone())? - .set_override_option("command-path", args.command_dir.clone())? + .set_override_option( + "command-path", + // A path expansion is a panic situation so just unwrap() is fine. + args.command_path + .map(|p| resolve_path(&p).unwrap().to_string_lossy().to_string()), + )? .set_override_option("model", args.model.clone())? .set_override_option("nick-password", args.nick_password.clone())? .set_override_option("instruct", args.instruct.clone())? diff --git a/tests/setup_test.rs b/tests/setup_test.rs index e23bc62..a5fa522 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -35,7 +35,7 @@ port = 6667 base_url: None, /* Should fail if required and not in file/env? No, base-url is optional * in args */ chroot_dir: None, - command_dir: None, + command_path: None, instruct: None, model: None, // Should fallback to file channels: None,