Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules
target/
Cargo.lock
dist
.DS_Store
.vscode/
Expand Down
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[workspace]
members = ["canisters/skills"]
resolver = "2"

[workspace.package]
edition = "2021"

[profile.release]
opt-level = "z"
lto = true
strip = "symbols"
13 changes: 13 additions & 0 deletions canisters/skills/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }

152 changes: 152 additions & 0 deletions canisters/skills/build.rs
Original file line number Diff line number Diff line change
@@ -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<String>, Option<String>) {
let mut title = String::new();
let mut description = String::new();
let mut category = String::new();
let mut license: Option<String> = None;
let mut compatibility: Option<String> = 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
}
22 changes: 22 additions & 0 deletions canisters/skills/skills.did
Original file line number Diff line number Diff line change
@@ -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;
};
74 changes: 74 additions & 0 deletions canisters/skills/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
compatibility: Option<String>,
}

/// List all skills with their names, titles, descriptions, and categories.
#[ic_cdk::query]
fn list_skills() -> Vec<SkillSummary> {
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<SkillDetail> {
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<SkillSummary> {
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()
}
32 changes: 32 additions & 0 deletions dfx.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading