From 6b89f59a245befce98b48ebcdc5fe8cdd12dc00d Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 17 Apr 2026 03:15:15 +0200 Subject: [PATCH 1/3] feat: introduce skills management system with CRUD operations - Added a new skills module to manage lightweight context templates for the agent. - Implemented API endpoints for fetching, adding, deleting, and enabling/disabling skills. - Created a user interface component for managing skills, including browsing and installing from ClawHub. - Documented the skills format and usage in a new markdown file. - Enhanced the agent's prompt with available skills to improve response accuracy. --- doc/skills.md | 142 +++++ src-tauri/Cargo.lock | 50 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/infrastructure/http_server.rs | 122 +++++ src-tauri/src/modules/bot/agent.rs | 4 +- src-tauri/src/modules/mod.rs | 1 + src-tauri/src/modules/skills/mod.rs | 2 + src-tauri/src/modules/skills/service.rs | 512 ++++++++++++++++++ src-tauri/src/modules/skills/types.rs | 64 +++ src/modules/skills/api/index.ts | 144 +++++ src/modules/skills/components/SkillsPanel.tsx | 413 ++++++++++++++ src/modules/skills/index.ts | 3 + src/modules/skills/types.ts | 25 + src/pages/DashboardPage.tsx | 6 + tools/skills/weather/README.md | 34 ++ 15 files changed, 1522 insertions(+), 1 deletion(-) create mode 100644 doc/skills.md create mode 100644 src-tauri/src/modules/skills/mod.rs create mode 100644 src-tauri/src/modules/skills/service.rs create mode 100644 src-tauri/src/modules/skills/types.rs create mode 100644 src/modules/skills/api/index.ts create mode 100644 src/modules/skills/components/SkillsPanel.tsx create mode 100644 src/modules/skills/index.ts create mode 100644 src/modules/skills/types.ts create mode 100644 tools/skills/weather/README.md diff --git a/doc/skills.md b/doc/skills.md new file mode 100644 index 0000000..335f12a --- /dev/null +++ b/doc/skills.md @@ -0,0 +1,142 @@ +# Skills + +Skills are lightweight context templates the agent can read before making a request. They are **secondary** to MCP tools — MCP servers run in containers and offer real capabilities, while skills are just markdown that tells the agent *how to call a public endpoint and what the response will look like*. + +Think **OpenAPI-lite in a README.md**. + +--- + +## Why skills exist + +Small local models can't interpret complex tools quickly. Skills solve this by giving the model a short, pre-shaped template instead of a full tool definition: + +- A concrete `curl` / `fetch` example it can copy. +- An expected response schema so it knows which fields to extract. +- A one-line "when to use" hint. + +They stay cheap on tokens because the content is hand-written for the task, not auto-generated. + +--- + +## Folder layout + +``` +tools/skills/ # bundled with the app (read-only examples) + weather/ + README.md + +$APP_DATA/skills/ # user-editable; ClawHub installs land here too + / + README.md +``` + +One folder per skill. One `README.md` per folder. That's it. + +- **Bundled** skills ship with the app and can't be edited in-place. Copy them to your custom dir to tweak. +- **Custom** skills are yours. Edit freely. + +On macOS the custom dir resolves to `~/Library/Application Support/pengine/skills/`. The exact path is shown in the Dashboard panel. + +--- + +## Skill file format + +Each `README.md` starts with a YAML frontmatter block: + +```markdown +--- +name: weather +description: Get current weather and forecasts — no API key required. +version: 1.0.0 +author: Your Name +source: https://clawhub.ai/you/weather +license: MIT-0 +tags: [weather, forecast] +requires: [curl] +--- + +# Weather + + +``` + +### Required fields + +| field | purpose | +|---|---| +| `name` | Short slug the agent uses to refer to the skill. | +| `description` | One-line summary shown in the UI and prepended to the model prompt. | + +### Optional fields + +`version`, `author`, `source`, `license`, `tags` (string[]), `requires` (string[] — host binaries needed like `curl`). + +### Body convention + +Write the body for a reader who will execute the request by hand. The agent treats it the same way — it reads the body as context, extracts the request pattern, and runs it. + +A good skill body has three sections: + +1. **Request** — the exact `curl` (or `fetch`) line, with query params explained in a table. +2. **Response schema** — a trimmed JSON example + a field-level cheatsheet. +3. **When to use** — 1–3 bullets about which question this skill answers. + +See `tools/skills/weather/README.md` for a worked example. + +--- + +## Adding a skill + +### From the Dashboard + +1. Open the **Skills** panel on the Dashboard. +2. Click **Add custom skill**. +3. Provide a slug and the full README markdown. The app writes it to `$APP_DATA/skills//README.md`. + +### By hand + +Drop a folder into `$APP_DATA/skills/`. The Dashboard picks it up on reload. + +### From ClawHub + +1. Click **Browse ClawHub** in the Skills panel. +2. Pick a skill and hit **Install**. The app fetches the README from ClawHub and writes it to your custom dir. + +> ClawHub is the community registry. Skills there are plain markdown with the same frontmatter shape as local ones — no special magic. + +--- + +## Editing a skill + +Open the file at `$APP_DATA/skills//README.md` in any editor. Changes are picked up on the next dashboard refresh. There is no compile step. + +To tweak a **bundled** skill, click **Fork to custom** in the panel (or copy the folder manually). Edits to `tools/skills/` inside the app bundle will not persist across reinstalls. + +--- + +## Sharing a skill + +1. Put the skill folder in a public repo (or submit it to ClawHub). +2. Set `source:` in the frontmatter to the canonical URL. +3. Others can install via Dashboard → Browse ClawHub, or clone the repo into their `$APP_DATA/skills/`. + +--- + +## Skills vs MCP tools — when to use which + +| | Skill | MCP tool | +|---|---|---| +| Runtime | Agent executes a `fetch`/`curl` inline | Separate MCP server (often containerised) | +| Cost | Free — just text context | Container memory + startup time | +| Best for | Read-only public APIs, templated fetches | Stateful tools, filesystem, long-running workers | +| Editability | Edit a markdown file | Rebuild container image | + +Reach for a skill first if the task is "call this URL, return this JSON". Reach for an MCP tool if the agent needs to write to disk, keep state, or call something that doesn't fit in one HTTP request. + +--- + +## Wiring into the agent + +The agent receives the `description` of every skill as part of its system context, plus the full body of any skill whose `name` appears in the user message. This keeps the prompt small: a user asking about weather pulls in only `weather`'s README, not every skill in the catalog. + +*(Injection is handled by `src-tauri/src/modules/skills/service.rs`; see the code for the exact selection rule.)* diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 87bf9f2..fb80e97 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -120,6 +120,15 @@ dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -859,6 +868,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -2973,6 +2993,7 @@ dependencies = [ "tokio", "tokio-stream", "tower-http", + "zip", ] [[package]] @@ -6680,12 +6701,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.13.1", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index daf70c7..c76bfe5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ tokio-stream = { version = "0.1", features = ["sync"] } socket2 = "0.5" fastrand = "2" tauri-plugin-dialog = "2" +zip = { version = "2", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index b2495fd..31e4232 100644 --- a/src-tauri/src/infrastructure/http_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -2,8 +2,11 @@ use crate::infrastructure::bot_lifecycle; use crate::modules::bot::{repository, service as bot_service}; use crate::modules::mcp::service as mcp_service; use crate::modules::ollama::service as ollama_service; +use crate::modules::skills::service as skills_service; +use crate::modules::skills::types::{ClawHubSkill, Skill}; use crate::modules::tool_engine::{runtime as te_runtime, service as te_service}; use crate::shared::state::{AppState, ConnectionData}; +use axum::extract::Query; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{Json, Sse}; @@ -115,6 +118,15 @@ pub async fn start_server(state: AppState) { "/v1/toolengine/custom/{key}", delete(handle_toolengine_custom_remove), ) + .route("/v1/skills", get(handle_skills_list)) + .route("/v1/skills", post(handle_skills_add)) + .route("/v1/skills/{slug}", delete(handle_skills_delete)) + .route("/v1/skills/{slug}/enabled", put(handle_skills_set_enabled)) + .route("/v1/skills/clawhub", get(handle_skills_clawhub_search)) + .route( + "/v1/skills/clawhub/install", + post(handle_skills_clawhub_install), + ) .layer(cors) .with_state(state.clone()); @@ -1229,6 +1241,116 @@ async fn handle_toolengine_custom_remove( Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } +#[derive(Serialize)] +pub struct SkillsListResponse { + pub skills: Vec, + pub custom_dir: String, +} + +#[derive(Deserialize)] +pub struct AddSkillBody { + pub slug: String, + pub markdown: String, +} + +#[derive(Serialize)] +pub struct ClawHubSearchResponseDto { + pub results: Vec, +} + +#[derive(Deserialize)] +pub struct ClawHubSearchQuery { + #[serde(default)] + pub q: Option, +} + +#[derive(Deserialize)] +pub struct ClawHubInstallBody { + pub slug: String, +} + +#[derive(Deserialize)] +pub struct SetSkillEnabledBody { + pub enabled: bool, +} + +async fn handle_skills_list(State(state): State) -> Json { + let skills = skills_service::list_skills(&state.store_path); + let custom_dir = skills_service::custom_skills_dir(&state.store_path) + .to_string_lossy() + .to_string(); + Json(SkillsListResponse { skills, custom_dir }) +} + +async fn handle_skills_add( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let skill = skills_service::write_custom_skill(&state.store_path, &body.slug, &body.markdown) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))?; + state + .emit_log("skills", &format!("custom skill '{}' saved", skill.slug)) + .await; + Ok((StatusCode::OK, Json(skill))) +} + +async fn handle_skills_delete( + State(state): State, + Path(slug): Path, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + skills_service::delete_custom_skill(&state.store_path, &slug) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))?; + state + .emit_log("skills", &format!("custom skill '{slug}' removed")) + .await; + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + +async fn handle_skills_clawhub_search( + Query(params): Query, +) -> Result, (StatusCode, Json)> { + let q = params.q.unwrap_or_default(); + let results = skills_service::search_clawhub(&q) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e })))?; + Ok(Json(ClawHubSearchResponseDto { results })) +} + +async fn handle_skills_clawhub_install( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let skill = skills_service::install_clawhub_skill(&state.store_path, &body.slug) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e })))?; + state + .emit_log( + "skills", + &format!("installed ClawHub skill '{}'", skill.slug), + ) + .await; + Ok((StatusCode::OK, Json(skill))) +} + +async fn handle_skills_set_enabled( + State(state): State, + Path(slug): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + skills_service::set_skill_enabled(&state.store_path, &slug, body.enabled) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))?; + state + .emit_log( + "skills", + &format!( + "skill '{slug}' {}", + if body.enabled { "enabled" } else { "disabled" } + ), + ) + .await; + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + async fn handle_logs_sse( State(state): State, ) -> Sse>> { diff --git a/src-tauri/src/modules/bot/agent.rs b/src-tauri/src/modules/bot/agent.rs index a7f29cf..26efb1c 100644 --- a/src-tauri/src/modules/bot/agent.rs +++ b/src-tauri/src/modules/bot/agent.rs @@ -1,5 +1,6 @@ use crate::modules::memory::{self, MemoryProvider, SessionCommand}; use crate::modules::ollama::service as ollama; +use crate::modules::skills::service as skills; use crate::modules::tool_engine::service::workspace_app_bind_pairs; use crate::shared::state::{AppState, MemorySession}; use chrono::Utc; @@ -338,9 +339,10 @@ async fn run_model_turn(state: &AppState, user_message: &str) -> Result PathBuf { + store_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("skills") +} + +fn disabled_file_path(store_path: &Path) -> PathBuf { + custom_skills_dir(store_path).join(DISABLED_FILE) +} + +/// Walk up from `CARGO_MANIFEST_DIR` and `current_dir` looking for `tools/skills/`. +/// Mirrors the lookup in `tool_engine::service` so `tauri dev` finds the bundled +/// folder regardless of where the binary is launched from. +pub fn bundled_skills_dir() -> Option { + let mut candidates: Vec = Vec::new(); + candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tools/skills")); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("tools/skills")); + } + } + if let Ok(mut cwd) = std::env::current_dir() { + for _ in 0..8 { + candidates.push(cwd.join("tools/skills")); + if !cwd.pop() { + break; + } + } + } + candidates.into_iter().find(|p| p.is_dir()) +} + +fn read_disabled_set(store_path: &Path) -> HashSet { + let path = disabled_file_path(store_path); + let Ok(raw) = std::fs::read_to_string(&path) else { + return HashSet::new(); + }; + serde_json::from_str::>(&raw) + .map(|v| v.into_iter().collect()) + .unwrap_or_default() +} + +fn write_disabled_set(store_path: &Path, set: &HashSet) -> Result<(), String> { + let dir = custom_skills_dir(store_path); + std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let mut list: Vec<&String> = set.iter().collect(); + list.sort(); + let json = serde_json::to_string_pretty(&list).map_err(|e| format!("encode disabled: {e}"))?; + let path = disabled_file_path(store_path); + std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display())) +} + +/// Mark `slug` enabled or disabled. Persisted in `.disabled.json`. +pub fn set_skill_enabled(store_path: &Path, slug: &str, enabled: bool) -> Result<(), String> { + validate_slug(slug)?; + let mut set = read_disabled_set(store_path); + if enabled { + set.remove(slug); + } else { + set.insert(slug.to_string()); + } + write_disabled_set(store_path, &set) +} + +/// Build a system-prompt fragment describing the enabled skills so the agent +/// knows when/how to invoke fetch tools for each. Returns `""` if there are +/// none enabled. +pub fn skills_prompt_hint(store_path: &Path) -> String { + let skills: Vec = list_skills(store_path) + .into_iter() + .filter(|s| s.enabled) + .collect(); + if skills.is_empty() { + return String::new(); + } + let mut out = String::from( + "\n\nAvailable skills. Follow each skill's recipe exactly — it tells you \ +WHICH URL to fetch and HOW MANY calls to make. Stop calling tools the moment \ +you have enough data to answer; do not probe alternative URLs or try variants. \ +Never claim you lack access — the fetch tool is available.", + ); + for s in &skills { + out.push_str(&format!( + "\n\n── skill:{slug} — {name} ──\n{desc}\n{body}", + slug = s.slug, + name = s.name, + desc = s.description, + body = s.body.trim_end(), + )); + } + out +} + +/// List every discoverable skill. Custom skills shadow bundled ones with the same slug. +pub fn list_skills(store_path: &Path) -> Vec { + let disabled = read_disabled_set(store_path); + let mut out: Vec = Vec::new(); + + if let Some(dir) = bundled_skills_dir() { + out.extend(read_dir_skills(&dir, SkillOrigin::Bundled)); + } + + let custom = custom_skills_dir(store_path); + if custom.is_dir() { + for skill in read_dir_skills(&custom, SkillOrigin::Custom) { + if let Some(i) = out.iter().position(|s| s.slug == skill.slug) { + out.remove(i); + } + out.push(skill); + } + } + + for skill in &mut out { + skill.enabled = !disabled.contains(&skill.slug); + } + + out.sort_by(|a, b| a.slug.cmp(&b.slug)); + out +} + +fn read_dir_skills(dir: &Path, origin: SkillOrigin) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut skills = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(slug) = path + .file_name() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + continue; + }; + if slug.starts_with('.') { + continue; + } + let readme = path.join("README.md"); + let raw = match std::fs::read_to_string(&readme) { + Ok(s) => s, + Err(_) => match std::fs::read_to_string(path.join("SKILL.md")) { + Ok(s) => s, + Err(_) => continue, + }, + }; + match parse_skill(&slug, &raw, origin) { + Ok(s) => skills.push(s), + Err(e) => log::warn!("skipping skill {}: {e}", readme.display()), + } + } + skills +} + +/// Parse a skill README. Frontmatter is the `---`-delimited YAML-ish block at the top. +/// The parser is deliberately tiny — scalars, quoted strings, and inline `[a, b]` arrays only. +pub fn parse_skill(slug: &str, raw: &str, origin: SkillOrigin) -> Result { + let (fm, body) = split_frontmatter(raw).ok_or("missing frontmatter block")?; + let fields = parse_frontmatter(fm)?; + + let name = fields + .get("name") + .cloned() + .unwrap_or_else(|| slug.to_string()); + let description = fields + .get("description") + .cloned() + .ok_or("frontmatter: missing `description`")?; + + // ClawHub skills use `homepage` where our local format uses `source`. + let source = fields + .get("source") + .or_else(|| fields.get("homepage")) + .cloned(); + + Ok(Skill { + slug: slug.to_string(), + name, + description, + tags: fields.get_list("tags"), + author: fields.get("author").cloned(), + version: fields.get("version").cloned(), + source, + license: fields.get("license").cloned(), + requires: fields.get_list("requires"), + origin, + enabled: true, + body: body.trim_start_matches(['\n', '\r']).to_string(), + }) +} + +fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { + let trimmed = raw.trim_start_matches('\u{feff}'); + let rest = trimmed.strip_prefix("---")?; + let rest = rest + .strip_prefix('\n') + .or_else(|| rest.strip_prefix("\r\n"))?; + let end = rest.find("\n---")?; + let fm = &rest[..end]; + let after = &rest[end + 4..]; + let after = after + .strip_prefix('\n') + .or_else(|| after.strip_prefix("\r\n")) + .unwrap_or(after); + Some((fm, after)) +} + +/// Case-sensitive key→value/list field bag. +#[derive(Default)] +struct Fields { + scalars: std::collections::HashMap, + lists: std::collections::HashMap>, +} + +impl Fields { + fn get(&self, key: &str) -> Option<&String> { + self.scalars.get(key) + } + fn get_list(&self, key: &str) -> Vec { + self.lists.get(key).cloned().unwrap_or_default() + } +} + +fn parse_frontmatter(fm: &str) -> Result { + let mut fields = Fields::default(); + for (lineno, line) in fm.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let (key, value) = trimmed + .split_once(':') + .ok_or_else(|| format!("frontmatter line {}: missing ':'", lineno + 1))?; + let key = key.trim().to_string(); + let value = value.trim(); + if key.is_empty() { + return Err(format!("frontmatter line {}: empty key", lineno + 1)); + } + if let Some(list) = parse_inline_list(value) { + fields.lists.insert(key, list); + } else { + fields.scalars.insert(key, unquote(value).to_string()); + } + } + Ok(fields) +} + +fn parse_inline_list(v: &str) -> Option> { + let inner = v.strip_prefix('[')?.strip_suffix(']')?; + Some( + inner + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| unquote(s).to_string()) + .collect(), + ) +} + +fn unquote(s: &str) -> &str { + s.strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .or_else(|| { + s.strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + }) + .unwrap_or(s) +} + +/// Slugs must be filesystem-safe and URL-safe. +fn validate_slug(slug: &str) -> Result<(), String> { + if slug.is_empty() || slug.len() > 64 { + return Err("slug must be 1–64 chars".into()); + } + if !slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err("slug may only contain a-z, 0-9, '-', '_'".into()); + } + Ok(()) +} + +/// Create or overwrite a custom skill from its full README markdown. +pub fn write_custom_skill(store_path: &Path, slug: &str, markdown: &str) -> Result { + validate_slug(slug)?; + if markdown.len() > MAX_README_BYTES { + return Err(format!("README exceeds {} byte limit", MAX_README_BYTES)); + } + + let skill = parse_skill(slug, markdown, SkillOrigin::Custom) + .map_err(|e| format!("invalid skill markdown: {e}"))?; + + let dir = custom_skills_dir(store_path).join(slug); + std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let path = dir.join("README.md"); + std::fs::write(&path, markdown).map_err(|e| format!("write {}: {e}", path.display()))?; + Ok(skill) +} + +/// Remove a custom skill's folder. Bundled skills cannot be deleted. +pub fn delete_custom_skill(store_path: &Path, slug: &str) -> Result<(), String> { + validate_slug(slug)?; + let dir = custom_skills_dir(store_path).join(slug); + if !dir.exists() { + return Err(format!("custom skill '{slug}' not found")); + } + std::fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display()))?; + + // Clean stale disabled-entry if present. + let mut set = read_disabled_set(store_path); + if set.remove(slug) { + let _ = write_disabled_set(store_path, &set); + } + Ok(()) +} + +fn build_clawhub_client() -> Result { + reqwest::Client::builder() + .timeout(CLAWHUB_TIMEOUT) + .user_agent("pengine-skills/1.0") + .build() + .map_err(|e| format!("http client: {e}")) +} + +/// Search the ClawHub registry. An empty `query` returns an empty list; the +/// registry has no "list all" endpoint. +pub async fn search_clawhub(query: &str) -> Result, String> { + let q = query.trim(); + if q.is_empty() { + return Ok(Vec::new()); + } + let client = build_clawhub_client()?; + let url = reqwest::Url::parse_with_params(CLAWHUB_SEARCH_URL, &[("q", q)]) + .map_err(|e| format!("build ClawHub search URL: {e}"))?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("search ClawHub: {e}"))?; + if !resp.status().is_success() { + return Err(format!("ClawHub returned HTTP {}", resp.status())); + } + let body = resp + .json::() + .await + .map_err(|e| format!("parse ClawHub search: {e}"))?; + Ok(body.results) +} + +/// Install a ClawHub skill by downloading its zip, extracting `SKILL.md`, +/// and writing it under `$APP_DATA/skills//README.md`. +pub async fn install_clawhub_skill(store_path: &Path, slug: &str) -> Result { + validate_slug(slug)?; + let client = build_clawhub_client()?; + let url = reqwest::Url::parse_with_params(CLAWHUB_DOWNLOAD_URL, &[("slug", slug)]) + .map_err(|e| format!("build ClawHub download URL: {e}"))?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("download ClawHub skill: {e}"))?; + if !resp.status().is_success() { + return Err(format!("ClawHub download returned HTTP {}", resp.status())); + } + let bytes = resp + .bytes() + .await + .map_err(|e| format!("read download body: {e}"))?; + if bytes.len() > MAX_ZIP_BYTES { + return Err(format!( + "ClawHub archive exceeds {MAX_ZIP_BYTES} byte limit" + )); + } + + let markdown = extract_skill_md(bytes.as_ref())?; + write_custom_skill(store_path, slug, &markdown) +} + +/// Find the first `SKILL.md` in `zip_bytes` and return it as a UTF-8 string. +fn extract_skill_md(zip_bytes: &[u8]) -> Result { + let mut archive = + ZipArchive::new(Cursor::new(zip_bytes)).map_err(|e| format!("invalid zip archive: {e}"))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("zip entry {i}: {e}"))?; + let name = file.name().to_string(); + let basename = name.rsplit('/').next().unwrap_or(&name); + if basename.eq_ignore_ascii_case("SKILL.md") { + if file.size() > MAX_README_BYTES as u64 { + return Err(format!("SKILL.md exceeds {MAX_README_BYTES} byte limit")); + } + let mut buf = String::new(); + file.read_to_string(&mut buf) + .map_err(|e| format!("read SKILL.md: {e}"))?; + return Ok(buf); + } + } + Err("archive does not contain SKILL.md".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const SAMPLE: &str = "---\nname: demo\ndescription: A demo skill.\ntags: [a, b]\nrequires: [curl]\n---\n\n# body\n"; + + #[test] + fn parses_minimal_frontmatter() { + let s = parse_skill("demo", SAMPLE, SkillOrigin::Custom).unwrap(); + assert_eq!(s.name, "demo"); + assert_eq!(s.description, "A demo skill."); + assert_eq!(s.tags, vec!["a", "b"]); + assert_eq!(s.requires, vec!["curl"]); + assert!(s.enabled); + assert!(s.body.starts_with("# body")); + } + + #[test] + fn rejects_missing_description() { + let raw = "---\nname: demo\n---\nbody\n"; + let err = parse_skill("demo", raw, SkillOrigin::Custom).unwrap_err(); + assert!(err.contains("description"), "got: {err}"); + } + + #[test] + fn accepts_clawhub_homepage_as_source() { + let raw = "---\nname: weather\ndescription: d\nhomepage: https://wttr.in\n---\nbody\n"; + let s = parse_skill("weather", raw, SkillOrigin::Custom).unwrap(); + assert_eq!(s.source.as_deref(), Some("https://wttr.in")); + } + + #[test] + fn rejects_bad_slug() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + assert!(write_custom_skill(&fake_store, "bad slug!", SAMPLE).is_err()); + assert!(write_custom_skill(&fake_store, "good-slug", SAMPLE).is_ok()); + } + + #[test] + fn write_then_list_roundtrip() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + write_custom_skill(&fake_store, "demo", SAMPLE).unwrap(); + let list = list_skills(&fake_store); + assert!(list.iter().any(|s| s.slug == "demo")); + } + + #[test] + fn disabled_flag_roundtrips() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + write_custom_skill(&fake_store, "demo", SAMPLE).unwrap(); + set_skill_enabled(&fake_store, "demo", false).unwrap(); + + let list = list_skills(&fake_store); + let s = list.iter().find(|s| s.slug == "demo").unwrap(); + assert!(!s.enabled); + + set_skill_enabled(&fake_store, "demo", true).unwrap(); + let list = list_skills(&fake_store); + let s = list.iter().find(|s| s.slug == "demo").unwrap(); + assert!(s.enabled); + } + + #[test] + fn delete_removes_custom_skill() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + write_custom_skill(&fake_store, "demo", SAMPLE).unwrap(); + delete_custom_skill(&fake_store, "demo").unwrap(); + assert!(delete_custom_skill(&fake_store, "demo").is_err()); + } + + #[test] + fn delete_clears_disabled_entry() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + write_custom_skill(&fake_store, "demo", SAMPLE).unwrap(); + set_skill_enabled(&fake_store, "demo", false).unwrap(); + delete_custom_skill(&fake_store, "demo").unwrap(); + assert!(!read_disabled_set(&fake_store).contains("demo")); + } +} diff --git a/src-tauri/src/modules/skills/types.rs b/src-tauri/src/modules/skills/types.rs new file mode 100644 index 0000000..0296d91 --- /dev/null +++ b/src-tauri/src/modules/skills/types.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SkillOrigin { + /// Shipped with the app under `tools/skills/`. Read-only from the app. + Bundled, + /// Lives under `$APP_DATA/skills/`. Editable by the user. + Custom, +} + +/// A skill is a folder with a `README.md` whose YAML frontmatter declares the +/// fields below. The markdown body after the frontmatter is passed to the agent +/// as context — see `doc/skills.md`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Skill { + pub slug: String, + pub name: String, + pub description: String, + #[serde(default)] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default)] + pub requires: Vec, + pub origin: SkillOrigin, + /// Whether the agent should see this skill. Controlled per-slug in the UI. + #[serde(default = "default_true")] + pub enabled: bool, + /// Raw markdown body after the frontmatter block. + pub body: String, +} + +fn default_true() -> bool { + true +} + +/// One row returned by `GET /api/search?q=` on ClawHub. +/// The extra fields ClawHub returns (e.g. `score`) are ignored. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClawHubSkill { + pub slug: String, + #[serde(default, rename = "displayName")] + pub display_name: String, + #[serde(default)] + pub summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, rename = "updatedAt", skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +/// Wrapper that matches the raw ClawHub `/api/search` response shape. +#[derive(Debug, Clone, Deserialize)] +pub struct ClawHubSearchResponse { + #[serde(default)] + pub results: Vec, +} diff --git a/src/modules/skills/api/index.ts b/src/modules/skills/api/index.ts new file mode 100644 index 0000000..110457b --- /dev/null +++ b/src/modules/skills/api/index.ts @@ -0,0 +1,144 @@ +import { fetchErrorMessage, PENGINE_API_BASE } from "../../../shared/api/config"; +import type { ClawHubSkill, Skill } from "../types"; + +export type SkillsListResponse = { + skills: Skill[]; + custom_dir: string; +}; + +function makeTimeoutSignal(timeoutMs: number): { signal: AbortSignal; cleanup: () => void } { + if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") { + return { signal: AbortSignal.timeout(timeoutMs), cleanup: () => {} }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { signal: controller.signal, cleanup: () => clearTimeout(timer) }; +} + +async function parseApiError(resp: Response): Promise { + const raw = await resp.text(); + try { + const body = JSON.parse(raw) as { error?: string }; + return body.error ?? raw.trim() ?? `HTTP ${resp.status}`; + } catch { + return raw.trim() || `HTTP ${resp.status}`; + } +} + +export async function fetchSkills(timeoutMs = 5000): Promise { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/skills`, { signal }); + if (!resp.ok) return null; + return (await resp.json()) as SkillsListResponse; + } catch { + return null; + } finally { + cleanup(); + } +} + +export async function addSkill( + slug: string, + markdown: string, + timeoutMs = 5000, +): Promise<{ ok: boolean; skill?: Skill; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/skills`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug, markdown }), + signal, + }); + if (resp.ok) return { ok: true, skill: (await resp.json()) as Skill }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function deleteSkill( + slug: string, + timeoutMs = 5000, +): Promise<{ ok: boolean; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/skills/${encodeURIComponent(slug)}`, { + method: "DELETE", + signal, + }); + if (resp.ok) return { ok: true }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function setSkillEnabled( + slug: string, + enabled: boolean, + timeoutMs = 5000, +): Promise<{ ok: boolean; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/skills/${encodeURIComponent(slug)}/enabled`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + signal, + }); + if (resp.ok) return { ok: true }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function searchClawHub( + query: string, + timeoutMs = 10_000, +): Promise<{ results?: ClawHubSkill[]; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const url = new URL(`${PENGINE_API_BASE}/v1/skills/clawhub`); + url.searchParams.set("q", query); + const resp = await fetch(url.toString(), { signal }); + if (resp.ok) { + const body = (await resp.json()) as { results: ClawHubSkill[] }; + return { results: body.results }; + } + return { error: await parseApiError(resp) }; + } catch (e) { + return { error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function installClawHubSkill( + slug: string, + timeoutMs = 20_000, +): Promise<{ ok: boolean; skill?: Skill; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/skills/clawhub/install`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug }), + signal, + }); + if (resp.ok) return { ok: true, skill: (await resp.json()) as Skill }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} diff --git a/src/modules/skills/components/SkillsPanel.tsx b/src/modules/skills/components/SkillsPanel.tsx new file mode 100644 index 0000000..a6f9809 --- /dev/null +++ b/src/modules/skills/components/SkillsPanel.tsx @@ -0,0 +1,413 @@ +import * as Accordion from "@radix-ui/react-accordion"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + addSkill, + deleteSkill, + fetchSkills, + installClawHubSkill, + searchClawHub, + setSkillEnabled, +} from "../api"; +import type { ClawHubSkill, Skill } from "../types"; + +const TEMPLATE = `--- +name: my-skill +description: One-line summary of what this skill does. +tags: [] +--- + +# My Skill + +## Request + +\`\`\`bash +curl -s "https://example.com/api/..." +\`\`\` + +## Response schema + +\`\`\`json +{} +\`\`\` + +## When to use + +- ... +`; + +export function SkillsPanel() { + const [skills, setSkills] = useState(null); + const [customDir, setCustomDir] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [togglingSlug, setTogglingSlug] = useState(null); + + const [showAdd, setShowAdd] = useState(false); + const [newSlug, setNewSlug] = useState(""); + const [newMarkdown, setNewMarkdown] = useState(TEMPLATE); + const [addBusy, setAddBusy] = useState(false); + const [addError, setAddError] = useState(null); + + const [showBrowse, setShowBrowse] = useState(false); + const [query, setQuery] = useState(""); + const [results, setResults] = useState(null); + const [browseError, setBrowseError] = useState(null); + const [browseLoading, setBrowseLoading] = useState(false); + const [installingSlug, setInstallingSlug] = useState(null); + + const cancelledRef = useRef(false); + + const load = useCallback(async () => { + const resp = await fetchSkills(); + if (cancelledRef.current) return; + setLoading(false); + if (resp) { + setSkills(resp.skills); + setCustomDir(resp.custom_dir); + setError(null); + } else { + setError("Could not load skills"); + } + }, []); + + useEffect(() => { + cancelledRef.current = false; + void load(); + return () => { + cancelledRef.current = true; + }; + }, [load]); + + const handleAdd = async () => { + setAddBusy(true); + setAddError(null); + const result = await addSkill(newSlug.trim(), newMarkdown); + setAddBusy(false); + if (result.ok) { + setNotice(`Skill '${result.skill?.slug}' saved`); + setShowAdd(false); + setNewSlug(""); + setNewMarkdown(TEMPLATE); + void load(); + } else { + setAddError(result.error ?? "Could not save skill"); + } + }; + + const handleDelete = async (slug: string) => { + setNotice(null); + const result = await deleteSkill(slug); + if (result.ok) { + setNotice(`Skill '${slug}' removed`); + void load(); + } else { + setError(result.error ?? "Could not delete skill"); + } + }; + + const handleToggleEnabled = async (skill: Skill) => { + setTogglingSlug(skill.slug); + const next = !skill.enabled; + // optimistic + setSkills((prev) => + prev ? prev.map((s) => (s.slug === skill.slug ? { ...s, enabled: next } : s)) : prev, + ); + const result = await setSkillEnabled(skill.slug, next); + setTogglingSlug(null); + if (!result.ok) { + setError(result.error ?? "Could not update skill"); + void load(); + } + }; + + const runSearch = async (q: string) => { + setBrowseLoading(true); + setBrowseError(null); + const result = await searchClawHub(q); + setBrowseLoading(false); + if (result.results) { + setResults(result.results); + } else { + setBrowseError(result.error ?? "ClawHub is unreachable"); + } + }; + + const openBrowse = () => { + setShowBrowse(true); + setResults(null); + setBrowseError(null); + setQuery(""); + }; + + const handleInstall = async (entry: ClawHubSkill) => { + setInstallingSlug(entry.slug); + const result = await installClawHubSkill(entry.slug); + setInstallingSlug(null); + if (result.ok) { + setNotice(`Installed '${result.skill?.slug}' from ClawHub`); + setShowBrowse(false); + void load(); + } else { + setBrowseError(result.error ?? "Install failed"); + } + }; + + return ( +
+
+

Skills

+
+ + +
+
+ +

+ Custom dir: {customDir || "—"} +

+ + {notice && ( +

+ {notice} +

+ )} + {error && ( +

+ {error} +

+ )} + + {showAdd && ( +
+ + setNewSlug(e.target.value)} + placeholder="my-skill" + className="mt-1 w-full rounded-lg border border-white/10 bg-black/20 px-2 py-1 font-mono text-xs text-white outline-none focus:border-emerald-300/40" + /> + +