diff --git a/.github/workflows/skill-check.yml b/.github/workflows/skill-check.yml index 49790efa..c499377c 100644 --- a/.github/workflows/skill-check.yml +++ b/.github/workflows/skill-check.yml @@ -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+) @@ -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 }} diff --git a/iii-directory/Cargo.lock b/iii-directory/Cargo.lock index 006b8673..3ef56bfd 100644 --- a/iii-directory/Cargo.lock +++ b/iii-directory/Cargo.lock @@ -1044,7 +1044,7 @@ dependencies = [ [[package]] name = "iii-directory" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "async-trait", diff --git a/iii-directory/examples/test_registry.rs b/iii-directory/examples/test_registry.rs new file mode 100644 index 00000000..be79ba2a --- /dev/null +++ b/iii-directory/examples/test_registry.rs @@ -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(®istry, &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(()) +} diff --git a/iii-directory/skill.md b/iii-directory/skill.md new file mode 100644 index 00000000..5957fb04 --- /dev/null +++ b/iii-directory/skill.md @@ -0,0 +1,11 @@ +# iii-directory + +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 `.md` file-path form, or the legacy `iii://` 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 [/index.md](/index.md)`) and the legacy `iii:///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 +``` diff --git a/iii-directory/src/fs_source.rs b/iii-directory/src/fs_source.rs index c1ad3265..1aeb6044 100644 --- a/iii-directory/src/fs_source.rs +++ b/iii-directory/src/fs_source.rs @@ -7,6 +7,7 @@ //! / //! / # one folder per download namespace //! index.md # → iii:///index +//! SKILLS.md # → iii:///index (alias of index.md) //! anything.md # → iii:///anything //! deep/path.md # → iii:///deep/path //! prompts/ # ← magic marker for prompts @@ -164,11 +165,36 @@ fn walk_markdown(base_dir: &Path) -> Result, String> { Ok(out) } +/// Convert a ``-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 `/SKILLS.md` +/// produces the id `/index`. The alias runs on the final path +/// component only — directories named `SKILLS` are *not* renamed. fn rel_to_id(rel: &Path) -> Result { 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('\\', "/")) } @@ -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] diff --git a/iii-directory/src/functions/registry.rs b/iii-directory/src/functions/registry.rs index 357a886f..4363cfd6 100644 --- a/iii-directory/src/functions/registry.rs +++ b/iii-directory/src/functions/registry.rs @@ -462,11 +462,7 @@ pub async fn worker_info( input: WorkerInfoInput, ) -> Result { 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::(&cache_key).await { return Ok(cached); } @@ -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() }) @@ -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); diff --git a/iii-directory/src/functions/skills.rs b/iii-directory/src/functions/skills.rs index a9651e27..13d2723d 100644 --- a/iii-directory/src/functions/skills.rs +++ b/iii-directory/src/functions/skills.rs @@ -61,8 +61,9 @@ const GET_DESCRIPTION: &str = "Fetch one filesystem-backed skill by id. Returns the raw markdown body plus id, \ title, type, description, and modified_at — same flat shape as directory::prompts::get \ with `type` lifted from the YAML frontmatter and `title` preferring frontmatter \ - over the body H1. Accepts a bare id (e.g. \"directory/skills/list\") or the same \ - id prefixed with iii://."; + over the body H1. Accepts a bare id (e.g. \"directory/skills/list\"), the same id \ + suffixed with `.md` (e.g. \"directory/skills/list.md\"), or either form prefixed \ + with iii://."; #[derive(Debug, Default, Deserialize, JsonSchema)] struct ListSkillsInput {} @@ -97,7 +98,7 @@ struct IndexSkillsOutput { /// Rendered markdown document — one short `## ` block per /// installed worker (skills with frontmatter `type: index`), /// carrying the worker's first-paragraph overview and a read-more - /// link pointing at `iii://<ns>/index`. Sorted lex by id. + /// link pointing at the file path `<ns>/index.md`. Sorted lex by id. body: String, /// Number of worker entries rendered (i.e. the count of /// `type: index` skills that survived the filter). Cheap sanity @@ -108,9 +109,11 @@ struct IndexSkillsOutput { #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct SkillGetInput { /// Skill id (the same string returned by `directory::skills::list`, - /// e.g. `"directory/skills/list"`). The legacy `iii://{id}` form is - /// also accepted for ergonomics; the prefix is stripped before - /// validation. Other URI schemes are rejected. + /// e.g. `"directory/skills/list"`). Two ergonomic variants are also + /// accepted: the file-path form `<id>.md` (the trailing `.md` is + /// stripped) and the legacy `iii://{id}` URI form. Other URI + /// schemes are rejected. The filename `SKILLS.md` is aliased to + /// `index.md` to match the filesystem scanner. pub id: String, } @@ -196,7 +199,7 @@ fn register_index_skills(iii: &Arc<III>, cfg: &Arc<SkillsConfig>) { .description( "Render one short markdown entry per installed worker (skills with frontmatter \ `type: index`). Each entry is a `## <worker title>` heading, the first paragraph \ - of the worker's overview, and a `Read iii://<ns>/index` line the agent can \ + of the worker's overview, and a `Read <ns>/index.md` line the agent can \ follow via `directory::skills::get` for the full reference. Token-light by \ design; for per-skill rows use `directory::skills::list`.", ), @@ -226,23 +229,39 @@ pub async fn get_skill(cfg: &SkillsConfig, req: SkillGetInput) -> Result<SkillGe }) } -/// Trim, strip an optional `iii://` prefix, and reject any other URI -/// scheme. The remaining string still has to satisfy [`validate_id`]; -/// this function only handles the prefix-stripping ergonomics. +/// Trim and strip an optional `iii://` prefix; reject any other URI +/// scheme. Also accepts a file-path form: a trailing `.md` is stripped +/// so callers can paste either `hello-worker/index` or +/// `hello-worker/index.md` and get the same id. The literal filename +/// `SKILLS.md` (final path component) is aliased to `index.md` — same +/// rule the filesystem scanner uses. The remaining string still has to +/// satisfy [`validate_id`]; this function only handles the prefix / +/// suffix ergonomics. pub fn normalize_get_id(raw: &str) -> Result<String, String> { let trimmed = raw.trim(); if trimmed.is_empty() { return Err("id must be non-empty".into()); } - if let Some(rest) = trimmed.strip_prefix(URI_PREFIX) { - return Ok(rest.to_string()); - } - if trimmed.contains("://") { + let without_scheme = if let Some(rest) = trimmed.strip_prefix(URI_PREFIX) { + rest + } else if trimmed.contains("://") { return Err(format!( - "Invalid id (must be a bare skill path or an iii:// URI): {trimmed}" + "Invalid id (must be a bare skill path, a path ending in .md, or an iii:// URI): {trimmed}" )); - } - Ok(trimmed.to_string()) + } else { + trimmed + }; + let aliased = if let Some(stem) = without_scheme.strip_suffix("/SKILLS.md") { + format!("{stem}/index") + } else if without_scheme == "SKILLS.md" { + "index".to_string() + } else { + without_scheme + .strip_suffix(".md") + .unwrap_or(without_scheme) + .to_string() + }; + Ok(aliased) } // ---------- validation ---------- @@ -366,9 +385,13 @@ pub fn extract_description(markdown: &str) -> Option<String> { /// /// <first paragraph from the worker's overview> /// -/// Read [`iii://<id>`](iii://<id>) for the full worker reference. +/// Read [`<id>.md`](<id>.md) (legacy `iii://<id>`) for the full worker reference. /// ``` /// +/// The legacy `iii://<id>` form is emitted alongside the file-path +/// pointer so harnesses that grep for the old URI scheme keep working +/// while new consumers prefer the markdown link target. +/// /// The description block is omitted (no extra blank line) when the /// overview body has no paragraph. Entries must already be sorted lex /// by `id` (the order `fs_source::scan_skills` returns); this function @@ -392,7 +415,7 @@ fn render_index_markdown(entries: &[SkillEntry]) -> String { } out.push('\n'); out.push_str(&format!( - "Read [`iii://{id}`](iii://{id}) for the full worker reference.\n", + "Read [`{id}.md`]({id}.md) (legacy `iii://{id}`) for the full worker reference.\n", id = worker.id )); } @@ -493,6 +516,72 @@ mod tests { assert!(normalize_get_id("ftp://nope").is_err()); } + #[test] + fn normalize_strips_md_suffix_on_bare_path() { + assert_eq!( + normalize_get_id("hello-worker/index.md").unwrap(), + "hello-worker/index" + ); + } + + #[test] + fn normalize_aliases_skills_md_to_index() { + assert_eq!( + normalize_get_id("hello-worker/SKILLS.md").unwrap(), + "hello-worker/index" + ); + } + + #[test] + fn normalize_aliases_nested_skills_md_to_index() { + assert_eq!( + normalize_get_id("resend/emails/SKILLS.md").unwrap(), + "resend/emails/index" + ); + } + + #[test] + fn normalize_strips_md_after_iii_prefix() { + assert_eq!( + normalize_get_id("iii://hello-worker/index.md").unwrap(), + "hello-worker/index" + ); + } + + #[test] + fn normalize_does_not_strip_md_in_middle_of_path() { + // ".md" inside a segment is a real id, not a file suffix. + assert_eq!( + normalize_get_id("hello-worker/index_md").unwrap(), + "hello-worker/index_md" + ); + } + + // ── iii:// back-compat ───────────────────────────────────────────── + + #[test] + fn normalize_iii_prefix_with_skills_md_aliases_to_index() { + // `iii://` + `SKILLS.md` filename composes through both transforms. + assert_eq!(normalize_get_id("iii://ns/SKILLS.md").unwrap(), "ns/index"); + } + + #[test] + fn normalize_iii_prefix_with_nested_skills_md_aliases_to_index() { + assert_eq!( + normalize_get_id("iii://resend/emails/SKILLS.md").unwrap(), + "resend/emails/index" + ); + } + + #[test] + fn normalize_iii_prefix_round_trips_with_render_emitted_id() { + // The `iii://<id>` token render_index_markdown emits for the + // legacy-pointer footer must parse back through normalize_get_id + // without modification. + let emitted = "iii://agent-memory/index"; + assert_eq!(normalize_get_id(emitted).unwrap(), "agent-memory/index"); + } + // ── validate_id: happy paths ──────────────────────────────────────── #[test] @@ -882,7 +971,7 @@ mod tests { ); // Filtered-out skills must not leak into the read-more pointers either. assert!( - !body.contains("iii://agent-memory/observe"), + !body.contains("agent-memory/observe.md"), "filtered-out how-to leaked a link; got: {body}" ); assert!(body.contains("1 worker(s).\n"), "wrong count; got: {body}"); @@ -941,7 +1030,7 @@ mod tests { )]); assert!( body.contains( - "Read [`iii://agent-memory/index`](iii://agent-memory/index) for the full worker reference.\n" + "Read [`agent-memory/index.md`](agent-memory/index.md) (legacy `iii://agent-memory/index`) for the full worker reference.\n" ), "missing dive-deeper pointer; got: {body}" ); @@ -958,7 +1047,7 @@ mod tests { // Title comes immediately before the read-more line — no extra // blank paragraph in the middle. assert!( - body.contains("\n## bare\n\nRead [`iii://bare/index`](iii://bare/index)"), + body.contains("\n## bare\n\nRead [`bare/index.md`](bare/index.md)"), "blank-description block should compress; got: {body}" ); // And the rest of the document still has the header. @@ -982,4 +1071,25 @@ mod tests { "headings out of order; got: {body}" ); } + + #[test] + fn render_index_emits_both_file_path_and_iii_pointer() { + let entries = vec![SkillEntry { + id: "agent-memory/index".into(), + title: "agent-memory".into(), + kind: Some("index".into()), + description: "Memory worker overview.".into(), + bytes: 10, + modified_at: String::new(), + }]; + let body = render_index_markdown(&entries); + assert!( + body.contains("[`agent-memory/index.md`](agent-memory/index.md)"), + "expected file-path pointer, got:\n{body}" + ); + assert!( + body.contains("legacy `iii://agent-memory/index`"), + "expected legacy iii:// pointer for back-compat, got:\n{body}" + ); + } } diff --git a/iii-directory/tests/features/read.feature b/iii-directory/tests/features/read.feature index e4106de2..0749ff07 100644 --- a/iii-directory/tests/features/read.feature +++ b/iii-directory/tests/features/read.feature @@ -203,7 +203,93 @@ Feature: filesystem-backed reads (directory::skills::list / directory::skills::g And the index response body contains "## team-b" And the index response body contains "Alpha team's worker. Owns alpha-only tooling." And the index response body contains "Bravo team's worker. Handles all bravo workflows." - And the index response body contains "Read [`iii://team-a/index`](iii://team-a/index) for the full worker reference." - And the index response body contains "Read [`iii://team-b/index`](iii://team-b/index) for the full worker reference." + And the index response body contains "Read [`team-a/index.md`](team-a/index.md) (legacy `iii://team-a/index`) for the full worker reference." + And the index response body contains "Read [`team-b/index.md`](team-b/index.md) (legacy `iii://team-b/index`) for the full worker reference." And the index response body does not contain "team-a/foo" And the index response body does not contain "## Foo" + + # ── SKILLS.md alias (case-sensitive entry-point) ──────────────────── + + Scenario: SKILLS.md at namespace root is aliased to <ns>/index + Given a skill file at "ns-skills/SKILLS.md" with body: + """ + # ns-skills entry-point + + Body served via the SKILLS.md alias. + """ + When I list skills + Then the listing has an entry with id "ns-skills/index" + And no listing entry has id "ns-skills/SKILLS" + + Scenario: nested SKILLS.md is aliased to <ns>/<sub>/index + Given a skill file at "ns-nested/section/SKILLS.md" with body: + """ + # nested entry-point + + Body for nested SKILLS.md. + """ + When I list skills + Then the listing has an entry with id "ns-nested/section/index" + + # ── directory::skills::get accepts the file-path forms ────────────── + + Scenario: directory::skills::get accepts the <id>.md file-path form + Given a skill file at "ns-path/lookup.md" with body: + """ + # Lookup via path + + Body for the path-form lookup. + """ + When I get skill "ns-path/lookup.md" + Then the get response has id "ns-path/lookup" + And the get response body contains "Body for the path-form lookup." + + Scenario: directory::skills::get accepts the SKILLS.md filename + Given a skill file at "ns-skills-get/SKILLS.md" with body: + """ + # ns-skills-get entry-point + + Body served via SKILLS.md lookup. + """ + When I get skill "ns-skills-get/SKILLS.md" + Then the get response has id "ns-skills-get/index" + And the get response body contains "Body served via SKILLS.md lookup." + + Scenario: directory::skills::get accepts the iii:// + .md combined form + Given a skill file at "ns-combo/lookup.md" with body: + """ + # Combo + + Body for combined-form lookup. + """ + When I get skill "iii://ns-combo/lookup.md" + Then the get response has id "ns-combo/lookup" + And the get response body contains "Body for combined-form lookup." + + Scenario: directory::skills::get accepts the iii:// + SKILLS.md combined form + Given a skill file at "ns-combo-skills/SKILLS.md" with body: + """ + # Combo SKILLS + + Body for combined iii:// + SKILLS.md lookup. + """ + When I get skill "iii://ns-combo-skills/SKILLS.md" + Then the get response has id "ns-combo-skills/index" + And the get response body contains "Body for combined iii:// + SKILLS.md lookup." + + Scenario: the legacy iii:// pointer emitted by skills::index round-trips through skills::get + Given a skill file at "round-trip/index.md" with body: + """ + --- + title: round-trip + type: index + --- + # round-trip + + Body served via the legacy iii:// pointer. + """ + When I index skills + Then the index response body contains "(legacy `iii://round-trip/index`)" + When I get skill "iii://round-trip/index" + Then the get response has id "round-trip/index" + And the get response body contains "Body served via the legacy iii:// pointer."