Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
46 changes: 46 additions & 0 deletions crates/rullm-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"#;
Expand Down Expand Up @@ -129,6 +132,10 @@ pub struct Cli {
#[arg(short, long, add = ArgValueCompleter::new(model_completer))]
pub model: Option<String>,

/// Template to use for the query (only available for quick-query mode)
#[arg(short, long, add = ArgValueCompleter::new(template_completer))]
pub template: Option<String>,

/// 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)>,
Expand Down Expand Up @@ -214,6 +221,45 @@ pub fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
.collect()
}

pub fn template_completer(current: &std::ffi::OsStr) -> Vec<clap_complete::CompletionCandidate> {
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::*;
Expand Down
6 changes: 6 additions & 0 deletions crates/rullm-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down
206 changes: 206 additions & 0 deletions crates/rullm-cli/src/commands/templates.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// Optional description
#[arg(long, short = 'd')]
description: Option<String>,
/// 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())
}
}
1 change: 1 addition & 0 deletions crates/rullm-cli/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Loading