Skip to content

Commit 3515d3d

Browse files
committed
feat(tinyclaw): implement skills system with executor and CLI commands
Add SkillExecutor with load/save/execute functionality: - SkillExecutor struct with storage directory management - Load/save skills from JSON files in ~/.config/terraphim/skills/ - Execute skills with template variable substitution - Progress tracking and cancellation support - Input validation with defaults support Add CLI commands: - skill save <path>: Save a skill from JSON file - skill load <name>: Display skill details - skill list: Show all saved skills - skill run <name> [key=value...]: Execute a skill - skill cancel: Cancel running skill execution Implement template substitution: - Replace {variable} patterns with input values - Smart matching for alphanumeric+underscore variables only - Prevents false matches on JSON object braces Tests: 12 new tests (all passing), 87 total tests
1 parent 3e6bc60 commit 3515d3d

8 files changed

Lines changed: 1271 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/terraphim_tinyclaw/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ env_logger = "0.11"
4747

4848
# Environment
4949
env_home = "0.1"
50+
dirs = "5"
5051

5152
# Regex for ExecutionGuard
5253
regex = "1"
@@ -61,11 +62,16 @@ serenity = { version = "0.12", optional = true }
6162
# Re-enable when matrix-sdk updates to rusqlite 0.32+ or when conflict resolved
6263
# matrix-sdk = { version = "0.7", optional = true, default-features = false, features = ["native-tls"] }
6364

65+
# Voice transcription (optional)
66+
# whisper-rs = { version = "0.11", optional = true }
67+
# symphonia = { version = "0.5", optional = true, features = ["ogg", "pcm"] }
68+
6469
[features]
6570
default = ["telegram", "discord"]
6671
telegram = ["dep:teloxide"]
6772
discord = ["dep:serenity"]
6873
# matrix = ["dep:matrix-sdk"]
74+
# voice = ["dep:whisper-rs", "dep:symphonia"]
6975

7076
[dev-dependencies]
7177
tokio-test = "0.4"

crates/terraphim_tinyclaw/src/main.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod channels;
55
mod config;
66
mod format;
77
mod session;
8+
mod skills;
89
mod tools;
910

1011
use crate::agent::agent_loop::{HybridLlmRouter, ToolCallingLoop};
@@ -14,8 +15,10 @@ use crate::channel::{Channel, ChannelManager, build_channels_from_config};
1415
use crate::channels::cli::CliChannel;
1516
use crate::config::Config;
1617
use crate::session::SessionManager;
18+
use crate::skills::{Skill, SkillExecutor};
1719
use crate::tools::create_default_registry;
1820
use clap::{Parser, Subcommand};
21+
use std::collections::HashMap;
1922
use std::path::PathBuf;
2023
use std::sync::Arc;
2124

@@ -47,6 +50,37 @@ enum Commands {
4750
},
4851
/// Run as gateway server with all enabled channels.
4952
Gateway,
53+
/// Manage skills (workflows).
54+
Skill {
55+
#[command(subcommand)]
56+
command: SkillCommands,
57+
},
58+
}
59+
60+
#[derive(Subcommand, Debug)]
61+
enum SkillCommands {
62+
/// Save a skill from a JSON file.
63+
Save {
64+
/// Path to the skill JSON file
65+
path: PathBuf,
66+
},
67+
/// Load and display a skill.
68+
Load {
69+
/// Name of the skill to load
70+
name: String,
71+
},
72+
/// List all saved skills.
73+
List,
74+
/// Run a skill with optional inputs.
75+
Run {
76+
/// Name of the skill to run
77+
name: String,
78+
/// Input values as key=value pairs (e.g., name=Alice message=hello)
79+
#[arg(value_name = "INPUTS")]
80+
inputs: Vec<String>,
81+
},
82+
/// Cancel the currently running skill.
83+
Cancel,
5084
}
5185

5286
#[tokio::main]
@@ -92,6 +126,10 @@ async fn main() -> anyhow::Result<()> {
92126
log::info!("Starting in gateway mode");
93127
run_gateway_mode(config).await?;
94128
}
129+
Commands::Skill { command } => {
130+
log::info!("Executing skill command");
131+
run_skill_command(command).await?;
132+
}
95133
}
96134

