diff --git a/README.md b/README.md index 4f6f9000..a2b4654c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ rullm --model gpt4 "Explain quantum computing" rullm --model claude "Write a poem about the ocean" rullm --model gemini "What's the weather like?" +# Use templates for structured queries ({{input}} placeholder is automatically filled) +rullm -t code-review "Review this function" + # Interactive chat rullm chat --model claude @@ -48,6 +51,39 @@ rullm keys set openai rullm keys list ``` +## 📝 Templates + +### Template Usage + +```bash +# Use a template ({{input}} is replaced by your query) +rullm -t my-template "input text" +``` + +### Template Format + +Templates are stored as TOML files in `~/.config/rullm/templates/` (or your system's config directory): + +```toml +name = "code-review" +description = "Template for code review requests" +# You can include multi-line prompts using TOML triple-quoted strings: +system_prompt = """ +You are a senior Rust engineer. + +Provide a thorough review with the following structure: +1. Summary +2. Strengths +3. Weaknesses +4. Suggestions +""" +user_prompt = "Please review this code: {{input}}" +``` + +### Template Placeholders + +- `{{input}}` – Automatically filled with the user's query text. + ### Built-in Model Aliases | Alias | Full Model | diff --git a/crates/rullm-cli/src/args.rs b/crates/rullm-cli/src/args.rs index d0245d0a..a74d5a07 100644 --- a/crates/rullm-cli/src/args.rs +++ b/crates/rullm-cli/src/args.rs @@ -11,6 +11,7 @@ use crate::commands::models::load_cached_models; use crate::commands::{Commands, ModelsCache}; use crate::config::{self, Config}; use crate::constants::{BINARY_NAME, KEYS_CONFIG_FILE}; +use crate::templates::TemplateStore; // Example strings for after_long_help const CLI_EXAMPLES: &str = r#"EXAMPLES: @@ -19,6 +20,8 @@ const CLI_EXAMPLES: &str = r#"EXAMPLES: rullm -m claude "Write a hello world program" # Using model alias rullm --no-streaming "Tell me a story" # Disable streaming for buffered output rullm -m gpt4 "Code a web server" # Stream tokens as they arrive (default) + rullm -t code-review "Review this code" # Use template for query + rullm -t greeting "Hello" # Template with input parameter rullm chat # Start interactive chat rullm chat -m gemini/gemini-pro # Chat with specific model rullm chat --no-streaming -m claude # Interactive chat without streaming"#; @@ -129,6 +132,10 @@ pub struct Cli { #[arg(short, long, add = ArgValueCompleter::new(model_completer))] pub model: Option, + /// Template to use for the query (only available for quick-query mode) + #[arg(short, long, add = ArgValueCompleter::new(template_completer))] + pub template: Option, + /// Set options in format: --option key value (e.g., --option temperature 0.1 --option max_tokens 2096) #[arg(long, value_parser = parse_key_val, global = true)] pub option: Vec<(String, String)>, @@ -214,6 +221,45 @@ pub fn model_completer(current: &OsStr) -> Vec { .collect() } +pub fn template_completer(current: &std::ffi::OsStr) -> Vec { + let cur_str = current.to_string_lossy(); + let mut candidates = Vec::new(); + + // Only suggest .toml files in CWD as @filename.toml if user input starts with '@' + if let Some(prefix) = cur_str.strip_prefix('@') { + if let Ok(entries) = std::fs::read_dir(".") { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension() { + if ext == "toml" { + if let Some(fname) = path.file_name().and_then(|f| f.to_str()) { + if fname.starts_with(prefix) { + candidates.push(format!("@{fname}").into()); + } + } + } + } + } + } + } else { + // Otherwise, suggest installed template names + let strategy = match etcetera::choose_base_strategy() { + Ok(s) => s, + Err(_) => return candidates, + }; + let config_base_path = strategy.config_dir().join(BINARY_NAME); + let mut store = TemplateStore::new(&config_base_path); + if store.load().is_ok() { + for name in store.list() { + if name.starts_with(cur_str.as_ref()) { + candidates.push(name.into()); + } + } + } + } + candidates +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rullm-cli/src/commands/mod.rs b/crates/rullm-cli/src/commands/mod.rs index fe604793..a6ca9751 100644 --- a/crates/rullm-cli/src/commands/mod.rs +++ b/crates/rullm-cli/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod alias; pub mod chat; pub mod completions; pub mod info; +pub mod templates; pub mod keys; pub mod models; @@ -79,6 +80,11 @@ pub enum Commands { /// Generate shell completions #[command(after_long_help = COMPLETIONS_EXAMPLES)] Completions(CompletionsArgs), + /// Manage templates + #[command( + after_long_help = "EXAMPLES:\n rullm templates list\n rullm templates show code-review\n rullm templates remove old-template" + )] + Templates(templates::TemplatesArgs), } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/crates/rullm-cli/src/commands/templates.rs b/crates/rullm-cli/src/commands/templates.rs new file mode 100644 index 00000000..ad2ec66c --- /dev/null +++ b/crates/rullm-cli/src/commands/templates.rs @@ -0,0 +1,206 @@ +use crate::args::template_completer; +use crate::args::{Cli, CliConfig}; +use crate::output::{self, OutputLevel}; +use crate::templates::TemplateStore; +use anyhow::Result; +use clap::{Args, Subcommand}; +use clap_complete::engine::ArgValueCompleter; + +#[derive(Args)] +pub struct TemplatesArgs { + #[command(subcommand)] + pub action: TemplateAction, +} + +#[derive(Subcommand)] +pub enum TemplateAction { + /// List all templates + List, + /// Show a specific template's details + Show { + /// Template name + #[arg(value_name = "NAME", add = ArgValueCompleter::new(template_completer))] + name: String, + }, + /// Remove a template file + Remove { + /// Template name to delete + #[arg(value_name = "NAME", add = ArgValueCompleter::new(template_completer))] + name: String, + }, + /// Edit a template file in $EDITOR + Edit { + /// Template name to edit + #[arg(value_name = "NAME", add = ArgValueCompleter::new(template_completer))] + name: String, + }, + /// Create a new template + Create { + /// Template name + name: String, + /// User prompt (use quotes if contains spaces) + #[arg(long, short = 'u')] + user_prompt: String, + /// Optional system prompt + #[arg(long, short = 's')] + system_prompt: Option, + /// Optional description + #[arg(long, short = 'd')] + description: Option, + /// Default placeholder values in key=value format. Can be repeated. + #[arg(long = "default", value_parser = parse_default_kv)] + defaults: Vec<(String, String)>, + /// Overwrite if template already exists + #[arg(long, short = 'f')] + force: bool, + }, +} + +impl TemplatesArgs { + pub async fn run( + &self, + output_level: OutputLevel, + cli_config: &CliConfig, + _cli: &Cli, + ) -> Result<()> { + let mut store = TemplateStore::new(&cli_config.config_base_path); + store.load()?; + + match &self.action { + TemplateAction::List => { + let names = store.list(); + if names.is_empty() { + output::note("No templates found.", output_level); + } else { + output::heading("Available templates:", output_level); + for name in names { + output::note(&format!(" - {name}"), output_level); + } + } + } + TemplateAction::Show { name } => { + if let Some(tpl) = store.get(name) { + output::heading(&format!("Template: {name}"), output_level); + if let Some(desc) = &tpl.description { + output::note(&format!("Description: {desc}"), output_level); + } + if let Some(sys) = &tpl.system_prompt { + output::note("System Prompt:", output_level); + output::note(sys, output_level); + } + + if let Some(user) = &tpl.user_prompt { + output::note("User Prompt:", output_level); + output::note(user, output_level); + } + + if !tpl.defaults.is_empty() { + output::note("\nDefaults:", output_level); + for (k, v) in &tpl.defaults { + output::note(&format!(" {k} = {v}"), output_level); + } + } + } else { + output::error(&format!("Template '{name}' not found."), output_level); + } + } + TemplateAction::Remove { name } => match store.delete(name) { + Ok(true) => output::success(&format!("Removed template '{name}'."), output_level), + Ok(false) => { + output::warning(&format!("Template '{name}' not found."), output_level) + } + Err(e) => output::error( + &format!("Failed to delete template '{name}': {e}"), + output_level, + ), + }, + TemplateAction::Edit { name } => { + use std::env; + use std::process::Command; + use std::process::Stdio; + + if !store.contains(name) { + output::error(&format!("Template '{name}' not found."), output_level); + return Ok(()); + } + + let file_path = store.templates_dir().join(format!("{name}.toml")); + let editor = env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string()); + + let status = Command::new(&editor) + .arg(&file_path) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status(); + + match status { + Ok(s) if s.success() => { + // Reload the store to refresh in-memory state + if let Err(e) = store.load() { + output::warning( + &format!("Edited, but failed to reload templates: {e}"), + output_level, + ); + } else { + output::success(&format!("Edited template '{name}'."), output_level); + } + } + Ok(s) => { + output::error(&format!("Editor exited with status: {s}"), output_level); + } + Err(e) => { + output::error(&format!("Failed to launch editor: {e}"), output_level); + } + } + } + TemplateAction::Create { + name, + user_prompt, + system_prompt, + description, + defaults, + force, + } => { + // Check if already exists + if store.contains(name) && !*force { + output::warning( + &format!("Template '{name}' already exists. Use --force to overwrite."), + output_level, + ); + return Ok(()); + } + + let mut template = + crate::templates::Template::new(name.clone(), user_prompt.clone()); + template.system_prompt = system_prompt.clone(); + template.description = description.clone(); + // Insert defaults + for (k, v) in defaults { + template.defaults.insert(k.clone(), v.clone()); + } + + match store.save(&template) { + Ok(_) => output::success(&format!("Saved template '{name}'."), output_level), + Err(e) => output::error( + &format!("Failed to save template '{name}': {e}"), + output_level, + ), + } + } + } + + Ok(()) + } +} + +fn parse_default_kv(s: &str) -> std::result::Result<(String, String), String> { + if let Some((k, v)) = s.split_once('=') { + if k.trim().is_empty() { + return Err("Key cannot be empty".into()); + } + Ok((k.trim().to_string(), v.trim().to_string())) + } else { + Err("Expected key=value format".into()) + } +} diff --git a/crates/rullm-cli/src/constants.rs b/crates/rullm-cli/src/constants.rs index 2766cd93..b0f35475 100644 --- a/crates/rullm-cli/src/constants.rs +++ b/crates/rullm-cli/src/constants.rs @@ -2,4 +2,5 @@ pub const CONFIG_FILE_NAME: &str = "config.toml"; pub const MODEL_FILE_NAME: &str = "models.json"; pub const ALIASES_CONFIG_FILE: &str = "aliases.toml"; pub const KEYS_CONFIG_FILE: &str = "keys.toml"; +pub const TEMPLATES_DIR_NAME: &str = "templates"; pub const BINARY_NAME: &str = env!("CARGO_BIN_NAME"); diff --git a/crates/rullm-cli/src/main.rs b/crates/rullm-cli/src/main.rs index 9b3a6a05..93adb825 100644 --- a/crates/rullm-cli/src/main.rs +++ b/crates/rullm-cli/src/main.rs @@ -10,6 +10,7 @@ mod config; mod constants; mod output; mod provider; +mod templates; use anyhow::Result; use args::{Cli, CliConfig}; @@ -17,6 +18,7 @@ use clap::{CommandFactory, Parser}; use cli_helpers::resolve_direct_query_model; use commands::Commands; use output::OutputLevel; +use templates::resolve_template_prompts; #[tokio::main] async fn main() -> Result<()> { @@ -60,6 +62,18 @@ pub async fn run() -> Result<()> { } } + // Validate that template flag is only used for quick-query mode + if cli.template.is_some() && cli.command.is_some() { + use clap::error::ErrorKind; + + let mut cmd = Cli::command(); + cmd.error( + ErrorKind::UnknownArgument, + "unexpected argument '-t/--template' found when using subcommands", + ) + .exit(); + } + // Handle commands match &cli.command { Some(Commands::Chat(args)) => args.run(output_level, &cli_config, &cli).await?, @@ -68,14 +82,28 @@ pub async fn run() -> Result<()> { Some(Commands::Keys(args)) => args.run(output_level, &mut cli_config, &cli).await?, Some(Commands::Alias(args)) => args.run(output_level, &cli_config, &cli).await?, Some(Commands::Completions(args)) => args.run(output_level, &cli_config, &cli).await?, + Some(Commands::Templates(args)) => args.run(output_level, &cli_config, &cli).await?, None => { if let Some(query) = &cli.query { let model_str = resolve_direct_query_model(&cli.model, &cli_config.config.default_model)?; let client = client::from_model(&model_str, &cli, &cli_config)?; - commands::run_single_query(&client, query, None, !cli.no_streaming) - .await - .map_err(anyhow::Error::from)?; + + // Handle template if provided + let (system_prompt, final_query) = if let Some(template_name) = &cli.template { + resolve_template_prompts(template_name, query, &cli_config.config_base_path)? + } else { + (None, query.clone()) + }; + + commands::run_single_query( + &client, + &final_query, + system_prompt.as_deref(), + !cli.no_streaming, + ) + .await + .map_err(anyhow::Error::from)?; } else { eprintln!("Error: No query provided. Use --help for usage information."); std::process::exit(1); diff --git a/crates/rullm-cli/src/templates.rs b/crates/rullm-cli/src/templates.rs new file mode 100644 index 00000000..4492fe4a --- /dev/null +++ b/crates/rullm-cli/src/templates.rs @@ -0,0 +1,660 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::constants::TEMPLATES_DIR_NAME; + +/// A template for LLM queries with placeholder support +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Template { + /// Name of the template + pub name: String, + /// System prompt/message (optional) + pub system_prompt: Option, + /// User prompt template with {{placeholder}} syntax (optional) + pub user_prompt: Option, + /// Default values for placeholders + #[serde(default)] + pub defaults: HashMap, + /// Description of the template (optional) + pub description: Option, +} + +impl Template { + /// Create a new template providing a user prompt. + /// If you need a template that only contains a system prompt, construct it manually + /// via `Template { .. }` or add a helper if needed. + pub fn new(name: String, user_prompt: String) -> Self { + Self { + name, + system_prompt: None, + user_prompt: Some(user_prompt), + defaults: HashMap::new(), + description: None, + } + } + + /// Render the template by replacing placeholders with provided values + /// Returns an error if any required placeholders are missing + pub fn render(&self, params: &HashMap) -> Result { + // Ensure we have at least one prompt defined + if self.user_prompt.is_none() && self.system_prompt.is_none() { + return Err(anyhow::anyhow!( + "Template must have at least a user_prompt or system_prompt" + )); + } + + let mut rendered_user = self.user_prompt.clone(); + let mut rendered_system = self.system_prompt.clone(); + let mut missing_placeholders = Vec::new(); + + // Extract placeholders from user prompt if it exists + let user_placeholders = if let Some(ref user) = self.user_prompt { + extract_placeholders(user) + } else { + Vec::new() + }; + + // Extract placeholders from system prompt if it exists + let system_placeholders = if let Some(ref system) = self.system_prompt { + extract_placeholders(system) + } else { + Vec::new() + }; + + // Combine all unique placeholders + let mut all_placeholders = user_placeholders; + for ph in system_placeholders { + if !all_placeholders.contains(&ph) { + all_placeholders.push(ph); + } + } + + // Replace placeholders + for placeholder in &all_placeholders { + let value = params + .get(placeholder) + .or_else(|| self.defaults.get(placeholder)); + + match value { + Some(val) => { + let pattern = format!("{{{{{placeholder}}}}}"); + if let Some(ref mut user) = rendered_user { + *user = user.replace(&pattern, val); + } + if let Some(ref mut system) = rendered_system { + *system = system.replace(&pattern, val); + } + } + None => { + missing_placeholders.push(placeholder.clone()); + } + } + } + + if !missing_placeholders.is_empty() { + return Err(anyhow::anyhow!( + "Missing required placeholders: {}", + missing_placeholders.join(", ") + )); + } + + Ok(RenderedTemplate { + system_prompt: rendered_system, + user_prompt: rendered_user, + }) + } + + /// Get all placeholders required by this template + #[allow(dead_code)] + pub fn get_placeholders(&self) -> Vec { + let mut placeholders = if let Some(ref user) = self.user_prompt { + extract_placeholders(user) + } else { + Vec::new() + }; + + if let Some(ref system) = self.system_prompt { + let system_placeholders = extract_placeholders(system); + for ph in system_placeholders { + if !placeholders.contains(&ph) { + placeholders.push(ph); + } + } + } + + placeholders + } + + /// Simplified rendering that only supports a single `{{input}}` placeholder. + /// This method constructs the minimal parameter map with the provided input + /// and delegates to `render`. All other placeholder parameters are no + /// longer supported. + pub fn render_input(&self, input: &str) -> Result { + let mut params = HashMap::new(); + params.insert("input".to_string(), input.to_string()); + self.render(¶ms) + } +} + +/// A rendered template ready for use +#[derive(Debug)] +pub struct RenderedTemplate { + pub system_prompt: Option, + pub user_prompt: Option, +} + +/// Store for managing templates +pub struct TemplateStore { + templates_dir: PathBuf, + templates: HashMap, +} + +impl TemplateStore { + /// Create a new TemplateStore with the given base directory + pub fn new(base_path: &Path) -> Self { + let templates_dir = base_path.join(TEMPLATES_DIR_NAME); + Self { + templates_dir, + templates: HashMap::new(), + } + } + + /// Load all templates from the templates directory + pub fn load(&mut self) -> Result<()> { + self.templates.clear(); + + // Create templates directory if it doesn't exist + if !self.templates_dir.exists() { + fs::create_dir_all(&self.templates_dir) + .context("Failed to create templates directory")?; + return Ok(()); + } + + // Read all .toml files in the templates directory + let entries = + fs::read_dir(&self.templates_dir).context("Failed to read templates directory")?; + + for entry in entries { + let entry = entry.context("Failed to read directory entry")?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("toml") { + match self.load_template_file(&path) { + Ok(template) => { + self.templates.insert(template.name.clone(), template); + } + Err(e) => { + eprintln!("Warning: Failed to load template {path:?}: {e}"); + } + } + } + } + + Ok(()) + } + + /// Load a single template file + fn load_template_file(&self, path: &Path) -> Result