diff --git a/Cargo.lock b/Cargo.lock index 68ecef5..b068844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -445,6 +457,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -484,6 +505,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -544,6 +590,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + [[package]] name = "cc" version = "1.2.60" @@ -623,6 +678,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -716,6 +772,30 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "comrak" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466" +dependencies = [ + "bon", + "caseless", + "clap", + "emojis", + "entities", + "finl_unicode", + "fmt2io", + "jetscii", + "phf", + "phf_codegen", + "rustc-hash", + "shell-words", + "smallvec", + "syntect", + "typed-arena", + "xdg", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -931,6 +1011,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "dtoa-short", + "itoa", + "smallvec", +] + [[package]] name = "darling" version = "0.20.11" @@ -1143,6 +1234,21 @@ dependencies = [ "const-random", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1164,6 +1270,15 @@ dependencies = [ "serde", ] +[[package]] +name = "emojis" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4" +dependencies = [ + "phf", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1179,6 +1294,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + [[package]] name = "equivalent" version = "1.0.2" @@ -1224,6 +1345,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fancy-regex" version = "0.18.0" @@ -1298,12 +1430,14 @@ dependencies = [ name = "fastskill-core" version = "0.9.150" dependencies = [ + "ammonia", "anyhow", "assert_cmd", "async-trait", "axum", "chrono", "cli-framework", + "comrak", "config", "dirs 6.0.0", "futures", @@ -1366,6 +1500,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "flate2" version = "1.1.9" @@ -1396,6 +1536,12 @@ dependencies = [ "serde", ] +[[package]] +name = "fmt2io" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b6129284da9f7e5296cc22183a63f24300e945e297705dcc0672f7df01d62c8" + [[package]] name = "fnv" version = "1.0.7" @@ -1780,6 +1926,16 @@ dependencies = [ "digest", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "0.2.12" @@ -2252,6 +2408,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + [[package]] name = "jni" version = "0.22.4" @@ -2344,7 +2506,7 @@ dependencies = [ "bytecount", "data-encoding", "email_address", - "fancy-regex", + "fancy-regex 0.18.0", "fraction", "getrandom 0.3.4", "idna", @@ -2424,6 +2586,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2484,6 +2652,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2595,6 +2780,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "newline-converter" version = "0.3.0" @@ -2745,6 +2936,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.11.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "openssl" version = "0.10.81" @@ -2920,6 +3133,45 @@ dependencies = [ "sha2", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.4.1", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2938,6 +3190,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2962,6 +3227,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "3.1.4" @@ -3033,6 +3304,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3985,6 +4265,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -4051,6 +4337,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -4120,6 +4412,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4169,6 +4485,28 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex 0.16.2", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -4224,6 +4562,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" @@ -4286,10 +4643,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -4298,6 +4657,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -4680,6 +5049,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -4716,6 +5091,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -5039,6 +5423,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -5536,6 +5932,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + [[package]] name = "xz2" version = "0.1.7" @@ -5545,6 +5947,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yaml-rust2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index e86be5d..c4fa1ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,10 @@ include_dir = "0.7" # Semantic versioning semver = "1.0" +# Server-side Markdown rendering (SKILL.md v2 HTML preview) + HTML sanitization +comrak = "0.54.0" +ammonia = "4.1.4" + # AWS S3 storage (for registry publishing) aws-sdk-s3 = "1" aws-config = "1" diff --git a/crates/fastskill-core/Cargo.toml b/crates/fastskill-core/Cargo.toml index 61fe45b..bc7a69e 100644 --- a/crates/fastskill-core/Cargo.toml +++ b/crates/fastskill-core/Cargo.toml @@ -85,6 +85,10 @@ include_dir.workspace = true # Semantic versioning semver.workspace = true +# Server-side Markdown rendering (SKILL.md v2 HTML preview) + HTML sanitization +comrak.workspace = true +ammonia.workspace = true + [dev-dependencies] tempfile.workspace = true wiremock = "0.5" diff --git a/crates/fastskill-core/src/http/handlers/registry.rs b/crates/fastskill-core/src/http/handlers/registry.rs index 3bc257f..d485f2f 100644 --- a/crates/fastskill-core/src/http/handlers/registry.rs +++ b/crates/fastskill-core/src/http/handlers/registry.rs @@ -410,6 +410,63 @@ impl SourceDefinition { } } +/// GET /api/v1/registry/skills/{id}/versions - Versions available for a skill +/// id across the configured sources (spec 003 v2 / Phase 4 version picker). +/// A pure read; always mounted (NOT write-gated). Sourced from +/// `PackageResolver::get_available_versions`, built over the same +/// `SourcesManager` the other `/registry/*` browse routes use, and sorted +/// descending (newest first) by the same semver ordering `VersionConstraint` +/// uses (unparseable versions sort lowest). No registry configured, or an +/// unknown id, is an empty `versions` list — a valid answer, never a 404. +pub async fn list_skill_versions( + State(state): State, + Path(skill_id): Path, +) -> HttpResult>> { + let empty = || { + Json(ApiResponse::success(SkillVersionsResponse { + id: skill_id.clone(), + versions: Vec::new(), + })) + }; + + let repo_manager = get_repository_manager(&state.service); + let (sources_manager, _sources_tmp) = match get_sources_manager_from_repos(&repo_manager).await + { + Ok(pair) => pair, + Err(e) => { + tracing::warn!("versions lookup: failed to build sources manager: {}", e); + return Ok(empty()); + } + }; + + let mut resolver = + crate::core::resolver::PackageResolver::new(std::sync::Arc::new(sources_manager)); + if let Err(e) = resolver.build_index().await { + tracing::warn!("versions lookup: failed to build registry index: {}", e); + return Ok(empty()); + } + + let mut candidates = resolver.get_available_versions(&skill_id); + candidates.sort_by(|a, b| { + semver::Version::parse(&b.version) + .ok() + .cmp(&semver::Version::parse(&a.version).ok()) + }); + + let versions = candidates + .into_iter() + .map(|c| VersionInfo { + version: c.version.clone(), + repo: Some(c.source_name.clone()), + }) + .collect(); + + Ok(Json(ApiResponse::success(SkillVersionsResponse { + id: skill_id, + versions, + }))) +} + /// GET /api/v1/registry/index/skills - List all skills from the registry index /// Query parameters: /// - scope: Filter by scope (optional) diff --git a/crates/fastskill-core/src/http/handlers/skills.rs b/crates/fastskill-core/src/http/handlers/skills.rs index 2499124..8ee9aae 100644 --- a/crates/fastskill-core/src/http/handlers/skills.rs +++ b/crates/fastskill-core/src/http/handlers/skills.rs @@ -2,12 +2,14 @@ use crate::core::install::{AddMode, UpdatePreflight}; use crate::core::manifest::SkillProjectToml; +use crate::core::origin::Origin; use crate::core::service::ServiceError; +use crate::core::version::VersionConstraint; use crate::http::errors::{HttpError, HttpResult}; use crate::http::handlers::AppState; use crate::http::models::*; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, Json, }; @@ -95,6 +97,7 @@ pub async fn get_skill( pub async fn get_skill_content( State(state): State, Path(skill_id): Path, + Query(query): Query, ) -> HttpResult>> { let skill_id_parsed = crate::core::service::SkillId::new(skill_id.clone()) .map_err(|_| HttpError::BadRequest("Invalid skill ID format".to_string()))?; @@ -148,12 +151,29 @@ pub async fn get_skill_content( .or_else(|| confined.file_name().map(std::path::PathBuf::from)) .unwrap_or_else(|| confined.clone()); + let format = query.format.unwrap_or_default(); + let rendered_content = match format { + ContentFormat::Raw => content, + ContentFormat::Html => render_skill_markdown_html(&content), + }; + Ok(axum::Json(ApiResponse::success(SkillContentResponse { path: display_path.to_string_lossy().to_string(), - content, + format: format.as_str().to_string(), + content: rendered_content, }))) } +/// Render `SKILL.md` Markdown to sanitized HTML (spec 003 v2 / Phase 4 §5 +/// sanitized preview). Server-side rendering via `comrak` followed by +/// allowlist sanitization via `ammonia::clean` — the result is safe to assign +/// to `innerHTML`: no `\n\n\ + \n\n\ + [click me](javascript:alert(1))\n", + ) + .unwrap(); + + let (status, body) = do_get(f.state, "/skills/alpha-skill/content?format=html").await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert!(body.contains("\"format\":\"html\""), "body: {body}"); + assert!(!body.contains(" Vec { + use std::io::Write; + use zip::write::FileOptions; + let mut buf = Vec::new(); + { + let cursor = std::io::Cursor::new(&mut buf); + let mut writer = zip::ZipWriter::new(cursor); + let opts = FileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer + .start_file(format!("{skill_dir_name}/SKILL.md"), opts) + .unwrap(); + writer.write_all(skill_md.as_bytes()).unwrap(); + writer.finish().unwrap(); + } + buf +} + +#[tokio::test] +async fn update_version_pin_happy_path_repository_origin() { + use fastskill_core::core::registry::client::IndexEntry; + use fastskill_core::core::repository::{ + RepositoryConfig, RepositoryDefinition, RepositoryManager, RepositoryType, + }; + use sha2::Digest; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let storage = TempDir::new().unwrap(); + let store = skills_root(&storage); + let project = TempDir::new().unwrap(); + let project_file_path = project.path().join("skill-project.toml"); + fs::write( + &project_file_path, + format!( + "[tool.fastskill]\nskills_directory = \"{}\"\n\n[dependencies]\n\ + widget = {{ origin = {{ type = \"repository\", repo = \"myreg\", skill = \"widget\" }} }}\n", + store.display() + ), + ) + .unwrap(); + + let server = MockServer::start().await; + let zip_bytes = build_zip_with_skill_md( + "widget", + "---\nname: widget\nversion: \"2.0.0\"\ndescription: A registry skill\n---\nBody\n", + ); + let cksum = format!("sha256:{:x}", sha2::Sha256::digest(&zip_bytes)); + + let entries = [ + IndexEntry { + name: "widget".to_string(), + vers: "1.0.0".to_string(), + deps: vec![], + cksum: "sha256:0".to_string(), + features: std::collections::HashMap::new(), + yanked: false, + links: None, + download_url: format!("{}/dl/1.0.0", server.uri()), + metadata: None, + }, + IndexEntry { + name: "widget".to_string(), + vers: "2.0.0".to_string(), + deps: vec![], + cksum, + features: std::collections::HashMap::new(), + yanked: false, + links: None, + download_url: format!("{}/dl/2.0.0", server.uri()), + metadata: None, + }, + ]; + let body = entries + .iter() + .map(|e| serde_json::to_string(e).unwrap()) + .collect::>() + .join("\n"); + + Mock::given(method("GET")) + .and(path("/index/widget")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/dl/2.0.0")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(zip_bytes)) + .mount(&server) + .await; + + let repo_manager = RepositoryManager::from_definitions(vec![RepositoryDefinition { + name: "myreg".to_string(), + repo_type: RepositoryType::HttpRegistry, + priority: 0, + config: RepositoryConfig::HttpRegistry { + index_url: format!("{}/index", server.uri()), + }, + auth: None, + storage: None, + }]); + + let config = ServiceConfig { + skill_storage_path: store.clone(), + ..Default::default() + }; + let mut svc = FastSkillService::new(config).await.unwrap(); + svc.initialize().await.unwrap(); + let svc = svc + .with_project_root(project.path().to_path_buf()) + .with_repository_manager(Arc::new(repo_manager)); + let service = Arc::new(svc); + + let mut state = AppState::new(service).unwrap(); + state.project_file_path = project_file_path.clone(); + state.project_root = project.path().to_path_buf(); + state.skills_directory = store; + state.enable_write = true; + + let (status, body) = post_json( + state, + "/skills/update", + serde_json::json!({"skillId": "widget", "version": "2.0.0"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert!(body.contains("\"outcome\":\"updated\""), "body: {body}"); + assert!( + body.contains("\"resolvedVersion\":\"2.0.0\""), + "body: {body}" + ); + + // The manifest now records the exact pin, not the original unconstrained origin. + let saved = fs::read_to_string(&project_file_path).unwrap(); + assert!(saved.contains("=2.0.0"), "manifest: {saved}"); +} + // --------------------------------------------------------------------------- // status.rs (status endpoint + root dashboard incl. SEC-7 escaping) // --------------------------------------------------------------------------- @@ -933,6 +1172,101 @@ async fn registry_marketplace_unknown_is_404() { assert_eq!(status, StatusCode::NOT_FOUND); } +// ---- list_skill_versions (spec 003 v2 / Phase 4 version picker) ---- + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn registry_skill_versions_empty_when_no_registry_configured() { + // No skill-project.toml with `[[tool.fastskill.repositories]]` reachable + // from cwd -> no sources -> empty `versions`, still 200 (never 404). + // `get_repository_manager` resolves off the process cwd, which is shared + // process-wide state; hold `DIR_MUTEX` so this can't race the other + // `registry_skill_versions_*` tests that `set_current_dir` into a temp + // project with repositories configured. + let _lock = fastskill_core::test_utils::DIR_MUTEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + let f = fixture_with_skills(false).await; + let (status, body) = do_get(f.state, "/registry/skills/widget/versions").await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert!(body.contains("\"id\":\"widget\""), "body: {body}"); + assert!(body.contains("\"versions\":[]"), "body: {body}"); +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn registry_skill_versions_unknown_id_is_empty_not_404() { + let _lock = fastskill_core::test_utils::DIR_MUTEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + let original_dir = std::env::current_dir().ok(); + let _guard = fastskill_core::test_utils::DirGuard(original_dir); + + let project = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + write_skill(repo_dir.path(), "known", "Known", "d"); + fs::write( + project.path().join("skill-project.toml"), + format!( + "[dependencies]\n\n[[tool.fastskill.repositories]]\nname = \"localrepo\"\ntype = \"local\"\npath = \"{}\"\npriority = 0\n", + repo_dir.path().display() + ), + ) + .unwrap(); + std::env::set_current_dir(project.path()).unwrap(); + + let f = fixture_with_skills(false).await; + let (status, body) = do_get(f.state, "/registry/skills/does-not-exist/versions").await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert!(body.contains("\"versions\":[]"), "body: {body}"); +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn registry_skill_versions_populated_and_sorted_descending() { + let _lock = fastskill_core::test_utils::DIR_MUTEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + let original_dir = std::env::current_dir().ok(); + let _guard = fastskill_core::test_utils::DirGuard(original_dir); + + let project = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + // Two versions of the same skill id, in separate subdirectories, discovered + // by the local-source scanner (walks for any nested SKILL.md). + for (subdir, version) in [("v1", "1.0.0"), ("v9", "1.9.0"), ("v10", "1.10.0")] { + let dir = repo_dir.path().join(subdir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("SKILL.md"), + format!("---\nid: widget\nname: widget\nversion: {version}\ndescription: d\n---\n"), + ) + .unwrap(); + } + fs::write( + project.path().join("skill-project.toml"), + format!( + "[dependencies]\n\n[[tool.fastskill.repositories]]\nname = \"localrepo\"\ntype = \"local\"\npath = \"{}\"\npriority = 0\n", + repo_dir.path().display() + ), + ) + .unwrap(); + std::env::set_current_dir(project.path()).unwrap(); + + let f = fixture_with_skills(false).await; + let (status, body) = do_get(f.state, "/registry/skills/widget/versions").await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert!(body.contains("\"id\":\"widget\""), "body: {body}"); + // Descending, semver-correct (not string) order: 1.10.0 before 1.9.0 before 1.0.0. + let pos_10 = body.find("1.10.0").expect("1.10.0 present"); + let pos_9 = body.find("1.9.0").expect("1.9.0 present"); + let pos_1 = body.find("1.0.0").expect("1.0.0 present"); + assert!( + pos_10 < pos_9 && pos_9 < pos_1, + "expected descending semver order: {body}" + ); +} + // ---- registry index (list_index_skills) ---- async fn state_with_registry(registry: &TempDir) -> AppState {