97135
log::info!("terraphim-tinyclaw shutting down");
@@ -226,3 +264,144 @@ async fn run_gateway_mode(config: Config) -> anyhow::Result<()> {
226264

227265
Ok(())
228266
}
267+
268+
async fn run_skill_command(command: SkillCommands) -> anyhow::Result<()> {
269+
let executor = SkillExecutor::with_default_storage()
270+
.map_err(|e| anyhow::anyhow!("Failed to initialize skill executor: {}", e))?;
271+
272+
match command {
273+
SkillCommands::Save { path } => {
274+
let json = tokio::fs::read_to_string(&path)
275+
.await
276+
.map_err(|e| anyhow::anyhow!("Failed to read skill file: {}", e))?;
277+
278+
let skill: Skill = serde_json::from_str(&json)
279+
.map_err(|e| anyhow::anyhow!("Invalid skill JSON: {}", e))?;
280+
281+
executor
282+
.save_skill(&skill)
283+
.map_err(|e| anyhow::anyhow!("Failed to save skill: {}", e))?;
284+
285+
println!(
286+
"✓ Skill '{}' saved successfully (v{})",
287+
skill.name, skill.version
288+
);
289+
}
290+
291+
SkillCommands::Load { name } => {
292+
let skill = executor
293+
.load_skill(&name)
294+
.map_err(|e| anyhow::anyhow!("Failed to load skill: {}", e))?;
295+
296+
println!("Skill: {}", skill.name);
297+
println!("Version: {}", skill.version);
298+
println!("Description: {}", skill.description);
299+
if let Some(author) = skill.author {
300+
println!("Author: {}", author);
301+
}
302+
303+
if !skill.inputs.is_empty() {
304+
println!("\nInputs:");
305+
for input in &skill.inputs {
306+
let req = if input.required {
307+
"required"
308+
} else {
309+
"optional"
310+
};
311+
let default = input
312+
.default
313+
.as_ref()
314+
.map(|d| format!(" (default: {})", d))
315+
.unwrap_or_default();
316+
println!(
317+
" - {}: {} [{}]{}",
318+
input.name, input.description, req, default
319+
);
320+
}
321+
}
322+
323+
println!("\nSteps ({} total):", skill.steps.len());
324+
for (i, step) in skill.steps.iter().enumerate() {
325+
let step_type = match step {
326+
crate::skills::SkillStep::Tool { tool, .. } => format!("tool: {}", tool),
327+
crate::skills::SkillStep::Llm { .. } => "llm".to_string(),
328+
crate::skills::SkillStep::Shell { .. } => "shell".to_string(),
329+
};
330+
println!(" {}. {}", i + 1, step_type);
331+
}
332+
}
333+
334+
SkillCommands::List => {
335+
let skills = executor
336+
.list_skills()
337+
.map_err(|e| anyhow::anyhow!("Failed to list skills: {}", e))?;
338+
339+
if skills.is_empty() {
340+
println!("No skills saved. Use 'skill save <file>' to add one.");
341+
} else {
342+
println!("Saved skills ({} total):", skills.len());
343+
for skill in skills {
344+
println!(
345+
" • {} (v{}) - {}",
346+
skill.name, skill.version, skill.description
347+
);
348+
}
349+
}
350+
}
351+
352+
SkillCommands::Run { name, inputs } => {
353+
let skill = executor
354+
.load_skill(&name)
355+
.map_err(|e| anyhow::anyhow!("Failed to load skill: {}", e))?;
356+
357+
// Parse inputs
358+
let mut input_map = HashMap::new();
359+
for input in inputs {
360+
if let Some((key, value)) = input.split_once('=') {
361+
input_map.insert(key.to_string(), value.to_string());
362+
} else {
363+
eprintln!(
364+
"Warning: Invalid input format '{}', expected key=value",
365+
input
366+
);
367+
}
368+
}
369+
370+
println!("Running skill '{}'...", skill.name);
371+
372+
let result = executor
373+
.execute_skill(&skill, input_map, None)
374+
.await
375+
.map_err(|e| anyhow::anyhow!("Skill execution failed: {}", e))?;
376+
377+
println!("\nStatus: {:?}", result.status);
378+
println!("Duration: {}ms", result.duration_ms);
379+
380+
if !result.output.is_empty() {
381+
println!("\nOutput:\n{}", result.output);
382+
}
383+
384+
if !result.execution_log.is_empty() {
385+
println!("\nExecution Log:");
386+
for log in &result.execution_log {
387+
let status = if log.success { "✓" } else { "✗" };
388+
println!(
389+
" {} Step {} ({}): {}ms - {}",
390+
status,
391+
log.step_number + 1,
392+
log.step_type,
393+
log.duration_ms,
394+
log.output.chars().take(50).collect::<String>()
395+
);
396+
}
397+
}
398+
}
399+
400+
SkillCommands::Cancel => {
401+
executor.cancel();
402+
println!("Cancellation signal sent.");
403+
}
404+
}
405+
406+
Ok(())
407+
}

0 commit comments

Comments
 (0)