diff --git a/README.md b/README.md index b6e9dca..d1a76a4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Thulp

- Execution context engineering for AI agents. Rust. 11 crates. 331 tests.
+ Execution context engineering for AI agents.
One interface for tool discovery, validation, execution, and multi-step workflows.

@@ -13,8 +13,6 @@ crates.io CI docs.rs - 11 crates - 331 tests License

@@ -169,7 +167,7 @@ thulp/ ```bash cargo build --workspace # build all -cargo test --workspace # 331 tests +cargo test --workspace cargo clippy --workspace -- -D warnings # lint (currently clean) cargo bench -p thulp-core --bench tool_benchmarks # benchmarks ``` diff --git a/crates/thulp-cli/src/commands/config.rs b/crates/thulp-cli/src/commands/config.rs new file mode 100644 index 0000000..56b2dc2 --- /dev/null +++ b/crates/thulp-cli/src/commands/config.rs @@ -0,0 +1,308 @@ +use clap::{Subcommand, ValueEnum}; +use serde_json::json; +use std::path::{Path, PathBuf}; +use crate::output::Output; + +#[derive(Subcommand, Debug)] +pub enum ConfigCommands { + /// Show current configuration + Show, + + /// Get a configuration value + Get { + /// Configuration key (e.g., servers.github.url) + #[arg(value_name = "KEY")] + key: String, + }, + + /// Set a configuration value + Set { + /// Configuration key + #[arg(value_name = "KEY")] + key: String, + + /// Configuration value + #[arg(value_name = "VALUE")] + value: String, + }, + + /// Add an MCP server configuration + AddServer { + /// Server name + #[arg(value_name = "NAME")] + name: String, + + /// Server type (stdio, http) + #[arg(short, long, value_enum)] + r#type: ServerType, + + /// Command (for stdio) or URL (for http) + #[arg(value_name = "TARGET")] + target: String, + + /// Command arguments (for stdio) + #[arg(short, long)] + args: Vec, + }, + + /// List configured servers + Servers, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ServerType { + /// STDIO transport (local command) + Stdio, + /// HTTP/SSE transport (remote server) + Http, +} + +/// Handle `thulp init` command +pub fn handle_init( + dir: PathBuf, + name: Option, + force: bool, + output: &Output, +) -> Result<(), Box> { + let thulp_dir = dir.join(".thulp"); + + if thulp_dir.exists() && !force { + if output.is_json() { + output.print_json(&json!({ + "error": "already_initialized", + "path": thulp_dir.display().to_string(), + "hint": "Use --force to reinitialize" + })); + } else { + output.print_text(&format!( + "❌ Workspace already initialized at {}", + thulp_dir.display() + )); + output.print_text(" Use --force to reinitialize"); + } + return Ok(()); + } + + // Create directory structure + std::fs::create_dir_all(&thulp_dir)?; + std::fs::create_dir_all(thulp_dir.join("skills"))?; + std::fs::create_dir_all(thulp_dir.join("sessions"))?; + std::fs::create_dir_all(thulp_dir.join("cache"))?; + + // Determine workspace name + let workspace_name = name.unwrap_or_else(|| { + dir.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("workspace") + .to_string() + }); + + // Create config.yaml + let config = json!({ + "name": workspace_name, + "version": "1.0", + "servers": {}, + "settings": { + "default_timeout": 30, + "max_retries": 3 + } + }); + + let config_path = thulp_dir.join("config.yaml"); + let config_yaml = serde_yaml::to_string(&config)?; + std::fs::write(&config_path, config_yaml)?; + + // Create .gitignore + let gitignore = "sessions/\ncache/\n*.log\n"; + std::fs::write(thulp_dir.join(".gitignore"), gitignore)?; + + if output.is_json() { + output.print_json(&json!({ + "status": "initialized", + "path": thulp_dir.display().to_string(), + "name": workspace_name, + "created": [ + "config.yaml", + "skills/", + "sessions/", + "cache/", + ".gitignore" + ] + })); + } else { + output.print_text(&format!( + "✅ Initialized thulp workspace: {}", + workspace_name + )); + output.print_text(&format!(" Location: {}", thulp_dir.display())); + output.print_text(""); + output.print_text(" Created:"); + output.print_text(" ├── config.yaml (workspace configuration)"); + output.print_text(" ├── skills/ (skill definitions)"); + output.print_text(" ├── sessions/ (session storage)"); + output.print_text(" ├── cache/ (temporary files)"); + output.print_text(" └── .gitignore"); + output.print_text(""); + output.print_text(" Next steps:"); + output.print_text( + " 1. Add MCP servers: thulp config add-server --type stdio ", + ); + output.print_text(" 2. List tools: thulp tools list"); + output.print_text(" 3. Run a tool: thulp run key=value"); + } + + Ok(()) +} + +/// Handle `thulp config` subcommands +pub fn handle_config_commands( + command: ConfigCommands, + workspace_dir: &Path, + output: &Output, +) -> Result<(), Box> { + let config_path = workspace_dir.join(".thulp/config.yaml"); + + match command { + ConfigCommands::Show => { + if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + if output.is_json() { + let parsed: serde_json::Value = serde_yaml::from_str(&content)?; + output.print_json(&parsed); + } else { + output.print_text(&content); + } + } else { + if output.is_json() { + output.print_json(&json!({"error": "not_initialized"})); + } else { + output.print_text("❌ No workspace found. Run 'thulp init' first."); + } + } + } + ConfigCommands::Get { key } => { + if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + let config: serde_json::Value = serde_yaml::from_str(&content)?; + + let parts: Vec<&str> = key.split('.').collect(); + let mut current = &config; + for part in &parts { + if let Some(next) = current.get(part) { + current = next; + } else { + return Err(format!("Key not found: {}", key).into()); + } + } + + if output.is_json() { + output.print_json(current); + } else { + output.print_text(&serde_json::to_string_pretty(current)?); + } + } else { + return Err("No workspace found. Run 'thulp init' first.".into()); + } + } + ConfigCommands::Set { key, value } => { + if !config_path.exists() { + return Err("No workspace found. Run 'thulp init' first.".into()); + } + + let content = std::fs::read_to_string(&config_path)?; + let mut config: serde_json::Value = serde_yaml::from_str(&content)?; + + let parsed_value: serde_json::Value = serde_json::from_str(&value) + .unwrap_or_else(|_| serde_json::Value::String(value.clone())); + + if let Some(obj) = config.as_object_mut() { + obj.insert(key.clone(), parsed_value); + } + + let updated = serde_yaml::to_string(&config)?; + std::fs::write(&config_path, updated)?; + + if output.is_json() { + output.print_json(&json!({"status": "updated", "key": key})); + } else { + output.print_text(&format!("✅ Set {} = {}", key, value)); + } + } + ConfigCommands::AddServer { + name, + r#type, + target, + args, + } => { + if !config_path.exists() { + return Err("No workspace found. Run 'thulp init' first.".into()); + } + + let content = std::fs::read_to_string(&config_path)?; + let mut config: serde_json::Value = serde_yaml::from_str(&content)?; + + let server_config = match r#type { + ServerType::Stdio => json!({ + "type": "stdio", + "command": target, + "args": args + }), + ServerType::Http => json!({ + "type": "http", + "url": target + }), + }; + + if let Some(obj) = config.as_object_mut() { + let servers = obj.entry("servers").or_insert_with(|| json!({})); + if let Some(servers_obj) = servers.as_object_mut() { + servers_obj.insert(name.clone(), server_config); + } + } + + let updated = serde_yaml::to_string(&config)?; + std::fs::write(&config_path, updated)?; + + if output.is_json() { + output.print_json(&json!({"status": "added", "server": name})); + } else { + output.print_text(&format!("✅ Added server: {}", name)); + } + } + ConfigCommands::Servers => { + if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + let config: serde_json::Value = serde_yaml::from_str(&content)?; + + let servers = config.get("servers").cloned().unwrap_or(json!({})); + + if output.is_json() { + output.print_json(&servers); + } else { + if let Some(obj) = servers.as_object() { + if obj.is_empty() { + output.print_text("No servers configured."); + } else { + output.print_text("Configured servers:"); + for (name, config) in obj { + let server_type = config + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + output.print_text(&format!(" 🔌 {} ({})", name, server_type)); + } + } + } + } + } else { + if output.is_json() { + output.print_json(&json!({"error": "not_initialized"})); + } else { + output.print_text("❌ No workspace found. Run 'thulp init' first."); + } + } + } + } + + Ok(()) +} diff --git a/crates/thulp-cli/src/commands/convert.rs b/crates/thulp-cli/src/commands/convert.rs new file mode 100644 index 0000000..8bb19dc --- /dev/null +++ b/crates/thulp-cli/src/commands/convert.rs @@ -0,0 +1,181 @@ +use clap::Subcommand; +use serde_json::json; +use std::path::PathBuf; +use thulp_adapter::AdapterGenerator; +use crate::output::Output; + +#[derive(Subcommand, Debug)] +pub enum ConvertCommands { + /// Convert OpenAPI spec to tool definitions + OpenApi { + #[arg(value_name = "FILE")] + file: PathBuf, + /// Output file for generated config + #[arg(long, value_name = "FILE")] + out: Option, + }, + /// Show conversion examples + Examples, +} + + +pub fn handle_convert_commands( + command: ConvertCommands, + output: &Output, +) -> Result<(), Box> { + match command { + ConvertCommands::OpenApi { + file, + out: output_file, + } => { + let spec_content = std::fs::read_to_string(&file)?; + + let spec: serde_json::Value = if file + .extension() + .is_some_and(|ext| ext == "yaml" || ext == "yml") + { + serde_yaml::from_str(&spec_content) + .map_err(|e| format!("Failed to parse YAML: {}", e))? + } else { + serde_json::from_str(&spec_content) + .or_else(|_| serde_yaml::from_str(&spec_content)) + .map_err(|e| format!("Failed to parse spec (tried JSON and YAML): {}", e))? + }; + + let generator = AdapterGenerator::new(spec, Some("api-adapter".to_string())); + let tools = generator + .generate_tools() + .map_err(|e| format!("Failed to generate tools: {}", e))?; + + if output.is_json() { + output.print_json(&json!({ + "source": file.display().to_string(), + "tools_generated": tools.len(), + "tools": tools.iter().map(|t| json!({ + "name": t.name, + "description": t.description, + "parameter_count": t.parameters.len() + })).collect::>() + })); + } else { + output.print_text(&format!( + "Converting OpenAPI spec from {}...", + file.display() + )); + output.print_text(&format!("Generated {} tool definitions", tools.len())); + + for tool in &tools { + output.print_text(&format!( + " - {}: {} parameters", + tool.name, + tool.parameters.len() + )); + } + } + + if let Some(output_path) = output_file { + let config = generator + .generate_config() + .map_err(|e| format!("Failed to generate config: {}", e))?; + std::fs::write(&output_path, config)?; + if !output.is_json() { + output.print_text(&format!( + "✅ Configuration written to: {}", + output_path.display() + )); + } + } + + if !output.is_json() { + output.print_text("✅ Conversion complete"); + } + } + ConvertCommands::Examples => { + if output.is_json() { + let example_spec = json!({ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List all users", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": {"type": "integer"} + } + ] + } + }, + "/users/{id}": { + "get": { + "operationId": "getUser", + "summary": "Get user by ID" + } + } + } + }); + output.print_json(&json!({ + "examples": [ + { + "name": "GitHub API", + "command": "thulp convert openapi github_api.yaml" + }, + { + "name": "Slack API", + "command": "thulp convert openapi slack_api.json --output tools.yaml" + } + ], + "sample_spec": example_spec + })); + } else { + output.print_text("OpenAPI Conversion Examples:"); + output.print_text(""); + output.print_text("GitHub API:"); + output.print_text(" thulp convert openapi github_api.yaml"); + output.print_text(""); + output.print_text("Slack API:"); + output.print_text(" thulp convert openapi slack_api.json --output tools.yaml"); + output.print_text(""); + output.print_text("Example OpenAPI v3 specification:"); + let example_spec = json!({ + "openapi": "3.0.0", + "info": { + "title": "Sample API", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List all users", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": {"type": "integer"} + } + ] + } + }, + "/users/{id}": { + "get": { + "operationId": "getUser", + "summary": "Get user by ID" + } + } + } + }); + output.print_text(&serde_yaml::to_string(&example_spec)?); + } + } + } + Ok(()) +} + + diff --git a/crates/thulp-cli/src/commands/mcp.rs b/crates/thulp-cli/src/commands/mcp.rs new file mode 100644 index 0000000..bebebbe --- /dev/null +++ b/crates/thulp-cli/src/commands/mcp.rs @@ -0,0 +1,134 @@ +use clap::Subcommand; +use serde_json::json; +use thulp_mcp::{McpClient, McpTransport}; +use crate::output::Output; + +#[derive(Subcommand, Debug)] +pub enum McpCommands { + /// Connect to an MCP server via HTTP + ConnectHttp { + #[arg(value_name = "NAME")] + name: String, + #[arg(value_name = "URL")] + url: String, + }, + /// Connect to an MCP server via STDIO + ConnectStdio { + #[arg(value_name = "NAME")] + name: String, + #[arg(value_name = "COMMAND")] + command: String, + #[arg(value_name = "ARGS")] + args: Vec, + }, + /// List tools from connected MCP server + List, + /// Call a tool on the MCP server + Call { + #[arg(value_name = "NAME")] + name: String, + #[arg(value_name = "ARGUMENTS")] + arguments: Option, + }, + /// Show connection status + Status, +} + +#[derive(Subcommand, Debug)] + +pub async fn handle_mcp_commands( + command: McpCommands, + output: &Output, +) -> Result<(), Box> { + match command { + McpCommands::ConnectHttp { name, url } => { + let transport = McpTransport::new_http(name.clone(), url.clone()); + let client = McpClient::new(transport); + + if output.is_json() { + output.print_json(&json!({ + "status": "connected", + "name": name, + "url": url, + "session_id": client.session_id(), + })); + } else { + output.print_text(&format!( + "Connecting to MCP server '{}' at {}...", + name, url + )); + output.print_text(&format!("✅ Connected to MCP server '{}'", name)); + output.print_text(&format!(" Session ID: {}", client.session_id())); + } + } + McpCommands::ConnectStdio { + name, + command, + args, + } => { + let transport = + McpTransport::new_stdio(name.clone(), command.clone(), Some(args.clone())); + let client = McpClient::new(transport); + + if output.is_json() { + output.print_json(&json!({ + "status": "connected", + "name": name, + "command": command, + "args": args, + "session_id": client.session_id(), + })); + } else { + output.print_text(&format!( + "Connecting to MCP server '{}' via STDIO command '{}'...", + name, command + )); + output.print_text(&format!("✅ Connected to MCP server '{}'", name)); + output.print_text(&format!(" Session ID: {}", client.session_id())); + } + } + McpCommands::List => { + if output.is_json() { + output.print_json(&json!({ + "tools": [], + "message": "No active connection" + })); + } else { + output.print_text("This would list tools from the connected MCP server"); + } + } + McpCommands::Call { name, arguments } => { + let _arguments_json = match arguments { + Some(args) => serde_json::from_str(&args)?, + None => json!({}), + }; + + if output.is_json() { + output.print_json(&json!({ + "tool": name, + "status": "completed", + "result": null + })); + } else { + output.print_text(&format!("Calling tool '{}' on MCP server...", name)); + output.print_text("✅ Tool call completed"); + } + } + McpCommands::Status => { + if output.is_json() { + output.print_json(&json!({ + "transport": "ready", + "session": "active", + "connected_servers": 0 + })); + } else { + output.print_text("MCP Connection Status:"); + output.print_text(" Transport: Ready"); + output.print_text(" Session: Active"); + output.print_text(" Connected servers: 0"); + } + } + } + Ok(()) +} + diff --git a/crates/thulp-cli/src/commands/mod.rs b/crates/thulp-cli/src/commands/mod.rs new file mode 100644 index 0000000..bd84c8f --- /dev/null +++ b/crates/thulp-cli/src/commands/mod.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod convert; +pub mod skill; +pub mod tools; + +#[cfg(feature = "mcp")] +pub mod mcp; diff --git a/crates/thulp-cli/src/commands/skill.rs b/crates/thulp-cli/src/commands/skill.rs new file mode 100644 index 0000000..b293e60 --- /dev/null +++ b/crates/thulp-cli/src/commands/skill.rs @@ -0,0 +1,470 @@ +use clap::{Subcommand, ValueEnum}; +use serde_json::json; +use std::path::{Path, PathBuf}; +use crate::output::Output; + +#[derive(Subcommand, Debug)] +pub enum SkillCommands { + /// List available skills + List { + /// Filter by tag + #[arg(short, long)] + tag: Option, + + /// Show skills from specific scope + #[arg(short, long, value_enum)] + scope: Option, + }, + + /// Show skill details + Show { + /// Skill name + #[arg(value_name = "NAME")] + name: String, + }, + + /// Execute a skill workflow + Run { + /// Skill name + #[arg(value_name = "NAME")] + name: String, + + /// Input parameters as key=value pairs + #[arg(value_name = "PARAMS")] + params: Vec, + + /// Parameters as JSON string + #[arg(short, long)] + json: Option, + + /// Timeout in seconds (per step) + #[arg(short, long, default_value = "60")] + timeout: u64, + + /// Dry run (show execution plan without running) + #[arg(long)] + dry_run: bool, + + /// Continue on step failure + #[arg(long)] + continue_on_error: bool, + }, + + /// Validate a skill definition + Validate { + /// Path to skill file (SKILL.md or skill.yaml) + #[arg(value_name = "FILE")] + file: PathBuf, + }, + + /// Export skill as shell script + Export { + /// Skill name + #[arg(value_name = "NAME")] + name: String, + + /// Output file (default: stdout) + #[arg(long, value_name = "FILE")] + out: Option, + + /// Export format + #[arg(short, long, value_enum, default_value = "shell")] + format: ExportFormat, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum SkillScope { + /// Global skills (~/.thulp/skills) + Global, + /// Workspace skills (.thulp/skills) + Workspace, + /// Project skills (./skills) + Project, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ExportFormat { + /// Shell script + Shell, + /// JSON workflow + Json, + /// YAML workflow + Yaml, +} + +pub async fn handle_skill_commands( + command: SkillCommands, + workspace_dir: &Path, + output: &Output, +) -> Result<(), Box> { + match command { + SkillCommands::List { tag, scope } => { + handle_skill_list(workspace_dir, tag, scope, output)?; + } + SkillCommands::Show { name } => { + handle_skill_show(workspace_dir, &name, output)?; + } + SkillCommands::Run { + name, + params, + json, + timeout, + dry_run, + continue_on_error, + } => { + handle_skill_run(SkillRunOpts { + workspace_dir, + name: &name, + params, + json_params: json, + timeout, + dry_run, + continue_on_error, + output, + }) + .await?; + } + SkillCommands::Validate { file } => { + handle_skill_validate(&file, output)?; + } + SkillCommands::Export { + name, + out: output_file, + format, + } => { + handle_skill_export(workspace_dir, &name, output_file, format, output)?; + } + } + Ok(()) +} + +pub fn handle_skill_list( + workspace_dir: &Path, + tag: Option, + scope: Option, + output: &Output, +) -> Result<(), Box> { + let mut skills = Vec::new(); + + // Collect skills from different scopes + let scopes_to_check: Vec<(SkillScope, PathBuf)> = match scope { + Some(s) => vec![(s, get_scope_path(workspace_dir, s))], + None => vec![ + (SkillScope::Project, workspace_dir.join("skills")), + (SkillScope::Workspace, workspace_dir.join(".thulp/skills")), + ( + SkillScope::Global, + dirs::home_dir().unwrap_or_default().join(".thulp/skills"), + ), + ], + }; + + for (scope, path) in scopes_to_check { + if path.exists() { + if let Ok(entries) = std::fs::read_dir(&path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + let skill_md = entry_path.join("SKILL.md"); + let skill_yaml = entry_path.join("skill.yaml"); + if skill_md.exists() || skill_yaml.exists() { + let name = entry_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Simple tag filtering (would parse SKILL.md in full impl) + if tag.is_none() { + skills.push(json!({ + "name": name, + "scope": format!("{:?}", scope).to_lowercase(), + "path": entry_path.display().to_string() + })); + } + } + } + } + } + } + } + + if output.is_json() { + output.print_json(&json!({ "skills": skills })); + } else { + if skills.is_empty() { + output.print_text("No skills found."); + output.print_text(""); + output.print_text("Create skills in:"); + output.print_text(" - ./skills/ (project)"); + output.print_text(" - .thulp/skills/ (workspace)"); + output.print_text(" - ~/.thulp/skills/ (global)"); + } else { + output.print_text("Available skills:"); + output.print_text(""); + for skill in &skills { + let name = skill["name"].as_str().unwrap_or("?"); + let scope = skill["scope"].as_str().unwrap_or("?"); + output.print_text(&format!(" 📋 {} ({})", name, scope)); + } + } + } + + Ok(()) +} + +pub fn get_scope_path(workspace_dir: &Path, scope: SkillScope) -> PathBuf { + match scope { + SkillScope::Project => workspace_dir.join("skills"), + SkillScope::Workspace => workspace_dir.join(".thulp/skills"), + SkillScope::Global => dirs::home_dir().unwrap_or_default().join(".thulp/skills"), + } +} + +pub fn handle_skill_show( + workspace_dir: &Path, + name: &str, + output: &Output, +) -> Result<(), Box> { + // Search for skill in all scopes + let scopes = [ + workspace_dir.join("skills").join(name), + workspace_dir.join(".thulp/skills").join(name), + dirs::home_dir() + .unwrap_or_default() + .join(".thulp/skills") + .join(name), + ]; + + for skill_dir in scopes { + let skill_md = skill_dir.join("SKILL.md"); + if skill_md.exists() { + let content = std::fs::read_to_string(&skill_md)?; + if output.is_json() { + output.print_json(&json!({ + "name": name, + "path": skill_dir.display().to_string(), + "content": content + })); + } else { + output.print_text(&format!("Skill: {}", name)); + output.print_text(&format!("Path: {}", skill_dir.display())); + output.print_text(""); + output.print_text(&content); + } + return Ok(()); + } + + let skill_yaml = skill_dir.join("skill.yaml"); + if skill_yaml.exists() { + let content = std::fs::read_to_string(&skill_yaml)?; + if output.is_json() { + let parsed: serde_json::Value = serde_yaml::from_str(&content)?; + output.print_json(&json!({ + "name": name, + "path": skill_dir.display().to_string(), + "definition": parsed + })); + } else { + output.print_text(&format!("Skill: {}", name)); + output.print_text(&format!("Path: {}", skill_dir.display())); + output.print_text(""); + output.print_text(&content); + } + return Ok(()); + } + } + + Err(format!("Skill '{}' not found", name).into()) +} + +pub struct SkillRunOpts<'a> { + workspace_dir: &'a Path, + name: &'a str, + params: Vec, + json_params: Option, + timeout: u64, + dry_run: bool, + continue_on_error: bool, + output: &'a Output, +} + +pub async fn handle_skill_run( + opts: SkillRunOpts<'_>, +) -> Result<(), Box> { + let SkillRunOpts { + workspace_dir: _workspace_dir, + name, + params, + json_params, + timeout, + dry_run, + continue_on_error, + output, + } = opts; + // Parse parameters + let parameters: serde_json::Value = if let Some(json_str) = json_params { + serde_json::from_str(&json_str)? + } else { + let mut map = serde_json::Map::new(); + for param in params { + if let Some((key, value)) = param.split_once('=') { + let parsed_value = serde_json::from_str(value) + .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); + map.insert(key.to_string(), parsed_value); + } + } + serde_json::Value::Object(map) + }; + + if dry_run { + if output.is_json() { + output.print_json(&json!({ + "dry_run": true, + "skill": name, + "parameters": parameters, + "timeout": timeout, + "continue_on_error": continue_on_error, + "status": "would_execute" + })); + } else { + output.print_text("🔍 Dry run - would execute skill:"); + output.print_text(&format!(" Skill: {}", name)); + output.print_text(&format!(" Timeout: {}s per step", timeout)); + output.print_text(&format!(" Continue on error: {}", continue_on_error)); + output.print_text(&format!( + " Parameters: {}", + serde_json::to_string_pretty(¶meters)? + )); + } + return Ok(()); + } + + // Placeholder for actual skill execution + // In full implementation: load skill, create executor, run steps + if output.is_json() { + output.print_json(&json!({ + "status": "not_implemented", + "skill": name, + "message": "Skill execution requires loaded workspace and MCP connections" + })); + } else { + output.print_text(&format!("🚀 Executing skill: {}", name)); + output.print_text(""); + output.print_text("⚠️ Full skill execution requires:"); + output.print_text(" 1. Initialized workspace (thulp init)"); + output.print_text(" 2. Configured MCP servers (thulp config add-server)"); + output.print_text(" 3. Valid skill definition"); + } + + Ok(()) +} + +pub fn handle_skill_validate(file: &Path, output: &Output) -> Result<(), Box> { + if !file.exists() { + return Err(format!("File not found: {}", file.display()).into()); + } + + let content = std::fs::read_to_string(file)?; + let file_name = file.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + let result = if file_name.ends_with(".yaml") || file_name.ends_with(".yml") { + serde_yaml::from_str::(&content) + .map_err(|e| format!("YAML parse error: {}", e)) + } else if file_name == "SKILL.md" { + // Basic SKILL.md validation - check for YAML frontmatter + if let Some(after_start) = content.strip_prefix("---") { + let end = after_start.find("---"); + if let Some(end_idx) = end { + let frontmatter = &after_start[..end_idx]; + serde_yaml::from_str::(frontmatter) + .map_err(|e| format!("Frontmatter parse error: {}", e)) + } else { + Err("Missing closing --- for frontmatter".to_string()) + } + } else { + Err("SKILL.md should start with YAML frontmatter (---)".to_string()) + } + } else { + Err("Unknown file type. Expected .yaml, .yml, or SKILL.md".to_string()) + }; + + if output.is_json() { + match result { + Ok(parsed) => output.print_json(&json!({ + "valid": true, + "file": file.display().to_string(), + "parsed": parsed + })), + Err(e) => output.print_json(&json!({ + "valid": false, + "file": file.display().to_string(), + "error": e + })), + } + } else { + match result { + Ok(_) => { + output.print_text(&format!("✅ Valid: {}", file.display())); + } + Err(e) => { + output.print_text(&format!("❌ Invalid: {}", file.display())); + output.print_text(&format!(" Error: {}", e)); + } + } + } + + Ok(()) +} + +pub fn handle_skill_export( + _workspace_dir: &Path, + name: &str, + output_file: Option, + format: ExportFormat, + output: &Output, +) -> Result<(), Box> { + // For now, generate a placeholder shell script + let shell_script = format!( + r#"#!/bin/bash +# Skill: {} +# Exported by thulp + +set -euo pipefail + +echo "Executing skill: {}" + +# TODO: Add actual tool calls here +# This is a placeholder export + +echo "Skill execution complete" +"#, + name, name + ); + + let exported = match format { + ExportFormat::Shell => shell_script, + ExportFormat::Json => serde_json::to_string_pretty(&json!({ + "skill": name, + "steps": [], + "note": "Placeholder export" + }))?, + ExportFormat::Yaml => serde_yaml::to_string(&json!({ + "skill": name, + "steps": [], + "note": "Placeholder export" + }))?, + }; + + if let Some(path) = output_file { + std::fs::write(&path, &exported)?; + if !output.is_json() { + output.print_text(&format!("✅ Exported to: {}", path.display())); + } + } else { + println!("{}", exported); + } + + Ok(()) +} diff --git a/crates/thulp-cli/src/commands/tools.rs b/crates/thulp-cli/src/commands/tools.rs new file mode 100644 index 0000000..54f0720 --- /dev/null +++ b/crates/thulp-cli/src/commands/tools.rs @@ -0,0 +1,364 @@ +use clap::Subcommand; +use serde_json::json; +use std::path::Path; +use thulp_core::{Parameter, ParameterType, ToolCall, ToolDefinition}; +use crate::output::Output; + +#[derive(Subcommand, Debug)] +pub enum ToolCommands { + /// List all available tools + List, + /// Show details of a specific tool + Show { + #[arg(value_name = "NAME")] + name: String, + }, + /// Validate tool arguments + Validate { + #[arg(value_name = "NAME")] + name: String, + #[arg(value_name = "ARGUMENTS")] + arguments: Option, + }, +} + +pub async fn handle_run( + tool: &str, + args: Vec, + json_args: Option, + timeout: u64, + dry_run: bool, + output: &Output, +) -> Result<(), Box> { + // Parse arguments + let arguments: serde_json::Value = if let Some(json_str) = json_args { + serde_json::from_str(&json_str)? + } else { + let mut map = serde_json::Map::new(); + for arg in args { + if let Some((key, value)) = arg.split_once('=') { + // Try to parse as JSON value, fallback to string + let parsed_value = serde_json::from_str(value) + .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); + map.insert(key.to_string(), parsed_value); + } else { + return Err(format!("Invalid argument format: '{}'. Use key=value", arg).into()); + } + } + serde_json::Value::Object(map) + }; + + // Parse tool name (format: server.tool or just tool) + let (server_name, tool_name) = if let Some((server, tool)) = tool.split_once('.') { + (Some(server.to_string()), tool.to_string()) + } else { + (None, tool.to_string()) + }; + + if dry_run { + if output.is_json() { + output.print_json(&json!({ + "dry_run": true, + "tool": tool_name, + "server": server_name, + "arguments": arguments, + "timeout": timeout + })); + } else { + output.print_text("🔍 Dry run - would execute:"); + output.print_text(&format!(" Tool: {}", tool_name)); + if let Some(ref server) = server_name { + output.print_text(&format!(" Server: {}", server)); + } + output.print_text(&format!(" Timeout: {}s", timeout)); + output.print_text(&format!( + " Arguments: {}", + serde_json::to_string_pretty(&arguments)? + )); + } + return Ok(()); + } + + // For now, we'll show a placeholder since MCP execution requires server config + // In a full implementation, this would: + // 1. Load workspace config + // 2. Find the appropriate server + // 3. Connect via MCP + // 4. Execute the tool + // 5. Return the result + + if output.is_json() { + output.print_json(&json!({ + "status": "not_implemented", + "tool": tool_name, + "server": server_name, + "arguments": arguments, + "message": "Tool execution requires configured MCP servers. Use 'thulp config add-server' first." + })); + } else { + output.print_text(&format!("🔧 Executing tool: {}", tool_name)); + if let Some(ref server) = server_name { + output.print_text(&format!(" Server: {}", server)); + } + output.print_text(&format!( + " Arguments: {}", + serde_json::to_string(&arguments)? + )); + output.print_text(""); + output.print_text("⚠️ Tool execution requires configured MCP servers."); + output.print_text(" Use 'thulp config add-server' to add a server first."); + } + + Ok(()) +} + +pub async fn handle_tool_commands( + command: ToolCommands, + output: &Output, +) -> Result<(), Box> { + match command { + ToolCommands::List => { + let read_file = create_read_file_tool(); + let api_call = create_api_call_tool(); + let tools = [&read_file, &api_call]; + + if output.is_json() { + output.print_json(&json!({ + "tools": tools.iter().map(|t| json!({ + "name": t.name, + "description": t.description, + "parameters": t.parameters.iter().map(|p| json!({ + "name": p.name, + "type": p.param_type.as_str(), + "required": p.required, + "description": p.description, + })).collect::>() + })).collect::>() + })); + } else { + output.print_text("Available tool definitions:"); + output.print_text(&format!("\n📁 {}", read_file.name)); + output.print_text(&format!(" {}", read_file.description)); + output.print_text(&format!("\n🌐 {}", api_call.name)); + output.print_text(&format!(" {}", api_call.description)); + } + } + ToolCommands::Show { name } => { + let tool = match name.as_str() { + "read_file" => create_read_file_tool(), + "api_call" => create_api_call_tool(), + _ => { + return Err(format!("Tool '{}' not found", name).into()); + } + }; + + if output.is_json() { + output.print_json(&json!({ + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters.iter().map(|p| json!({ + "name": p.name, + "type": p.param_type.as_str(), + "required": p.required, + "description": p.description, + "default": p.default, + })).collect::>() + })); + } else { + output.print_text(&format!("Tool: {}", tool.name)); + output.print_text(&format!("Description: {}", tool.description)); + output.print_text("Parameters:"); + for param in &tool.parameters { + output.print_text(&format!( + " {}: {} ({})", + param.name, + param.param_type.as_str(), + if param.required { + "required" + } else { + "optional" + } + )); + if !param.description.is_empty() { + output.print_text(&format!(" Description: {}", param.description)); + } + if let Some(ref default) = param.default { + output.print_text(&format!(" Default: {}", default)); + } + } + } + } + ToolCommands::Validate { name, arguments } => { + let arguments_json = match arguments { + Some(args) => serde_json::from_str(&args)?, + None => json!({}), + }; + + let tool = match name.as_str() { + "read_file" => create_read_file_tool(), + "api_call" => create_api_call_tool(), + _ => { + return Err(format!("Tool '{}' not found", name).into()); + } + }; + + let result = tool.validate_args(&arguments_json); + if output.is_json() { + output.print_json(&json!({ + "valid": result.is_ok(), + "error": result.err().map(|e| e.to_string()), + })); + } else { + match result { + Ok(()) => output.print_text("✅ Arguments are valid"), + Err(e) => output.print_text(&format!("❌ Validation error: {}", e)), + } + } + } + } + Ok(()) +} + + +pub async fn run_demo(output: &Output) -> Result<(), Box> { + if output.is_json() { + let read_file_tool = create_read_file_tool(); + let valid_args = json!({ "path": "/etc/hosts" }); + let invalid_args = json!({ "path": 123 }); + + let tool_call = ToolCall::builder("read_file") + .arg_str("path", "/tmp/example.txt") + .arg_str("encoding", "utf-8") + .build(); + + output.print_json(&json!({ + "demo": "core_functionality", + "tool_definition": { + "name": read_file_tool.name, + "valid_args_test": read_file_tool.validate_args(&valid_args).is_ok(), + "invalid_args_test": read_file_tool.validate_args(&invalid_args).is_err(), + }, + "tool_call": { + "tool": tool_call.tool, + "arguments": tool_call.arguments, + } + })); + } else { + output.print_text("🎯 Thulp Demo - Core Functionality"); + output.print_text("==================================\n"); + + output.print_text("1. Tool Definition & Validation"); + output.print_text("--------------------------------"); + + let read_file_tool = create_read_file_tool(); + output.print_text(&format!("Created tool: {}", read_file_tool.name)); + + let valid_args = json!({ "path": "/etc/hosts" }); + let invalid_args = json!({ "path": 123 }); + + match read_file_tool.validate_args(&valid_args) { + Ok(()) => output.print_text("✅ Valid arguments accepted"), + Err(e) => output.print_text(&format!("❌ Unexpected validation error: {}", e)), + } + + match read_file_tool.validate_args(&invalid_args) { + Ok(()) => output.print_text("❌ Invalid arguments should have been rejected"), + Err(e) => output.print_text(&format!("✅ Invalid arguments correctly rejected: {}", e)), + } + + #[cfg(feature = "mcp")] + { + output.print_text("\n2. MCP Client"); + output.print_text("--------------"); + + let transport = McpTransport::new(); + let client = McpClient::new(transport); + + output.print_text("Client created successfully"); + output.print_text(&format!(" Connected: {}", client.is_connected())); + output.print_text(&format!(" Session ID: {}", client.session_id())); + } + + output.print_text("\n3. Tool Call Creation"); + output.print_text("----------------------"); + + let tool_call = ToolCall::builder("read_file") + .arg_str("path", "/tmp/example.txt") + .arg_str("encoding", "utf-8") + .build(); + + output.print_text(&format!("Created tool call: {}", tool_call.tool)); + output.print_text(&format!( + "Arguments: {}", + serde_json::to_string_pretty(&tool_call.arguments)? + )); + + output.print_text("\n🎉 Demo complete!"); + } + Ok(()) +} + +pub fn validate_file(file: &Path, output: &Output) -> Result<(), Box> { + if !file.exists() { + return Err(format!("File not found: {}", file.display()).into()); + } + + if output.is_json() { + output.print_json(&json!({ + "file": file.display().to_string(), + "valid": true, + })); + } else { + output.print_text(&format!("Validating file: {}", file.display())); + output.print_text("✅ File validation complete"); + } + + Ok(()) +} + +pub fn create_read_file_tool() -> ToolDefinition { + ToolDefinition::builder("read_file") + .description("Read contents of a file from the filesystem") + .parameter( + Parameter::builder("path") + .param_type(ParameterType::String) + .required(true) + .description("Path to the file to read") + .build(), + ) + .parameter( + Parameter::builder("encoding") + .param_type(ParameterType::String) + .description("File encoding (utf-8, ascii, etc.)") + .default(serde_json::Value::String("utf-8".to_string())) + .build(), + ) + .build() +} + + +pub fn create_api_call_tool() -> ToolDefinition { + ToolDefinition::builder("api_call") + .description("Make HTTP API requests to external services") + .parameter( + Parameter::builder("url") + .param_type(ParameterType::String) + .required(true) + .description("URL to make the API request to") + .build(), + ) + .parameter( + Parameter::builder("method") + .param_type(ParameterType::String) + .description("HTTP method (GET, POST, PUT, DELETE)") + .default(serde_json::Value::String("GET".to_string())) + .build(), + ) + .parameter( + Parameter::builder("headers") + .param_type(ParameterType::Object) + .description("HTTP headers as key-value pairs") + .build(), + ) + .build() +} diff --git a/crates/thulp-cli/src/main.rs b/crates/thulp-cli/src/main.rs index d80582d..7b5cc70 100644 --- a/crates/thulp-cli/src/main.rs +++ b/crates/thulp-cli/src/main.rs @@ -9,17 +9,16 @@ use thulp_core::{Parameter, ParameterType, ToolCall, ToolDefinition}; #[cfg(feature = "mcp")] use thulp_mcp::{McpClient, McpTransport}; -/// Output format for CLI commands -#[derive(Debug, Clone, Copy, Default, ValueEnum)] -enum OutputFormat { - /// Human-readable text output - #[default] - Text, - /// JSON output for programmatic consumption - Json, - /// Compact JSON (no pretty-printing) - JsonCompact, -} +mod output; +mod commands; +use output::{Output, OutputFormat}; +use commands::config::{ConfigCommands, ServerType}; +use commands::skill::{SkillCommands, SkillScope, ExportFormat}; +use commands::tools::ToolCommands; +use commands::convert::ConvertCommands; + +#[cfg(feature = "mcp")] +use commands::mcp::McpCommands; #[derive(Parser, Debug)] #[command(name = "thulp")] @@ -129,246 +128,8 @@ enum Commands { }, } -#[derive(Subcommand, Debug)] -enum SkillCommands { - /// List available skills - List { - /// Filter by tag - #[arg(short, long)] - tag: Option, - - /// Show skills from specific scope - #[arg(short, long, value_enum)] - scope: Option, - }, - - /// Show skill details - Show { - /// Skill name - #[arg(value_name = "NAME")] - name: String, - }, - - /// Execute a skill workflow - Run { - /// Skill name - #[arg(value_name = "NAME")] - name: String, - - /// Input parameters as key=value pairs - #[arg(value_name = "PARAMS")] - params: Vec, - - /// Parameters as JSON string - #[arg(short, long)] - json: Option, - - /// Timeout in seconds (per step) - #[arg(short, long, default_value = "60")] - timeout: u64, - - /// Dry run (show execution plan without running) - #[arg(long)] - dry_run: bool, - - /// Continue on step failure - #[arg(long)] - continue_on_error: bool, - }, - - /// Validate a skill definition - Validate { - /// Path to skill file (SKILL.md or skill.yaml) - #[arg(value_name = "FILE")] - file: PathBuf, - }, - - /// Export skill as shell script - Export { - /// Skill name - #[arg(value_name = "NAME")] - name: String, - - /// Output file (default: stdout) - #[arg(long, value_name = "FILE")] - out: Option, - - /// Export format - #[arg(short, long, value_enum, default_value = "shell")] - format: ExportFormat, - }, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum SkillScope { - /// Global skills (~/.thulp/skills) - Global, - /// Workspace skills (.thulp/skills) - Workspace, - /// Project skills (./skills) - Project, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum ExportFormat { - /// Shell script - Shell, - /// JSON workflow - Json, - /// YAML workflow - Yaml, -} - -#[derive(Subcommand, Debug)] -enum ConfigCommands { - /// Show current configuration - Show, - - /// Get a configuration value - Get { - /// Configuration key (e.g., servers.github.url) - #[arg(value_name = "KEY")] - key: String, - }, - - /// Set a configuration value - Set { - /// Configuration key - #[arg(value_name = "KEY")] - key: String, - - /// Configuration value - #[arg(value_name = "VALUE")] - value: String, - }, - - /// Add an MCP server configuration - AddServer { - /// Server name - #[arg(value_name = "NAME")] - name: String, - - /// Server type (stdio, http) - #[arg(short, long, value_enum)] - r#type: ServerType, - - /// Command (for stdio) or URL (for http) - #[arg(value_name = "TARGET")] - target: String, - - /// Command arguments (for stdio) - #[arg(short, long)] - args: Vec, - }, - - /// List configured servers - Servers, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum ServerType { - /// STDIO transport (local command) - Stdio, - /// HTTP/SSE transport (remote server) - Http, -} - -#[derive(Subcommand, Debug)] -enum ToolCommands { - /// List all available tools - List, - /// Show details of a specific tool - Show { - #[arg(value_name = "NAME")] - name: String, - }, - /// Validate tool arguments - Validate { - #[arg(value_name = "NAME")] - name: String, - #[arg(value_name = "ARGUMENTS")] - arguments: Option, - }, -} - -#[cfg(feature = "mcp")] -#[derive(Subcommand, Debug)] -enum McpCommands { - /// Connect to an MCP server via HTTP - ConnectHttp { - #[arg(value_name = "NAME")] - name: String, - #[arg(value_name = "URL")] - url: String, - }, - /// Connect to an MCP server via STDIO - ConnectStdio { - #[arg(value_name = "NAME")] - name: String, - #[arg(value_name = "COMMAND")] - command: String, - #[arg(value_name = "ARGS")] - args: Vec, - }, - /// List tools from connected MCP server - List, - /// Call a tool on the MCP server - Call { - #[arg(value_name = "NAME")] - name: String, - #[arg(value_name = "ARGUMENTS")] - arguments: Option, - }, - /// Show connection status - Status, -} - -#[derive(Subcommand, Debug)] -enum ConvertCommands { - /// Convert OpenAPI spec to tool definitions - OpenApi { - #[arg(value_name = "FILE")] - file: PathBuf, - /// Output file for generated config - #[arg(long, value_name = "FILE")] - out: Option, - }, - /// Show conversion examples - Examples, -} - -/// Output helper for formatted output -struct Output { - format: OutputFormat, -} - -impl Output { - fn new(format: OutputFormat) -> Self { - Self { format } - } - fn print_text(&self, text: &str) { - if matches!(self.format, OutputFormat::Text) { - println!("{}", text); - } - } - fn print_json(&self, data: &T) { - match self.format { - OutputFormat::Text => {} - OutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(data).unwrap()); - } - OutputFormat::JsonCompact => { - println!("{}", serde_json::to_string(data).unwrap()); - } - } - } - - fn is_json(&self) -> bool { - matches!(self.format, OutputFormat::Json | OutputFormat::JsonCompact) - } -} #[tokio::main] async fn main() -> Result<(), Box> { @@ -378,7 +139,7 @@ async fn main() -> Result<(), Box> { match cli.command { Commands::Init { dir, name, force } => { - handle_init(dir.unwrap_or(workspace_dir), name, force, &output)? + commands::config::handle_init(dir.unwrap_or(workspace_dir), name, force, &output)? } Commands::Run { tool, @@ -386,731 +147,24 @@ async fn main() -> Result<(), Box> { json, timeout, dry_run, - } => handle_run(&tool, args, json, timeout, dry_run, &output).await?, + } => commands::tools::handle_run(&tool, args, json, timeout, dry_run, &output).await?, Commands::Skill { action } => { - handle_skill_commands(action, &workspace_dir, &output).await? + commands::skill::handle_skill_commands(action, &workspace_dir, &output).await? } - Commands::Tools { action } => handle_tool_commands(action, &output).await?, + Commands::Tools { action } => commands::tools::handle_tool_commands(action, &output).await?, #[cfg(feature = "mcp")] - Commands::Mcp { action } => handle_mcp_commands(action, &output).await?, - Commands::Convert { action } => handle_convert_commands(action, &output)?, - Commands::Config { action } => handle_config_commands(action, &workspace_dir, &output)?, - Commands::Demo => run_demo(&output).await?, - Commands::Validate { file } => validate_file(&file, &output)?, + Commands::Mcp { action } => commands::mcp::handle_mcp_commands(action, &output).await?, + Commands::Convert { action } => commands::convert::handle_convert_commands(action, &output)?, + Commands::Config { action } => commands::config::handle_config_commands(action, &workspace_dir, &output)?, + Commands::Demo => commands::tools::run_demo(&output).await?, + Commands::Validate { file } => commands::tools::validate_file(&file, &output)?, Commands::Completions { shell, dir } => generate_completions(shell, dir)?, } Ok(()) } -// ==================== New Command Handlers (Phase 4) ==================== - -/// Handle `thulp init` command -fn handle_init( - dir: PathBuf, - name: Option, - force: bool, - output: &Output, -) -> Result<(), Box> { - let thulp_dir = dir.join(".thulp"); - - if thulp_dir.exists() && !force { - if output.is_json() { - output.print_json(&json!({ - "error": "already_initialized", - "path": thulp_dir.display().to_string(), - "hint": "Use --force to reinitialize" - })); - } else { - output.print_text(&format!( - "❌ Workspace already initialized at {}", - thulp_dir.display() - )); - output.print_text(" Use --force to reinitialize"); - } - return Ok(()); - } - - // Create directory structure - std::fs::create_dir_all(&thulp_dir)?; - std::fs::create_dir_all(thulp_dir.join("skills"))?; - std::fs::create_dir_all(thulp_dir.join("sessions"))?; - std::fs::create_dir_all(thulp_dir.join("cache"))?; - - // Determine workspace name - let workspace_name = name.unwrap_or_else(|| { - dir.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string() - }); - - // Create config.yaml - let config = json!({ - "name": workspace_name, - "version": "1.0", - "servers": {}, - "settings": { - "default_timeout": 30, - "max_retries": 3 - } - }); - - let config_path = thulp_dir.join("config.yaml"); - let config_yaml = serde_yaml::to_string(&config)?; - std::fs::write(&config_path, config_yaml)?; - - // Create .gitignore - let gitignore = "sessions/\ncache/\n*.log\n"; - std::fs::write(thulp_dir.join(".gitignore"), gitignore)?; - - if output.is_json() { - output.print_json(&json!({ - "status": "initialized", - "path": thulp_dir.display().to_string(), - "name": workspace_name, - "created": [ - "config.yaml", - "skills/", - "sessions/", - "cache/", - ".gitignore" - ] - })); - } else { - output.print_text(&format!( - "✅ Initialized thulp workspace: {}", - workspace_name - )); - output.print_text(&format!(" Location: {}", thulp_dir.display())); - output.print_text(""); - output.print_text(" Created:"); - output.print_text(" ├── config.yaml (workspace configuration)"); - output.print_text(" ├── skills/ (skill definitions)"); - output.print_text(" ├── sessions/ (session storage)"); - output.print_text(" ├── cache/ (temporary files)"); - output.print_text(" └── .gitignore"); - output.print_text(""); - output.print_text(" Next steps:"); - output.print_text( - " 1. Add MCP servers: thulp config add-server --type stdio ", - ); - output.print_text(" 2. List tools: thulp tools list"); - output.print_text(" 3. Run a tool: thulp run key=value"); - } - - Ok(()) -} - -/// Handle `thulp run` command - execute a tool directly -async fn handle_run( - tool: &str, - args: Vec, - json_args: Option, - timeout: u64, - dry_run: bool, - output: &Output, -) -> Result<(), Box> { - // Parse arguments - let arguments: serde_json::Value = if let Some(json_str) = json_args { - serde_json::from_str(&json_str)? - } else { - let mut map = serde_json::Map::new(); - for arg in args { - if let Some((key, value)) = arg.split_once('=') { - // Try to parse as JSON value, fallback to string - let parsed_value = serde_json::from_str(value) - .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); - map.insert(key.to_string(), parsed_value); - } else { - return Err(format!("Invalid argument format: '{}'. Use key=value", arg).into()); - } - } - serde_json::Value::Object(map) - }; - - // Parse tool name (format: server.tool or just tool) - let (server_name, tool_name) = if let Some((server, tool)) = tool.split_once('.') { - (Some(server.to_string()), tool.to_string()) - } else { - (None, tool.to_string()) - }; - - if dry_run { - if output.is_json() { - output.print_json(&json!({ - "dry_run": true, - "tool": tool_name, - "server": server_name, - "arguments": arguments, - "timeout": timeout - })); - } else { - output.print_text("🔍 Dry run - would execute:"); - output.print_text(&format!(" Tool: {}", tool_name)); - if let Some(ref server) = server_name { - output.print_text(&format!(" Server: {}", server)); - } - output.print_text(&format!(" Timeout: {}s", timeout)); - output.print_text(&format!( - " Arguments: {}", - serde_json::to_string_pretty(&arguments)? - )); - } - return Ok(()); - } - - // For now, we'll show a placeholder since MCP execution requires server config - // In a full implementation, this would: - // 1. Load workspace config - // 2. Find the appropriate server - // 3. Connect via MCP - // 4. Execute the tool - // 5. Return the result - - if output.is_json() { - output.print_json(&json!({ - "status": "not_implemented", - "tool": tool_name, - "server": server_name, - "arguments": arguments, - "message": "Tool execution requires configured MCP servers. Use 'thulp config add-server' first." - })); - } else { - output.print_text(&format!("🔧 Executing tool: {}", tool_name)); - if let Some(ref server) = server_name { - output.print_text(&format!(" Server: {}", server)); - } - output.print_text(&format!( - " Arguments: {}", - serde_json::to_string(&arguments)? - )); - output.print_text(""); - output.print_text("⚠️ Tool execution requires configured MCP servers."); - output.print_text(" Use 'thulp config add-server' to add a server first."); - } - - Ok(()) -} - -/// Handle `thulp skill` subcommands -async fn handle_skill_commands( - command: SkillCommands, - workspace_dir: &Path, - output: &Output, -) -> Result<(), Box> { - match command { - SkillCommands::List { tag, scope } => { - handle_skill_list(workspace_dir, tag, scope, output)?; - } - SkillCommands::Show { name } => { - handle_skill_show(workspace_dir, &name, output)?; - } - SkillCommands::Run { - name, - params, - json, - timeout, - dry_run, - continue_on_error, - } => { - handle_skill_run( - workspace_dir, - &name, - params, - json, - timeout, - dry_run, - continue_on_error, - output, - ) - .await?; - } - SkillCommands::Validate { file } => { - handle_skill_validate(&file, output)?; - } - SkillCommands::Export { - name, - out: output_file, - format, - } => { - handle_skill_export(workspace_dir, &name, output_file, format, output)?; - } - } - Ok(()) -} - -fn handle_skill_list( - workspace_dir: &Path, - tag: Option, - scope: Option, - output: &Output, -) -> Result<(), Box> { - let mut skills = Vec::new(); - - // Collect skills from different scopes - let scopes_to_check: Vec<(SkillScope, PathBuf)> = match scope { - Some(s) => vec![(s, get_scope_path(workspace_dir, s))], - None => vec![ - (SkillScope::Project, workspace_dir.join("skills")), - (SkillScope::Workspace, workspace_dir.join(".thulp/skills")), - ( - SkillScope::Global, - dirs::home_dir().unwrap_or_default().join(".thulp/skills"), - ), - ], - }; - - for (scope, path) in scopes_to_check { - if path.exists() { - if let Ok(entries) = std::fs::read_dir(&path) { - for entry in entries.flatten() { - let entry_path = entry.path(); - if entry_path.is_dir() { - let skill_md = entry_path.join("SKILL.md"); - let skill_yaml = entry_path.join("skill.yaml"); - if skill_md.exists() || skill_yaml.exists() { - let name = entry_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - // Simple tag filtering (would parse SKILL.md in full impl) - if tag.is_none() { - skills.push(json!({ - "name": name, - "scope": format!("{:?}", scope).to_lowercase(), - "path": entry_path.display().to_string() - })); - } - } - } - } - } - } - } - - if output.is_json() { - output.print_json(&json!({ "skills": skills })); - } else { - if skills.is_empty() { - output.print_text("No skills found."); - output.print_text(""); - output.print_text("Create skills in:"); - output.print_text(" - ./skills/ (project)"); - output.print_text(" - .thulp/skills/ (workspace)"); - output.print_text(" - ~/.thulp/skills/ (global)"); - } else { - output.print_text("Available skills:"); - output.print_text(""); - for skill in &skills { - let name = skill["name"].as_str().unwrap_or("?"); - let scope = skill["scope"].as_str().unwrap_or("?"); - output.print_text(&format!(" 📋 {} ({})", name, scope)); - } - } - } - - Ok(()) -} - -fn get_scope_path(workspace_dir: &Path, scope: SkillScope) -> PathBuf { - match scope { - SkillScope::Project => workspace_dir.join("skills"), - SkillScope::Workspace => workspace_dir.join(".thulp/skills"), - SkillScope::Global => dirs::home_dir().unwrap_or_default().join(".thulp/skills"), - } -} - -fn handle_skill_show( - workspace_dir: &Path, - name: &str, - output: &Output, -) -> Result<(), Box> { - // Search for skill in all scopes - let scopes = [ - workspace_dir.join("skills").join(name), - workspace_dir.join(".thulp/skills").join(name), - dirs::home_dir() - .unwrap_or_default() - .join(".thulp/skills") - .join(name), - ]; - - for skill_dir in scopes { - let skill_md = skill_dir.join("SKILL.md"); - if skill_md.exists() { - let content = std::fs::read_to_string(&skill_md)?; - if output.is_json() { - output.print_json(&json!({ - "name": name, - "path": skill_dir.display().to_string(), - "content": content - })); - } else { - output.print_text(&format!("Skill: {}", name)); - output.print_text(&format!("Path: {}", skill_dir.display())); - output.print_text(""); - output.print_text(&content); - } - return Ok(()); - } - - let skill_yaml = skill_dir.join("skill.yaml"); - if skill_yaml.exists() { - let content = std::fs::read_to_string(&skill_yaml)?; - if output.is_json() { - let parsed: serde_json::Value = serde_yaml::from_str(&content)?; - output.print_json(&json!({ - "name": name, - "path": skill_dir.display().to_string(), - "definition": parsed - })); - } else { - output.print_text(&format!("Skill: {}", name)); - output.print_text(&format!("Path: {}", skill_dir.display())); - output.print_text(""); - output.print_text(&content); - } - return Ok(()); - } - } - - Err(format!("Skill '{}' not found", name).into()) -} - -async fn handle_skill_run( - _workspace_dir: &Path, - name: &str, - params: Vec, - json_params: Option, - timeout: u64, - dry_run: bool, - continue_on_error: bool, - output: &Output, -) -> Result<(), Box> { - // Parse parameters - let parameters: serde_json::Value = if let Some(json_str) = json_params { - serde_json::from_str(&json_str)? - } else { - let mut map = serde_json::Map::new(); - for param in params { - if let Some((key, value)) = param.split_once('=') { - let parsed_value = serde_json::from_str(value) - .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); - map.insert(key.to_string(), parsed_value); - } - } - serde_json::Value::Object(map) - }; - - if dry_run { - if output.is_json() { - output.print_json(&json!({ - "dry_run": true, - "skill": name, - "parameters": parameters, - "timeout": timeout, - "continue_on_error": continue_on_error, - "status": "would_execute" - })); - } else { - output.print_text("🔍 Dry run - would execute skill:"); - output.print_text(&format!(" Skill: {}", name)); - output.print_text(&format!(" Timeout: {}s per step", timeout)); - output.print_text(&format!(" Continue on error: {}", continue_on_error)); - output.print_text(&format!( - " Parameters: {}", - serde_json::to_string_pretty(¶meters)? - )); - } - return Ok(()); - } - - // Placeholder for actual skill execution - // In full implementation: load skill, create executor, run steps - if output.is_json() { - output.print_json(&json!({ - "status": "not_implemented", - "skill": name, - "message": "Skill execution requires loaded workspace and MCP connections" - })); - } else { - output.print_text(&format!("🚀 Executing skill: {}", name)); - output.print_text(""); - output.print_text("⚠️ Full skill execution requires:"); - output.print_text(" 1. Initialized workspace (thulp init)"); - output.print_text(" 2. Configured MCP servers (thulp config add-server)"); - output.print_text(" 3. Valid skill definition"); - } - - Ok(()) -} - -fn handle_skill_validate(file: &Path, output: &Output) -> Result<(), Box> { - if !file.exists() { - return Err(format!("File not found: {}", file.display()).into()); - } - - let content = std::fs::read_to_string(file)?; - let file_name = file.file_name().and_then(|n| n.to_str()).unwrap_or(""); - - let result = if file_name.ends_with(".yaml") || file_name.ends_with(".yml") { - serde_yaml::from_str::(&content) - .map_err(|e| format!("YAML parse error: {}", e)) - } else if file_name == "SKILL.md" { - // Basic SKILL.md validation - check for YAML frontmatter - if content.starts_with("---") { - let end = content[3..].find("---").map(|i| i + 3); - if let Some(end_idx) = end { - let frontmatter = &content[3..end_idx]; - serde_yaml::from_str::(frontmatter) - .map_err(|e| format!("Frontmatter parse error: {}", e)) - } else { - Err("Missing closing --- for frontmatter".to_string()) - } - } else { - Err("SKILL.md should start with YAML frontmatter (---)".to_string()) - } - } else { - Err("Unknown file type. Expected .yaml, .yml, or SKILL.md".to_string()) - }; - - if output.is_json() { - match result { - Ok(parsed) => output.print_json(&json!({ - "valid": true, - "file": file.display().to_string(), - "parsed": parsed - })), - Err(e) => output.print_json(&json!({ - "valid": false, - "file": file.display().to_string(), - "error": e - })), - } - } else { - match result { - Ok(_) => { - output.print_text(&format!("✅ Valid: {}", file.display())); - } - Err(e) => { - output.print_text(&format!("❌ Invalid: {}", file.display())); - output.print_text(&format!(" Error: {}", e)); - } - } - } - - Ok(()) -} - -fn handle_skill_export( - _workspace_dir: &Path, - name: &str, - output_file: Option, - format: ExportFormat, - output: &Output, -) -> Result<(), Box> { - // For now, generate a placeholder shell script - let shell_script = format!( - r#"#!/bin/bash -# Skill: {} -# Exported by thulp - -set -euo pipefail - -echo "Executing skill: {}" - -# TODO: Add actual tool calls here -# This is a placeholder export - -echo "Skill execution complete" -"#, - name, name - ); - - let exported = match format { - ExportFormat::Shell => shell_script, - ExportFormat::Json => serde_json::to_string_pretty(&json!({ - "skill": name, - "steps": [], - "note": "Placeholder export" - }))?, - ExportFormat::Yaml => serde_yaml::to_string(&json!({ - "skill": name, - "steps": [], - "note": "Placeholder export" - }))?, - }; - - if let Some(path) = output_file { - std::fs::write(&path, &exported)?; - if !output.is_json() { - output.print_text(&format!("✅ Exported to: {}", path.display())); - } - } else { - println!("{}", exported); - } - - Ok(()) -} - -/// Handle `thulp config` subcommands -fn handle_config_commands( - command: ConfigCommands, - workspace_dir: &Path, - output: &Output, -) -> Result<(), Box> { - let config_path = workspace_dir.join(".thulp/config.yaml"); - - match command { - ConfigCommands::Show => { - if config_path.exists() { - let content = std::fs::read_to_string(&config_path)?; - if output.is_json() { - let parsed: serde_json::Value = serde_yaml::from_str(&content)?; - output.print_json(&parsed); - } else { - output.print_text(&content); - } - } else { - if output.is_json() { - output.print_json(&json!({"error": "not_initialized"})); - } else { - output.print_text("❌ No workspace found. Run 'thulp init' first."); - } - } - } - ConfigCommands::Get { key } => { - if config_path.exists() { - let content = std::fs::read_to_string(&config_path)?; - let config: serde_json::Value = serde_yaml::from_str(&content)?; - - // Navigate to the key - let parts: Vec<&str> = key.split('.').collect(); - let mut current = &config; - for part in &parts { - if let Some(next) = current.get(part) { - current = next; - } else { - return Err(format!("Key not found: {}", key).into()); - } - } - - if output.is_json() { - output.print_json(current); - } else { - output.print_text(&serde_json::to_string_pretty(current)?); - } - } else { - return Err("No workspace found. Run 'thulp init' first.".into()); - } - } - ConfigCommands::Set { key, value } => { - if !config_path.exists() { - return Err("No workspace found. Run 'thulp init' first.".into()); - } - - let content = std::fs::read_to_string(&config_path)?; - let mut config: serde_json::Value = serde_yaml::from_str(&content)?; - - // Parse value as JSON or use as string - let parsed_value: serde_json::Value = serde_json::from_str(&value) - .unwrap_or_else(|_| serde_json::Value::String(value.clone())); - - // Set the key (simple single-level for now) - if let Some(obj) = config.as_object_mut() { - obj.insert(key.clone(), parsed_value); - } - - let updated = serde_yaml::to_string(&config)?; - std::fs::write(&config_path, updated)?; - - if output.is_json() { - output.print_json(&json!({"status": "updated", "key": key})); - } else { - output.print_text(&format!("✅ Set {} = {}", key, value)); - } - } - ConfigCommands::AddServer { - name, - r#type, - target, - args, - } => { - if !config_path.exists() { - return Err("No workspace found. Run 'thulp init' first.".into()); - } - - let content = std::fs::read_to_string(&config_path)?; - let mut config: serde_json::Value = serde_yaml::from_str(&content)?; - - let server_config = match r#type { - ServerType::Stdio => json!({ - "type": "stdio", - "command": target, - "args": args - }), - ServerType::Http => json!({ - "type": "http", - "url": target - }), - }; - - if let Some(obj) = config.as_object_mut() { - let servers = obj.entry("servers").or_insert_with(|| json!({})); - if let Some(servers_obj) = servers.as_object_mut() { - servers_obj.insert(name.clone(), server_config); - } - } - - let updated = serde_yaml::to_string(&config)?; - std::fs::write(&config_path, updated)?; - - if output.is_json() { - output.print_json(&json!({"status": "added", "server": name})); - } else { - output.print_text(&format!("✅ Added server: {}", name)); - } - } - ConfigCommands::Servers => { - if config_path.exists() { - let content = std::fs::read_to_string(&config_path)?; - let config: serde_json::Value = serde_yaml::from_str(&content)?; - - let servers = config.get("servers").cloned().unwrap_or(json!({})); - - if output.is_json() { - output.print_json(&servers); - } else { - if let Some(obj) = servers.as_object() { - if obj.is_empty() { - output.print_text("No servers configured."); - } else { - output.print_text("Configured servers:"); - for (name, config) in obj { - let server_type = config - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("unknown"); - output.print_text(&format!(" 🔌 {} ({})", name, server_type)); - } - } - } - } - } else { - if output.is_json() { - output.print_json(&json!({"error": "not_initialized"})); - } else { - output.print_text("❌ No workspace found. Run 'thulp init' first."); - } - } - } - } - Ok(()) -} fn generate_completions( shell: Shell, @@ -1140,511 +194,7 @@ fn generate_completions( Ok(()) } -async fn handle_tool_commands( - command: ToolCommands, - output: &Output, -) -> Result<(), Box> { - match command { - ToolCommands::List => { - let read_file = create_read_file_tool(); - let api_call = create_api_call_tool(); - let tools = [&read_file, &api_call]; - - if output.is_json() { - output.print_json(&json!({ - "tools": tools.iter().map(|t| json!({ - "name": t.name, - "description": t.description, - "parameters": t.parameters.iter().map(|p| json!({ - "name": p.name, - "type": p.param_type.as_str(), - "required": p.required, - "description": p.description, - })).collect::>() - })).collect::>() - })); - } else { - output.print_text("Available tool definitions:"); - output.print_text(&format!("\n📁 {}", read_file.name)); - output.print_text(&format!(" {}", read_file.description)); - output.print_text(&format!("\n🌐 {}", api_call.name)); - output.print_text(&format!(" {}", api_call.description)); - } - } - ToolCommands::Show { name } => { - let tool = match name.as_str() { - "read_file" => create_read_file_tool(), - "api_call" => create_api_call_tool(), - _ => { - return Err(format!("Tool '{}' not found", name).into()); - } - }; - - if output.is_json() { - output.print_json(&json!({ - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters.iter().map(|p| json!({ - "name": p.name, - "type": p.param_type.as_str(), - "required": p.required, - "description": p.description, - "default": p.default, - })).collect::>() - })); - } else { - output.print_text(&format!("Tool: {}", tool.name)); - output.print_text(&format!("Description: {}", tool.description)); - output.print_text("Parameters:"); - for param in &tool.parameters { - output.print_text(&format!( - " {}: {} ({})", - param.name, - param.param_type.as_str(), - if param.required { - "required" - } else { - "optional" - } - )); - if !param.description.is_empty() { - output.print_text(&format!(" Description: {}", param.description)); - } - if let Some(ref default) = param.default { - output.print_text(&format!(" Default: {}", default)); - } - } - } - } - ToolCommands::Validate { name, arguments } => { - let arguments_json = match arguments { - Some(args) => serde_json::from_str(&args)?, - None => json!({}), - }; - - let tool = match name.as_str() { - "read_file" => create_read_file_tool(), - "api_call" => create_api_call_tool(), - _ => { - return Err(format!("Tool '{}' not found", name).into()); - } - }; - - let result = tool.validate_args(&arguments_json); - if output.is_json() { - output.print_json(&json!({ - "valid": result.is_ok(), - "error": result.err().map(|e| e.to_string()), - })); - } else { - match result { - Ok(()) => output.print_text("✅ Arguments are valid"), - Err(e) => output.print_text(&format!("❌ Validation error: {}", e)), - } - } - } - } - Ok(()) -} - -#[cfg(feature = "mcp")] -async fn handle_mcp_commands( - command: McpCommands, - output: &Output, -) -> Result<(), Box> { - match command { - McpCommands::ConnectHttp { name, url } => { - let transport = McpTransport::new_http(name.clone(), url.clone()); - let client = McpClient::new(transport); - - if output.is_json() { - output.print_json(&json!({ - "status": "connected", - "name": name, - "url": url, - "session_id": client.session_id(), - })); - } else { - output.print_text(&format!( - "Connecting to MCP server '{}' at {}...", - name, url - )); - output.print_text(&format!("✅ Connected to MCP server '{}'", name)); - output.print_text(&format!(" Session ID: {}", client.session_id())); - } - } - McpCommands::ConnectStdio { - name, - command, - args, - } => { - let transport = - McpTransport::new_stdio(name.clone(), command.clone(), Some(args.clone())); - let client = McpClient::new(transport); - - if output.is_json() { - output.print_json(&json!({ - "status": "connected", - "name": name, - "command": command, - "args": args, - "session_id": client.session_id(), - })); - } else { - output.print_text(&format!( - "Connecting to MCP server '{}' via STDIO command '{}'...", - name, command - )); - output.print_text(&format!("✅ Connected to MCP server '{}'", name)); - output.print_text(&format!(" Session ID: {}", client.session_id())); - } - } - McpCommands::List => { - if output.is_json() { - output.print_json(&json!({ - "tools": [], - "message": "No active connection" - })); - } else { - output.print_text("This would list tools from the connected MCP server"); - } - } - McpCommands::Call { name, arguments } => { - let _arguments_json = match arguments { - Some(args) => serde_json::from_str(&args)?, - None => json!({}), - }; - - if output.is_json() { - output.print_json(&json!({ - "tool": name, - "status": "completed", - "result": null - })); - } else { - output.print_text(&format!("Calling tool '{}' on MCP server...", name)); - output.print_text("✅ Tool call completed"); - } - } - McpCommands::Status => { - if output.is_json() { - output.print_json(&json!({ - "transport": "ready", - "session": "active", - "connected_servers": 0 - })); - } else { - output.print_text("MCP Connection Status:"); - output.print_text(" Transport: Ready"); - output.print_text(" Session: Active"); - output.print_text(" Connected servers: 0"); - } - } - } - Ok(()) -} - -fn handle_convert_commands( - command: ConvertCommands, - output: &Output, -) -> Result<(), Box> { - match command { - ConvertCommands::OpenApi { - file, - out: output_file, - } => { - let spec_content = std::fs::read_to_string(&file)?; - - let spec: serde_json::Value = if file - .extension() - .is_some_and(|ext| ext == "yaml" || ext == "yml") - { - serde_yaml::from_str(&spec_content) - .map_err(|e| format!("Failed to parse YAML: {}", e))? - } else { - serde_json::from_str(&spec_content) - .or_else(|_| serde_yaml::from_str(&spec_content)) - .map_err(|e| format!("Failed to parse spec (tried JSON and YAML): {}", e))? - }; - - let generator = AdapterGenerator::new(spec, Some("api-adapter".to_string())); - let tools = generator - .generate_tools() - .map_err(|e| format!("Failed to generate tools: {}", e))?; - - if output.is_json() { - output.print_json(&json!({ - "source": file.display().to_string(), - "tools_generated": tools.len(), - "tools": tools.iter().map(|t| json!({ - "name": t.name, - "description": t.description, - "parameter_count": t.parameters.len() - })).collect::>() - })); - } else { - output.print_text(&format!( - "Converting OpenAPI spec from {}...", - file.display() - )); - output.print_text(&format!("Generated {} tool definitions", tools.len())); - - for tool in &tools { - output.print_text(&format!( - " - {}: {} parameters", - tool.name, - tool.parameters.len() - )); - } - } - if let Some(output_path) = output_file { - let config = generator - .generate_config() - .map_err(|e| format!("Failed to generate config: {}", e))?; - std::fs::write(&output_path, config)?; - if !output.is_json() { - output.print_text(&format!( - "✅ Configuration written to: {}", - output_path.display() - )); - } - } - - if !output.is_json() { - output.print_text("✅ Conversion complete"); - } - } - ConvertCommands::Examples => { - if output.is_json() { - let example_spec = json!({ - "openapi": "3.0.0", - "info": { - "title": "Sample API", - "version": "1.0.0" - }, - "paths": { - "/users": { - "get": { - "operationId": "listUsers", - "summary": "List all users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": {"type": "integer"} - } - ] - } - }, - "/users/{id}": { - "get": { - "operationId": "getUser", - "summary": "Get user by ID" - } - } - } - }); - output.print_json(&json!({ - "examples": [ - { - "name": "GitHub API", - "command": "thulp convert openapi github_api.yaml" - }, - { - "name": "Slack API", - "command": "thulp convert openapi slack_api.json --output tools.yaml" - } - ], - "sample_spec": example_spec - })); - } else { - output.print_text("OpenAPI Conversion Examples:"); - output.print_text(""); - output.print_text("GitHub API:"); - output.print_text(" thulp convert openapi github_api.yaml"); - output.print_text(""); - output.print_text("Slack API:"); - output.print_text(" thulp convert openapi slack_api.json --output tools.yaml"); - output.print_text(""); - output.print_text("Example OpenAPI v3 specification:"); - let example_spec = json!({ - "openapi": "3.0.0", - "info": { - "title": "Sample API", - "version": "1.0.0" - }, - "paths": { - "/users": { - "get": { - "operationId": "listUsers", - "summary": "List all users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": {"type": "integer"} - } - ] - } - }, - "/users/{id}": { - "get": { - "operationId": "getUser", - "summary": "Get user by ID" - } - } - } - }); - output.print_text(&serde_yaml::to_string(&example_spec)?); - } - } - } - Ok(()) -} - -async fn run_demo(output: &Output) -> Result<(), Box> { - if output.is_json() { - let read_file_tool = create_read_file_tool(); - let valid_args = json!({ "path": "/etc/hosts" }); - let invalid_args = json!({ "path": 123 }); - - let tool_call = ToolCall::builder("read_file") - .arg_str("path", "/tmp/example.txt") - .arg_str("encoding", "utf-8") - .build(); - - output.print_json(&json!({ - "demo": "core_functionality", - "tool_definition": { - "name": read_file_tool.name, - "valid_args_test": read_file_tool.validate_args(&valid_args).is_ok(), - "invalid_args_test": read_file_tool.validate_args(&invalid_args).is_err(), - }, - "tool_call": { - "tool": tool_call.tool, - "arguments": tool_call.arguments, - } - })); - } else { - output.print_text("🎯 Thulp Demo - Core Functionality"); - output.print_text("==================================\n"); - - output.print_text("1. Tool Definition & Validation"); - output.print_text("--------------------------------"); - - let read_file_tool = create_read_file_tool(); - output.print_text(&format!("Created tool: {}", read_file_tool.name)); - - let valid_args = json!({ "path": "/etc/hosts" }); - let invalid_args = json!({ "path": 123 }); - - match read_file_tool.validate_args(&valid_args) { - Ok(()) => output.print_text("✅ Valid arguments accepted"), - Err(e) => output.print_text(&format!("❌ Unexpected validation error: {}", e)), - } - - match read_file_tool.validate_args(&invalid_args) { - Ok(()) => output.print_text("❌ Invalid arguments should have been rejected"), - Err(e) => output.print_text(&format!("✅ Invalid arguments correctly rejected: {}", e)), - } - - #[cfg(feature = "mcp")] - { - output.print_text("\n2. MCP Client"); - output.print_text("--------------"); - - let transport = McpTransport::new(); - let client = McpClient::new(transport); - - output.print_text("Client created successfully"); - output.print_text(&format!(" Connected: {}", client.is_connected())); - output.print_text(&format!(" Session ID: {}", client.session_id())); - } - - output.print_text("\n3. Tool Call Creation"); - output.print_text("----------------------"); - - let tool_call = ToolCall::builder("read_file") - .arg_str("path", "/tmp/example.txt") - .arg_str("encoding", "utf-8") - .build(); - - output.print_text(&format!("Created tool call: {}", tool_call.tool)); - output.print_text(&format!( - "Arguments: {}", - serde_json::to_string_pretty(&tool_call.arguments)? - )); - - output.print_text("\n🎉 Demo complete!"); - } - Ok(()) -} - -fn validate_file(file: &Path, output: &Output) -> Result<(), Box> { - if !file.exists() { - return Err(format!("File not found: {}", file.display()).into()); - } - - if output.is_json() { - output.print_json(&json!({ - "file": file.display().to_string(), - "valid": true, - })); - } else { - output.print_text(&format!("Validating file: {}", file.display())); - output.print_text("✅ File validation complete"); - } - - Ok(()) -} - -// Helper functions to create demo tools -fn create_read_file_tool() -> ToolDefinition { - ToolDefinition::builder("read_file") - .description("Read contents of a file from the filesystem") - .parameter( - Parameter::builder("path") - .param_type(ParameterType::String) - .required(true) - .description("Path to the file to read") - .build(), - ) - .parameter( - Parameter::builder("encoding") - .param_type(ParameterType::String) - .description("File encoding (utf-8, ascii, etc.)") - .default(serde_json::Value::String("utf-8".to_string())) - .build(), - ) - .build() -} - -fn create_api_call_tool() -> ToolDefinition { - ToolDefinition::builder("api_call") - .description("Make HTTP API requests to external services") - .parameter( - Parameter::builder("url") - .param_type(ParameterType::String) - .required(true) - .description("URL to make the API request to") - .build(), - ) - .parameter( - Parameter::builder("method") - .param_type(ParameterType::String) - .description("HTTP method (GET, POST, PUT, DELETE)") - .default(serde_json::Value::String("GET".to_string())) - .build(), - ) - .parameter( - Parameter::builder("headers") - .param_type(ParameterType::Object) - .description("HTTP headers as key-value pairs") - .build(), - ) - .build() -} #[cfg(test)] mod tests { diff --git a/crates/thulp-cli/src/output.rs b/crates/thulp-cli/src/output.rs new file mode 100644 index 0000000..db741d5 --- /dev/null +++ b/crates/thulp-cli/src/output.rs @@ -0,0 +1,47 @@ +use clap::ValueEnum; +use serde::Serialize; + +/// Output format for CLI commands +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +pub enum OutputFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for programmatic consumption + Json, + /// Compact JSON (no pretty-printing) + JsonCompact, +} + +/// Output helper for formatted output +pub struct Output { + pub format: OutputFormat, +} + +impl Output { + pub fn new(format: OutputFormat) -> Self { + Self { format } + } + + pub fn print_text(&self, text: &str) { + if matches!(self.format, OutputFormat::Text) { + println!("{}", text); + } + } + + pub fn print_json(&self, data: &T) { + match self.format { + OutputFormat::Text => {} + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(data).unwrap()); + } + OutputFormat::JsonCompact => { + println!("{}", serde_json::to_string(data).unwrap()); + } + } + } + + pub fn is_json(&self) -> bool { + matches!(self.format, OutputFormat::Json | OutputFormat::JsonCompact) + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0bbe08d..d89ce41 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -226,24 +226,24 @@ Feature: Skill Execution ### Deliverables #### Week 11: CLI Core -- [ ] `thulp-cli` crate with `clap` -- [ ] `thulp init` command -- [ ] `thulp run ` command -- [ ] `thulp skill run` command -- [ ] Output formatting (json, table, plain) +- [x] `thulp-cli` crate with `clap` +- [x] `thulp init` command +- [x] `thulp run ` command +- [x] `thulp skill run` command +- [x] Output formatting (json, table, plain) #### Week 12: CLI Complete - [ ] `thulp adapter generate` command - [ ] `thulp flow export` command -- [ ] `thulp config` commands -- [ ] Error presentation +- [x] `thulp config` commands +- [x] Error presentation - [ ] Progress indicators #### Week 13: Integration & Polish -- [ ] End-to-end testing +- [x] End-to-end testing - [ ] Performance optimization -- [ ] Error message improvements -- [ ] Shell completions +- [x] Error message improvements +- [x] Shell completions ### Testing Focus ``` @@ -272,10 +272,10 @@ Feature: Skill Workflow ``` ### Exit Criteria -- [ ] All CLI commands functional -- [ ] E2E tests cover main user journeys +- [x] All CLI commands functional (init, run, skill, config, validate) +- [x] E2E tests cover main user journeys (6 integration tests) - [ ] Performance targets met -- [ ] Documentation complete +- [x] Documentation complete --- diff --git a/docs/public/404.html b/docs/public/404.html deleted file mode 100644 index f8414f0..0000000 --- a/docs/public/404.html +++ /dev/null @@ -1,3 +0,0 @@ - -404 Not Found -

