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 +}