Skip to content

Commit 28d2dbf

Browse files
authored
feat(cli): add basic templates (#1)
feat(cli/templates): add template management - Adds a templates subcommand for listing, showing, editing, creating, and removing prompt templates. - Enables quick-query template usage via -t/--template with TOML-based files and placeholder substitution. - Updates CLI help, examples, and autocompletion for templates. - Refactors template storage, config integration, and user experience for structured prompt workflows.
1 parent a35acc7 commit 28d2dbf

File tree

8 files changed

+987
-4
lines changed

8 files changed

+987
-4
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ rullm --model gpt4 "Explain quantum computing"
1515
rullm --model claude "Write a poem about the ocean"
1616
rullm --model gemini "What's the weather like?"
1717

18+
# Use templates for structured queries ({{input}} placeholder is automatically filled)
19+
rullm -t code-review "Review this function"
20+
1821
# Interactive chat
1922
rullm chat --model claude
2023

@@ -48,6 +51,39 @@ rullm keys set openai
4851
rullm keys list
4952
```
5053

54+
## 📝 Templates
55+
56+
### Template Usage
57+
58+
```bash
59+
# Use a template ({{input}} is replaced by your query)
60+
rullm -t my-template "input text"
61+
```
62+
63+
### Template Format
64+
65+
Templates are stored as TOML files in `~/.config/rullm/templates/` (or your system's config directory):
66+
67+
```toml
68+
name = "code-review"
69+
description = "Template for code review requests"
70+
# You can include multi-line prompts using TOML triple-quoted strings:
71+
system_prompt = """
72+
You are a senior Rust engineer.
73+
74+
Provide a thorough review with the following structure:
75+
1. Summary
76+
2. Strengths
77+
3. Weaknesses
78+
4. Suggestions
79+
"""
80+
user_prompt = "Please review this code: {{input}}"
81+
```
82+
83+
### Template Placeholders
84+
85+
- `{{input}}` – Automatically filled with the user's query text.
86+
5187
### Built-in Model Aliases
5288

5389
| Alias | Full Model |

crates/rullm-cli/src/args.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::commands::models::load_cached_models;
1111
use crate::commands::{Commands, ModelsCache};
1212
use crate::config::{self, Config};
1313
use crate::constants::{BINARY_NAME, KEYS_CONFIG_FILE};
14+
use crate::templates::TemplateStore;
1415

1516
// Example strings for after_long_help
1617
const CLI_EXAMPLES: &str = r#"EXAMPLES:
@@ -19,6 +20,8 @@ const CLI_EXAMPLES: &str = r#"EXAMPLES:
1920
rullm -m claude "Write a hello world program" # Using model alias
2021
rullm --no-streaming "Tell me a story" # Disable streaming for buffered output
2122
rullm -m gpt4 "Code a web server" # Stream tokens as they arrive (default)
23+
rullm -t code-review "Review this code" # Use template for query
24+
rullm -t greeting "Hello" # Template with input parameter
2225
rullm chat # Start interactive chat
2326
rullm chat -m gemini/gemini-pro # Chat with specific model
2427
rullm chat --no-streaming -m claude # Interactive chat without streaming"#;
@@ -129,6 +132,10 @@ pub struct Cli {
129132
#[arg(short, long, add = ArgValueCompleter::new(model_completer))]
130133
pub model: Option<String>,
131134

135+
/// Template to use for the query (only available for quick-query mode)
136+
#[arg(short, long, add = ArgValueCompleter::new(template_completer))]
137+
pub template: Option<String>,
138+
132139
/// Set options in format: --option key value (e.g., --option temperature 0.1 --option max_tokens 2096)
133140
#[arg(long, value_parser = parse_key_val, global = true)]
134141
pub option: Vec<(String, String)>,
@@ -214,6 +221,45 @@ pub fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
214221
.collect()
215222
}
216223

224+
pub fn template_completer(current: &std::ffi::OsStr) -> Vec<clap_complete::CompletionCandidate> {
225+
let cur_str = current.to_string_lossy();
226+
let mut candidates = Vec::new();
227+
228+
// Only suggest .toml files in CWD as @filename.toml if user input starts with '@'
229+
if let Some(prefix) = cur_str.strip_prefix('@') {
230+
if let Ok(entries) = std::fs::read_dir(".") {
231+
for entry in entries.flatten() {
232+
let path = entry.path();
233+
if let Some(ext) = path.extension() {
234+
if ext == "toml" {
235+
if let Some(fname) = path.file_name().and_then(|f| f.to_str()) {
236+
if fname.starts_with(prefix) {
237+
candidates.push(format!("@{fname}").into());
238+
}
239+
}
240+
}
241+
}
242+
}
243+
}
244+
} else {
245+
// Otherwise, suggest installed template names
246+
let strategy = match etcetera::choose_base_strategy() {
247+
Ok(s) => s,
248+
Err(_) => return candidates,
249+
};
250+
let config_base_path = strategy.config_dir().join(BINARY_NAME);
251+
let mut store = TemplateStore::new(&config_base_path);
252+
if store.load().is_ok() {
253+
for name in store.list() {
254+
if name.starts_with(cur_str.as_ref()) {
255+
candidates.push(name.into());
256+
}
257+
}
258+
}
259+
}
260+
candidates
261+
}
262+
217263
#[cfg(test)]
218264
mod tests {
219265
use super::*;

crates/rullm-cli/src/commands/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod alias;
1414
pub mod chat;
1515
pub mod completions;
1616
pub mod info;
17+
pub mod templates;
1718

1819
pub mod keys;
1920
pub mod models;
@@ -79,6 +80,11 @@ pub enum Commands {
7980
/// Generate shell completions
8081
#[command(after_long_help = COMPLETIONS_EXAMPLES)]
8182
Completions(CompletionsArgs),
83+
/// Manage templates
84+
#[command(
85+
after_long_help = "EXAMPLES:\n rullm templates list\n rullm templates show code-review\n rullm templates remove old-template"
86+
)]
87+
Templates(templates::TemplatesArgs),
8288
}
8389

8490
#[derive(Serialize, Deserialize, Debug, Clone)]
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
use crate::args::template_completer;
2+
use crate::args::{Cli, CliConfig};
3+
use crate::output::{self, OutputLevel};
4+
use crate::templates::TemplateStore;
5+
use anyhow::Result;
6+
use clap::{Args, Subcommand};
7+
use clap_complete::engine::ArgValueCompleter;
8+
9+
#[derive(Args)]
10+
pub struct TemplatesArgs {
11+
#[command(subcommand)]
12+
pub action: TemplateAction,
13+
}
14+
15+
#[derive(Subcommand)]
16+
pub enum TemplateAction {
17+
/// List all templates
18+
List,
19+
/// Show a specific template's details
20+
Show {
21+
/// Template name
22+
#[arg(value_name = "NAME", add = ArgValueCompleter::new(template_completer))]
23+
name: String,
24+
},
25+
/// Remove a template file
26+
Remove {
27+
/// Template name to delete
28+
#[arg(value_name = "NAME", add = ArgValueCompleter::new(template_completer))]
29+
name: String,
30+
},
31+
/// Edit a template file in $EDITOR
32+
Edit {
33+
/// Template name to edit
34+
#[arg(value_name = "NAME", add = ArgValueCompleter::new(template_completer))]
35+
name: String,
36+
},
37+
/// Create a new template
38+
Create {
39+
/// Template name
40+
name: String,
41+
/// User prompt (use quotes if contains spaces)
42+
#[arg(long, short = 'u')]
43+
user_prompt: String,
44+
/// Optional system prompt
45+
#[arg(long, short = 's')]
46+
system_prompt: Option<String>,
47+
/// Optional description
48+
#[arg(long, short = 'd')]
49+
description: Option<String>,
50+
/// Default placeholder values in key=value format. Can be repeated.
51+
#[arg(long = "default", value_parser = parse_default_kv)]
52+
defaults: Vec<(String, String)>,
53+
/// Overwrite if template already exists
54+
#[arg(long, short = 'f')]
55+
force: bool,
56+
},
57+
}
58+
59+
impl TemplatesArgs {
60+
pub async fn run(
61+
&self,
62+
output_level: OutputLevel,
63+
cli_config: &CliConfig,
64+
_cli: &Cli,
65+
) -> Result<()> {
66+
let mut store = TemplateStore::new(&cli_config.config_base_path);
67+
store.load()?;
68+
69+
match &self.action {
70+
TemplateAction::List => {
71+
let names = store.list();
72+
if names.is_empty() {
73+
output::note("No templates found.", output_level);
74+
} else {
75+
output::heading("Available templates:", output_level);
76+
for name in names {
77+
output::note(&format!(" - {name}"), output_level);
78+
}
79+
}
80+
}
81+
TemplateAction::Show { name } => {
82+
if let Some(tpl) = store.get(name) {
83+
output::heading(&format!("Template: {name}"), output_level);
84+
if let Some(desc) = &tpl.description {
85+
output::note(&format!("Description: {desc}"), output_level);
86+
}
87+
if let Some(sys) = &tpl.system_prompt {
88+
output::note("System Prompt:", output_level);
89+
output::note(sys, output_level);
90+
}
91+
92+
if let Some(user) = &tpl.user_prompt {
93+
output::note("User Prompt:", output_level);
94+
output::note(user, output_level);
95+
}
96+
97+
if !tpl.defaults.is_empty() {
98+
output::note("\nDefaults:", output_level);
99+
for (k, v) in &tpl.defaults {
100+
output::note(&format!(" {k} = {v}"), output_level);
101+
}
102+
}
103+
} else {
104+
output::error(&format!("Template '{name}' not found."), output_level);
105+
}
106+
}
107+
TemplateAction::Remove { name } => match store.delete(name) {
108+
Ok(true) => output::success(&format!("Removed template '{name}'."), output_level),
109+
Ok(false) => {
110+
output::warning(&format!("Template '{name}' not found."), output_level)
111+
}
112+
Err(e) => output::error(
113+
&format!("Failed to delete template '{name}': {e}"),
114+
output_level,
115+
),
116+
},
117+
TemplateAction::Edit { name } => {
118+
use std::env;
119+
use std::process::Command;
120+
use std::process::Stdio;
121+
122+
if !store.contains(name) {
123+
output::error(&format!("Template '{name}' not found."), output_level);
124+
return Ok(());
125+
}
126+
127+
let file_path = store.templates_dir().join(format!("{name}.toml"));
128+
let editor = env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
129+
130+
let status = Command::new(&editor)
131+
.arg(&file_path)
132+
.stdin(Stdio::inherit())
133+
.stdout(Stdio::inherit())
134+
.stderr(Stdio::inherit())
135+
.status();
136+
137+
match status {
138+
Ok(s) if s.success() => {
139+
// Reload the store to refresh in-memory state
140+
if let Err(e) = store.load() {
141+
output::warning(
142+
&format!("Edited, but failed to reload templates: {e}"),
143+
output_level,
144+
);
145+
} else {
146+
output::success(&format!("Edited template '{name}'."), output_level);
147+
}
148+
}
149+
Ok(s) => {
150+
output::error(&format!("Editor exited with status: {s}"), output_level);
151+
}
152+
Err(e) => {
153+
output::error(&format!("Failed to launch editor: {e}"), output_level);
154+
}
155+
}
156+
}
157+
TemplateAction::Create {
158+
name,
159+
user_prompt,
160+
system_prompt,
161+
description,
162+
defaults,
163+
force,
164+
} => {
165+
// Check if already exists
166+
if store.contains(name) && !*force {
167+
output::warning(
168+
&format!("Template '{name}' already exists. Use --force to overwrite."),
169+
output_level,
170+
);
171+
return Ok(());
172+
}
173+
174+
let mut template =
175+
crate::templates::Template::new(name.clone(), user_prompt.clone());
176+
template.system_prompt = system_prompt.clone();
177+
template.description = description.clone();
178+
// Insert defaults
179+
for (k, v) in defaults {
180+
template.defaults.insert(k.clone(), v.clone());
181+
}
182+
183+
match store.save(&template) {
184+
Ok(_) => output::success(&format!("Saved template '{name}'."), output_level),
185+
Err(e) => output::error(
186+
&format!("Failed to save template '{name}': {e}"),
187+
output_level,
188+
),
189+
}
190+
}
191+
}
192+
193+
Ok(())
194+
}
195+
}
196+
197+
fn parse_default_kv(s: &str) -> std::result::Result<(String, String), String> {
198+
if let Some((k, v)) = s.split_once('=') {
199+
if k.trim().is_empty() {
200+
return Err("Key cannot be empty".into());
201+
}
202+
Ok((k.trim().to_string(), v.trim().to_string()))
203+
} else {
204+
Err("Expected key=value format".into())
205+
}
206+
}

crates/rullm-cli/src/constants.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ pub const CONFIG_FILE_NAME: &str = "config.toml";
22
pub const MODEL_FILE_NAME: &str = "models.json";
33
pub const ALIASES_CONFIG_FILE: &str = "aliases.toml";
44
pub const KEYS_CONFIG_FILE: &str = "keys.toml";
5+
pub const TEMPLATES_DIR_NAME: &str = "templates";
56
pub const BINARY_NAME: &str = env!("CARGO_BIN_NAME");

0 commit comments

Comments
 (0)