Added ability to execute commands in a specified directory.

This commit is contained in:
Micheal Smith
2025-12-11 05:28:14 -06:00
parent c3b168f86f
commit 0be94d8b87
5 changed files with 97 additions and 18 deletions

1
setup.rs Normal file
View File

@@ -0,0 +1 @@

View File

@@ -12,13 +12,15 @@ use irc::client::prelude::{Client, Command, Config as IRCConfig, Message};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{Level, event, instrument}; 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. /// Chat struct that is used to interact with IRC chat.
#[derive(Debug)] #[derive(Debug)]
pub struct Chat { pub struct Chat {
/// The actual IRC [`irc::client`](client). /// The actual IRC [`irc::client`](client).
client: Client, client: Client,
/// Handle to the directory that *may* contain command scripts.
command_dir: Option<CommandDir>,
/// Event manager for handling plugin interaction. /// Event manager for handling plugin interaction.
event_manager: Arc<EventManager>, event_manager: Arc<EventManager>,
/// Handle for whichever LLM is being used. /// Handle for whichever LLM is being used.
@@ -52,10 +54,17 @@ impl Chat {
..IRCConfig::default() ..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..."); event!(Level::INFO, "IRC connection starting...");
Ok(Chat { Ok(Chat {
client: Client::from_config(config).await?, client: Client::from_config(config).await?,
command_dir: commands_dir,
llm_handle: handle.clone(), llm_handle: handle.clone(),
event_manager: manager, event_manager: manager,
}) })
@@ -111,24 +120,63 @@ impl Chat {
// Only handle PRIVMSG for now. // Only handle PRIVMSG for now.
if let Command::PRIVMSG(channel, msg) = &message.command { if let Command::PRIVMSG(channel, msg) = &message.command {
// Just preserve the original behavior for now. // Check it's a command.
if msg.starts_with("!gem") { if let Some((cmd, args)) = command_str(msg) {
let mut llm_response = self.llm_handle.send_request(msg).await?; // Command handling time.
event!(Level::INFO, "Asked: {message}"); match cmd {
event!(Level::INFO, "Response: {llm_response}"); // 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. event!(Level::INFO, "Asked: {message}");
llm_response.retain(|c| c != '\n' && c != '\r'); event!(Level::INFO, "Response: {llm_response}");
// TODO: Make this configurable. // Keep responses to one line for now.
llm_response.truncate(500); llm_response.retain(|c| c != '\n' && c != '\r');
event!(Level::INFO, "Sending {llm_response} to channel {channel}"); // TODO: Make this configurable.
self.client.send_privmsg(channel, llm_response)?; 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(()) Ok(())
} }
} }
fn command_str(cmd: &str) -> Option<(&str, &str)> {
if !cmd.starts_with('!') {
return None;
}
Some(cmd.split_once(' ').unwrap_or((cmd, "")))
}

View File

@@ -18,6 +18,7 @@ pub mod qna;
pub mod setup; pub mod setup;
pub use chat::Chat; pub use chat::Chat;
pub use command::CommandDir;
pub use event::Event; pub use event::Event;
pub use event_manager::EventManager; pub use event_manager::EventManager;
pub use qna::LLMHandle; pub use qna::LLMHandle;

View File

@@ -3,11 +3,14 @@
//! Both command line, and configuration file options are handled here. //! Both command line, and configuration file options are handled here.
use clap::Parser; use clap::Parser;
use color_eyre::{Result, eyre::WrapErr}; use color_eyre::{
Result,
eyre::{OptionExt, WrapErr},
};
use config::Config; use config::Config;
use directories::ProjectDirs; use directories::{BaseDirs, ProjectDirs};
use std::path::PathBuf; use std::path::PathBuf;
use tracing::{info, instrument}; use tracing::{Level, event, info, instrument};
// TODO: use [clap(long, short, help_heading = Some(section))] // TODO: use [clap(long, short, help_heading = Some(section))]
/// Struct of potential arguments. /// Struct of potential arguments.
@@ -28,7 +31,7 @@ pub struct Args {
/// Root directory for file based command structure. /// Root directory for file based command structure.
#[arg(long)] #[arg(long)]
pub command_dir: Option<String>, pub command_path: Option<String>,
#[arg(long)] #[arg(long)]
/// Instructions to the model on how to behave. /// Instructions to the model on how to behave.
@@ -92,6 +95,27 @@ pub async fn init() -> Result<Setup> {
Ok(Setup { config: settings }) 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<PathBuf> {
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. /// Create a configuration object from arguments.
/// ///
/// This is exposed for testing purposes. /// This is exposed for testing purposes.
@@ -117,7 +141,12 @@ pub fn make_config(args: Args) -> Result<Config> {
.set_override_option("api-key", args.api_key.clone())? .set_override_option("api-key", args.api_key.clone())?
.set_override_option("base-url", args.base_url.clone())? .set_override_option("base-url", args.base_url.clone())?
.set_override_option("chroot-dir", args.chroot_dir.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("model", args.model.clone())?
.set_override_option("nick-password", args.nick_password.clone())? .set_override_option("nick-password", args.nick_password.clone())?
.set_override_option("instruct", args.instruct.clone())? .set_override_option("instruct", args.instruct.clone())?

View File

@@ -35,7 +35,7 @@ port = 6667
base_url: None, /* Should fail if required and not in file/env? No, base-url is optional base_url: None, /* Should fail if required and not in file/env? No, base-url is optional
* in args */ * in args */
chroot_dir: None, chroot_dir: None,
command_dir: None, command_path: None,
instruct: None, instruct: None,
model: None, // Should fallback to file model: None, // Should fallback to file
channels: None, channels: None,