Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/skill-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
branches: [main]

permissions:
contents: write # for auto-fix mode (commits the rendered diff back)
contents: read # report-only; the action no longer commits back
pull-requests: write # for the sticky PR comment
actions: write # for the persistent AI skip-cache (v0.3+)

Expand All @@ -19,6 +19,6 @@ jobs:
ref: ${{ github.head_ref }} # check out the PR branch directly
- uses: iii-hq/skills-and-validation@v0.4
with:
write: true
write: false
scope: pr-diff
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
2 changes: 1 addition & 1 deletion iii-directory/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions iii-directory/examples/test_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use iii_directory::fs_source::{scan_prompts, scan_skills};
use iii_directory::sources::registry::{download, VersionSpec};

#[tokio::main]
async fn main() -> Result<(), String> {
let registry =
std::env::var("REGISTRY").unwrap_or_else(|_| "http://localhost:3111".to_string());
let worker = std::env::var("WORKER").unwrap_or_else(|_| "hello-worker".to_string());
let tag = std::env::var("TAG").unwrap_or_else(|_| "latest".to_string());

let tmp = tempfile::tempdir().unwrap();
let skills_folder = tmp.path();

println!("→ Downloading {worker} (tag={tag}) from {registry}");
println!(" skills_folder = {}", skills_folder.display());

let spec = VersionSpec::Tag(tag);
let result = download(&registry, &worker, &spec, skills_folder, 30_000).await?;

println!("\n[download result]");
println!(" namespace = {}", result.namespace);
println!(" skills_written = {:?}", result.skills_written);
println!(" prompts_written = {:?}", result.prompts_written);

let (skills, skill_skipped) = scan_skills(skills_folder);
let (prompts, prompt_skipped) = scan_prompts(skills_folder);

println!("\n[scan_skills]");
for s in &skills {
println!(" id={:<40} path={}", s.id, s.abs_path.display());
}
if !skill_skipped.is_empty() {
println!("\n[skill skips]");
for s in &skill_skipped {
println!(" {} → {}", s.path.display(), s.reason);
}
}

println!("\n[scan_prompts]");
for p in &prompts {
println!(" name={:<30} path={}", p.name, p.abs_path.display());
}
if !prompt_skipped.is_empty() {
println!("\n[prompt skips]");
for s in &prompt_skipped {
println!(" {} → {}", s.path.display(), s.reason);
}
}

println!(
"\nDONE — skills scanned={}, prompts scanned={}",
skills.len(),
prompts.len()
);

// Soft assertions so the example doubles as a smoke test.
let any_index = skills.iter().any(|s| s.id.ends_with("/index"));
if !any_index {
eprintln!("FAIL: no skill with id ending in /index was scanned");
std::process::exit(1);
}
for s in &skills {
if s.id.chars().any(|c| c.is_ascii_uppercase()) {
eprintln!("FAIL: skill id leaked uppercase: {}", s.id);
std::process::exit(1);
}
}
println!("smoke: at least one /index id present, no uppercase leaks");
Ok(())
}
11 changes: 11 additions & 0 deletions iii-directory/skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# iii-directory
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think we need this


Engine introspection, workers registry proxy, and filesystem-backed skill + prompt reader for the iii engine. Every public function sits under a single `directory::*` namespace, split into four MCP-agnostic surfaces (`skills`, `prompts`, `engine`, and `registry`), so callers learn one envelope across local files, the running engine, and the public workers registry.

Skills and prompts are sourced from a single configured folder on disk (`skills_folder`). The only write path is `directory::skills::download`, which pulls markdown into `skills_folder` from either the workers registry or a GitHub repo. `directory::skills::list` returns one row per markdown file with `title` (preferring the YAML frontmatter `title:` over the body H1) and `type` lifted from frontmatter. `directory::skills::get` accepts a bare id, a `<id>.md` file-path form, or the legacy `iii://<id>` URI. `SKILLS.md` is aliased to `index.md` at scan time so the new convention round-trips through both filesystem and parser. `directory::skills::index` renders a short per-worker overview that emits both relative file-path pointers (`Read [<ns>/index.md](<ns>/index.md)`) and the legacy `iii://<ns>/index` form side by side for back-compat.

`directory::skills::download` pulls bundles from the public workers registry (`api.workers.iii.dev` by default). For self-hosted setups, repoint `registry_url` in `config.yaml` at your own registry. The `directory::registry::*` proxies share their envelope with `directory::engine::workers::*`, so a single parser handles both local and remote worker discovery.

```bash
iii worker add iii-directory
```
77 changes: 76 additions & 1 deletion iii-directory/src/fs_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! <skills_folder>/
//! <ns>/ # one folder per download namespace
//! index.md # → iii://<ns>/index
//! SKILLS.md # → iii://<ns>/index (alias of index.md)
//! anything.md # → iii://<ns>/anything
//! deep/path.md # → iii://<ns>/deep/path
//! prompts/ # ← magic marker for prompts
Expand Down Expand Up @@ -164,11 +165,36 @@ fn walk_markdown(base_dir: &Path) -> Result<Vec<(PathBuf, PathBuf)>, String> {
Ok(out)
}