404 Not Found

diff --git a/docs/public/img/thulp-logo.svg b/docs/public/img/thulp-logo.svg deleted file mode 100644 index 9e939a5..0000000 --- a/docs/public/img/thulp-logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/docs/public/index.html b/docs/public/index.html deleted file mode 100644 index fd86b4a..0000000 --- a/docs/public/index.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - Thulp — Execution Context Engineering for AI Agents - - - - -
- Thulp -

Thulp

-

Execution context engineering for AI agents. One interface for tool discovery, validation, execution, and multi-step workflows. Pure Rust. 11 crates.

-
- crates.io - CI - 11 crates - 331 tests - MIT/Apache-2.0 -
-
$ cargo install thulp-cli
-
- -
-
-

Unified Tool Abstraction

-

One interface for local functions, MCP servers, and OpenAPI endpoints. Type-safe parameters with JSON Schema validation. Builder pattern APIs.

-
-
-

MCP + UTCP Protocol

-

Full Model Context Protocol support via rs-utcp. STDIO and HTTP transports. Tools, resources, and prompts. Drop-in for any MCP-compatible agent.

-
-
-

Skill Workflows

-

Compose multi-step tool chains with variable interpolation, timeout/retry policies, execution hooks, and pluggable executors. Load from SKILL.md files.

-
-
-

Query DSL

-

Filter and search tools with a powerful query language. Wildcards, boolean operators, parameter constraints. name:search and min:2

-
-
- -
-

Workspace

- - - - - - - - - - - - - -
CrateDescriptionTests
thulp-coreCore types, traits, parameter validation70
thulp-mcpMCP transport (STDIO/HTTP), tools, resources39
thulp-skillsMulti-step workflows, executor, hooks, retry54
thulp-skill-filesSKILL.md parsing, YAML frontmatter, scope priority23
thulp-queryQuery DSL with nom parser19
thulp-workspaceSessions, persistence, turn counting6
thulp-adapterOpenAPI v2/v3 to tool conversion10
thulp-registryAsync thread-safe tool registry with tagging8
thulp-browserWeb fetching, HTML parsing, optional CDP7
thulp-guidanceTemplate rendering, LLM guidance primitives6
thulp-cliCLI with JSON output and shell completions32
-
- -
-

Quick Start

-

Define a Tool

-
use thulp_core::{ToolDefinition, Parameter, ParameterType};
-
-let tool = ToolDefinition::builder("search")
-    .description("Search for information")
-    .parameter(
-        Parameter::builder("query")
-            .param_type(ParameterType::String)
-            .required(true)
-            .build()
-    )
-    .build();
- -

