From 2c64cbf4c756c3b92d82c79b735893691d8dbbc6 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Thu, 2 Apr 2026 10:38:01 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20skills=20canister=20=E2=80=94=20l?= =?UTF-8?q?ist=5Fskills,=20get=5Fskill,=20search=5Fskills=20query=20method?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Rust IC canister that exposes the skill collection as callable query methods. Skill content is embedded at compile time via build.rs so there is no runtime filesystem dependency and no separate data migration needed. - `canisters/skills/src/lib.rs` — three query methods: list_skills(), get_skill(name), search_skills(query); returns SkillSummary / SkillDetail - `canisters/skills/build.rs` — parses SKILL.md frontmatter at build time, embeds names/titles/descriptions/categories/content as static data - `canisters/skills/skills.did` — Candid interface (SkillSummary, SkillDetail) - `canisters/skills/Cargo.toml` — ic-cdk 0.19.0, candid 0.10.22 - `Cargo.toml` — workspace root with release profile - `dfx.json` — dfx config for skills canister + webmcp method descriptions (input for ic-webmcp-codegen dfx when generating /.well-known/webmcp.json) - `.gitignore` — add target/ and Cargo.lock Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + Cargo.toml | 11 +++ canisters/skills/Cargo.toml | 13 +++ canisters/skills/build.rs | 152 ++++++++++++++++++++++++++++++++++++ canisters/skills/skills.did | 22 ++++++ canisters/skills/src/lib.rs | 74 ++++++++++++++++++ dfx.json | 32 ++++++++ 7 files changed, 306 insertions(+) create mode 100644 Cargo.toml create mode 100644 canisters/skills/Cargo.toml create mode 100644 canisters/skills/build.rs create mode 100644 canisters/skills/skills.did create mode 100644 canisters/skills/src/lib.rs create mode 100644 dfx.json diff --git a/.gitignore b/.gitignore index d6be60b..9220ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules +target/ +Cargo.lock dist .DS_Store .vscode/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d2bf5d9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +members = ["canisters/skills"] +resolver = "2" + +[workspace.package] +edition = "2021" + +[profile.release] +opt-level = "z" +lto = true +strip = "symbols" diff --git a/canisters/skills/Cargo.toml b/canisters/skills/Cargo.toml new file mode 100644 index 0000000..91886a0 --- /dev/null +++ b/canisters/skills/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ic-skills-canister" +version = "0.1.0" +edition.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +candid = "0.10.22" +ic-cdk = "0.19.0" +serde = { version = "1", features = ["derive"] } + diff --git a/canisters/skills/build.rs b/canisters/skills/build.rs new file mode 100644 index 0000000..3ed386e --- /dev/null +++ b/canisters/skills/build.rs @@ -0,0 +1,152 @@ +use std::fmt::Write as FmtWrite; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let skills_dir = manifest_dir.join("../../skills"); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR set by Cargo"); + let out_path = Path::new(&out_dir).join("skills_data.rs"); + + // Re-run whenever skills content changes. + println!( + "cargo:rerun-if-changed={}", + skills_dir.canonicalize().unwrap_or(skills_dir.clone()).display() + ); + + let mut entries: Vec<_> = fs::read_dir(&skills_dir) + .expect("skills/ directory must exist") + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let s = name.to_string_lossy(); + !s.starts_with('_') && !s.starts_with('.') && e.path().is_dir() + }) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let mut code = String::new(); + + writeln!( + code, + "struct StaticSkillData {{ + name: &'static str, + title: &'static str, + description: &'static str, + category: &'static str, + license: Option<&'static str>, + compatibility: Option<&'static str>, + content: &'static str, +}}" + ) + .unwrap(); + writeln!(code).unwrap(); + writeln!(code, "static SKILLS_DATA: &[StaticSkillData] = &[").unwrap(); + + for entry in &entries { + let skill_md = entry.path().join("SKILL.md"); + if !skill_md.exists() { + continue; + } + + let raw = fs::read_to_string(&skill_md).unwrap_or_default(); + let (title, description, category, license, compatibility) = parse_frontmatter(&raw); + + writeln!(code, " StaticSkillData {{").unwrap(); + writeln!( + code, + " name: \"{}\",", + escape(&entry.file_name().to_string_lossy()) + ) + .unwrap(); + writeln!(code, " title: \"{}\",", escape(&title)).unwrap(); + writeln!(code, " description: \"{}\",", escape(&description)).unwrap(); + writeln!(code, " category: \"{}\",", escape(&category)).unwrap(); + match license { + Some(l) => writeln!(code, " license: Some(\"{}\"),", escape(&l)).unwrap(), + None => writeln!(code, " license: None,").unwrap(), + } + match compatibility { + Some(c) => writeln!(code, " compatibility: Some(\"{}\"),", escape(&c)).unwrap(), + None => writeln!(code, " compatibility: None,").unwrap(), + } + // Embed content directly to avoid include_str! path resolution across include! boundary. + writeln!(code, " content: \"{}\",", escape(&raw)).unwrap(); + writeln!(code, " }},").unwrap(); + } + + writeln!(code, "];").unwrap(); + + fs::write(&out_path, code).expect("write skills_data.rs"); +} + +fn parse_frontmatter( + content: &str, +) -> (String, String, String, Option, Option) { + let mut title = String::new(); + let mut description = String::new(); + let mut category = String::new(); + let mut license: Option = None; + let mut compatibility: Option = None; + + let Some(rest) = content.strip_prefix("---\n").or_else(|| content.strip_prefix("---\r\n")) else { + return (title, description, category, license, compatibility); + }; + let end = rest.find("\n---").unwrap_or(rest.len()); + let frontmatter = &rest[..end]; + + let mut in_metadata = false; + for line in frontmatter.lines() { + // Detect metadata: block (indented children) + if line == "metadata:" { + in_metadata = true; + continue; + } + if !line.starts_with(' ') && !line.starts_with('\t') && line.contains(':') { + in_metadata = false; + } + + if in_metadata { + let trimmed = line.trim(); + if let Some(v) = trimmed.strip_prefix("title:") { + title = unquote(v.trim()); + } else if let Some(v) = trimmed.strip_prefix("category:") { + category = unquote(v.trim()); + } + } else if let Some(v) = line.strip_prefix("description:") { + description = unquote(v.trim()); + } else if let Some(v) = line.strip_prefix("license:") { + license = Some(unquote(v.trim())); + } else if let Some(v) = line.strip_prefix("compatibility:") { + compatibility = Some(unquote(v.trim())); + } + } + + (title, description, category, license, compatibility) +} + +/// Strip optional surrounding double-quotes from a YAML scalar. +fn unquote(s: &str) -> String { + if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +/// Escape a string for embedding inside a Rust double-quoted string literal. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 16); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => write!(out, "\\u{{{:04x}}}", c as u32).unwrap(), + c => out.push(c), + } + } + out +} diff --git a/canisters/skills/skills.did b/canisters/skills/skills.did new file mode 100644 index 0000000..4f2b4d0 --- /dev/null +++ b/canisters/skills/skills.did @@ -0,0 +1,22 @@ +type SkillSummary = record { + name : text; + title : text; + description : text; + category : text; +}; + +type SkillDetail = record { + name : text; + title : text; + description : text; + category : text; + content : text; + license : opt text; + compatibility : opt text; +}; + +service : { + list_skills : () -> (vec SkillSummary) query; + get_skill : (text) -> (opt SkillDetail) query; + search_skills : (text) -> (vec SkillSummary) query; +}; diff --git a/canisters/skills/src/lib.rs b/canisters/skills/src/lib.rs new file mode 100644 index 0000000..f9c72dc --- /dev/null +++ b/canisters/skills/src/lib.rs @@ -0,0 +1,74 @@ +use candid::CandidType; + +// Generated at compile time from skills/*/SKILL.md by build.rs. +include!(concat!(env!("OUT_DIR"), "/skills_data.rs")); + +#[derive(CandidType, Clone)] +struct SkillSummary { + name: String, + title: String, + description: String, + category: String, +} + +#[derive(CandidType, Clone)] +struct SkillDetail { + name: String, + title: String, + description: String, + category: String, + content: String, + license: Option, + compatibility: Option, +} + +/// List all skills with their names, titles, descriptions, and categories. +#[ic_cdk::query] +fn list_skills() -> Vec { + SKILLS_DATA + .iter() + .map(|s| SkillSummary { + name: s.name.to_string(), + title: s.title.to_string(), + description: s.description.to_string(), + category: s.category.to_string(), + }) + .collect() +} + +/// Get the full documentation for a skill by its name (e.g. "motoko", "ckbtc"). +/// Returns `null` if the skill is not found. +#[ic_cdk::query] +fn get_skill(name: String) -> Option { + SKILLS_DATA.iter().find(|s| s.name == name).map(|s| SkillDetail { + name: s.name.to_string(), + title: s.title.to_string(), + description: s.description.to_string(), + category: s.category.to_string(), + content: s.content.to_string(), + license: s.license.map(str::to_string), + compatibility: s.compatibility.map(str::to_string), + }) +} + +/// Search skills by keyword. Matches against name, title, description, and content. +/// Returns summaries of all matching skills. +#[ic_cdk::query] +fn search_skills(query: String) -> Vec { + let q = query.to_lowercase(); + SKILLS_DATA + .iter() + .filter(|s| { + s.name.to_lowercase().contains(&q) + || s.title.to_lowercase().contains(&q) + || s.description.to_lowercase().contains(&q) + || s.content.to_lowercase().contains(&q) + }) + .map(|s| SkillSummary { + name: s.name.to_string(), + title: s.title.to_string(), + description: s.description.to_string(), + category: s.category.to_string(), + }) + .collect() +} diff --git a/dfx.json b/dfx.json new file mode 100644 index 0000000..e638e74 --- /dev/null +++ b/dfx.json @@ -0,0 +1,32 @@ +{ + "canisters": { + "skills": { + "type": "rust", + "candid": "canisters/skills/skills.did", + "package": "ic-skills-canister", + "webmcp": { + "enabled": true, + "name": "IC Skills", + "description": "Query Internet Computer skill documentation. Covers Motoko, Rust, ckBTC, Internet Identity, stable memory, HTTPS outcalls, EVM RPC, SNS, asset canisters, custom domains, and more.", + "expose_methods": ["list_skills", "get_skill", "search_skills"], + "certified_queries": ["list_skills", "get_skill", "search_skills"], + "descriptions": { + "list_skills": "List all available Internet Computer skill topics with their names, titles, descriptions, and categories", + "get_skill": "Get the full documentation for a specific IC skill by name (e.g. 'motoko', 'asset-canister', 'internet-identity')", + "search_skills": "Search Internet Computer skill documentation by keyword. Returns matching skills with their descriptions." + }, + "param_descriptions": { + "get_skill.name": "Skill name in lowercase-hyphenated format, e.g. 'motoko', 'ckbtc', 'https-outcalls'", + "search_skills.query": "Keyword to match against skill names, titles, descriptions, and full content" + } + } + } + }, + "networks": { + "local": { + "bind": "127.0.0.1:4943", + "type": "ephemeral" + } + }, + "version": 1 +}