/// Convert a `<skills_folder>`-relative path to a skill id.
///
/// `SKILLS.md` (the literal filename, any case-sensitive match) is
/// treated as an alias for `index.md`, so a file at `<ns>/SKILLS.md`
/// produces the id `<ns>/index`. The alias runs on the final path
/// component only — directories named `SKILLS` are *not* renamed.
fn rel_to_id(rel: &Path) -> Result<String, String> {
let rel_str = rel
.to_str()
.ok_or_else(|| format!("non-UTF-8 path: {}", rel.display()))?;
let stripped = rel_str.strip_suffix(".md").unwrap_or(rel_str);
let aliased = if let Some(parent) = rel.parent() {
let last_is_skills_md = rel
.file_name()
.and_then(|s| s.to_str())
.map(|n| n == "SKILLS.md")
.unwrap_or(false);
if last_is_skills_md {
let parent_str = parent.to_str().unwrap_or("");
if parent_str.is_empty() {
"index.md".to_string()
} else {
format!("{}/index.md", parent_str.replace('\\', "/"))
}
} else {
rel_str.to_string()
}
} else {
rel_str.to_string()
};
let stripped = aliased.strip_suffix(".md").unwrap_or(&aliased).to_string();
Ok(stripped.replace('\\', "/"))
}

Expand Down Expand Up @@ -520,6 +546,55 @@ mod tests {
assert!(skipped.is_empty());
}

#[test]
fn scan_skills_treats_skills_md_as_index_alias() {
let tmp = tempfile::tempdir().unwrap();
let ns = tmp.path().join("resend");
std::fs::create_dir_all(&ns).unwrap();
std::fs::write(ns.join("SKILLS.md"), "# resend\n").unwrap();

let (skills, skipped) = scan_skills(tmp.path());
assert!(skipped.is_empty(), "unexpected skips: {skipped:?}");
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].id, "resend/index");
}

#[test]
fn scan_skills_treats_nested_skills_md_as_index_alias() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("resend/emails");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("SKILLS.md"), "# emails\n").unwrap();

let (skills, skipped) = scan_skills(tmp.path());
assert!(skipped.is_empty(), "unexpected skips: {skipped:?}");
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].id, "resend/emails/index");
}

#[test]
fn scan_skills_skips_one_when_both_index_and_skills_present() {
let tmp = tempfile::tempdir().unwrap();
let ns = tmp.path().join("resend");
std::fs::create_dir_all(&ns).unwrap();
std::fs::write(ns.join("index.md"), "# from index\n").unwrap();
std::fs::write(ns.join("SKILLS.md"), "# from SKILLS\n").unwrap();

let (skills, skipped) = scan_skills(tmp.path());
assert_eq!(skills.len(), 1, "should keep exactly one entry");
assert_eq!(skills[0].id, "resend/index");
assert_eq!(
skipped.len(),
1,
"second entry should be reported as duplicate"
);
assert!(
skipped[0].reason.contains("duplicate id \"resend/index\""),
"expected duplicate-id skip, got: {}",
skipped[0].reason
);
}

// ── scan_prompts ─────────────────────────────────────────────────

#[test]
Expand Down
15 changes: 8 additions & 7 deletions iii-directory/src/functions/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,7 @@ pub async fn worker_info(
input: WorkerInfoInput,
) -> Result<WorkerInfoOutput, String> {
let (name, spec) = classify_worker_info_input(input)?;
let cache_key = format!(
"worker-info:{name}:{}={}",
spec.label(),
spec.query_value()
);
let cache_key = format!("worker-info:{name}:{}={}", spec.label(), spec.query_value());
if let Some(cached) = cache.get::<WorkerInfoOutput>(&cache_key).await {
return Ok(cached);
}
Expand Down Expand Up @@ -618,7 +614,9 @@ fn parse_skills_tree(value: &Value) -> SkillsTree {
.filter_map(|s| {
s.get("path")
.and_then(|p| p.as_str())
.map(|p| SkillsTreeSkill { path: p.to_string() })
.map(|p| SkillsTreeSkill {
path: p.to_string(),
})
})
.collect()
})
Expand Down Expand Up @@ -748,7 +746,10 @@ mod tests {
assert_eq!(out.workers[0].kind.as_deref(), Some("binary"));
assert_eq!(out.workers[0].version.as_deref(), Some("1.2.3"));
assert_eq!(out.workers[0].total_downloads, 42);
assert_eq!(out.workers[0].supported_targets, vec!["x86_64-unknown-linux-gnu".to_string()]);
assert_eq!(
out.workers[0].supported_targets,
vec!["x86_64-unknown-linux-gnu".to_string()]
);
let author = out.workers[0].author.as_ref().unwrap();
assert_eq!(author.name.as_deref(), Some("iii"));
assert!(author.verified);
Expand Down
Loading
Loading