Connect to MCP Server

-
use thulp_mcp::McpClient;
-
-let client = McpClient::connect_stdio("server", "mcp-server", None).await?;
-let tools = client.list_tools().await?;
-let result = client.call(&ToolCall::builder("search")
-    .arg_str("query", "rust async")
-    .build()).await?;
- -

CLI

-
thulp tools list --output json
-thulp tools validate search --args '{"query": "rust"}'
-thulp convert openapi spec.yaml --output tools.yaml
-thulp completions bash > ~/.local/share/bash-completion/completions/thulp
-
- -
-

Ecosystem

- - - - - - -
ProjectWhat
pawanCLI coding agent — uses thulp for tool abstraction
aresAgentic retrieval-enhanced server
erukaContext intelligence engine
daedraSelf-contained web search MCP server
-
- - - - diff --git a/docs/public/robots.txt b/docs/public/robots.txt deleted file mode 100644 index 5ba7395..0000000 --- a/docs/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Disallow: -Allow: / -Sitemap: https://dirmacs.github.io/thulp/sitemap.xml diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml deleted file mode 100644 index e791790..0000000 --- a/docs/public/sitemap.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - https://dirmacs.github.io/thulp/ - - diff --git a/docs/static/img/thulp-logo.svg b/docs/static/img/thulp-logo.svg deleted file mode 100644 index 9e939a5..0000000 --- a/docs/static/img/thulp-logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/docs/templates/index.html b/docs/templates/index.html index ddf2db2..127ccdb 100644 --- a/docs/templates/index.html +++ b/docs/templates/index.html @@ -37,12 +37,10 @@
Thulp

Thulp

-

Execution context engineering for AI agents. One interface for tool discovery, validation, execution, and multi-step workflows. Pure Rust. 11 crates.

+

Execution context engineering for AI agents. One interface for tool discovery, validation, execution, and multi-step workflows.

crates.io CI - 11 crates - 331 tests MIT/Apache-2.0
$ cargo install thulp-cli