From ccaa51f606b66738ad08c015f6e05b3cc6f94772 Mon Sep 17 00:00:00 2001 From: Alex Oliveira <4482374+aroff@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:45:08 +0000 Subject: [PATCH 1/2] feat(ui): SKILL.md rendered/raw toggle + repository version picker in drawer (spec 003 v2) Adds two additive drawer features on top of the existing browser console: a Raw/Rendered toggle for SKILL.md (Rendered fetches ?format=html, trusting the server's comrak+ammonia sanitization for the one intentional innerHTML assignment), and a version picker for repository-origin skills only (fetches /registry/skills/{id}/versions, applies via POST /skills/update with { skillId, version }), gated on the existing writable capability. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0153XpJc4DrMRAJJqVDchxRm --- crates/fastskill-core/src/http/static/app.js | 225 +++++++++++++++++- .../fastskill-core/src/http/static/styles.css | 154 ++++++++++++ 2 files changed, 369 insertions(+), 10 deletions(-) diff --git a/crates/fastskill-core/src/http/static/app.js b/crates/fastskill-core/src/http/static/app.js index f34e592..253374a 100644 --- a/crates/fastskill-core/src/http/static/app.js +++ b/crates/fastskill-core/src/http/static/app.js @@ -489,6 +489,21 @@ class FastSkillApp { ` : `origin—`; + // Version picker (v2): only repository-origin skills carry a version + // index to pick from — git/local/zip-url are versionless (spec 003 + // §3, ADR-0004 scope) and render nothing here. + const isRepository = !!(origin && origin.type === 'repository'); + const versionSection = isRepository + ? ` +

Version

+
+ + +

Loading versions…

+
+ ` + : ''; + body.innerHTML = ` @@ -497,33 +512,222 @@ class FastSkillApp { ${originRows}
id${this.escapeHtml(skill.id)}
author${this.escapeHtml(meta.author || '—')}
+ ${versionSection}

SKILL.md

+
+ + +
Loading…
+ `; drawer.classList.add('open'); drawer.setAttribute('aria-hidden', 'false'); this._openSkillId = skill.id; + // format -> fetched content, so toggling Raw/Rendered back and forth + // doesn't refetch on every click. + this._contentCache = { raw: null, rendered: null }; + + const rawBtn = document.getElementById('drawer-format-raw'); + const renderedBtn = document.getElementById('drawer-format-rendered'); + if (rawBtn) rawBtn.addEventListener('click', () => this.setContentFormat(skill.id, 'raw')); + if (renderedBtn) renderedBtn.addEventListener('click', () => this.setContentFormat(skill.id, 'rendered')); + + if (isRepository) { + const versionApplyBtn = document.getElementById('drawer-version-apply'); + if (versionApplyBtn) versionApplyBtn.addEventListener('click', () => this.applyVersionChange(skill)); + this.loadVersionPicker(skill); + } + + // Raw is selected by default (unchanged v1 behavior). + this.setContentFormat(skill.id, 'raw'); + } + + // Switches the drawer's SKILL.md view between the escaped raw
 and
+    // the server-rendered/sanitized markdown container, fetching content for
+    // that format on first use and reusing the cache afterward.
+    setContentFormat(skillId, format) {
+        this._contentFormat = format;
+        const rawBtn = document.getElementById('drawer-format-raw');
+        const renderedBtn = document.getElementById('drawer-format-rendered');
+        const contentEl = document.getElementById('drawer-content');
+        const renderedEl = document.getElementById('drawer-content-rendered');
+        if (rawBtn) rawBtn.classList.toggle('active', format === 'raw');
+        if (renderedBtn) renderedBtn.classList.toggle('active', format === 'rendered');
+        if (contentEl) contentEl.hidden = format !== 'raw';
+        if (renderedEl) renderedEl.hidden = format !== 'rendered';
+        this.loadDrawerContent(skillId, format);
+    }
+
+    async loadDrawerContent(skillId, format) {
+        const cache = this._contentCache || (this._contentCache = { raw: null, rendered: null });
+        if (cache[format] != null) {
+            this.applyContentToDom(format, cache[format]);
+            return;
+        }
+
+        const contentEl = document.getElementById('drawer-content');
+        const renderedEl = document.getElementById('drawer-content-rendered');
+        if (format === 'raw') {
+            if (contentEl) contentEl.textContent = 'Loading…';
+        } else if (renderedEl) {
+            renderedEl.textContent = 'Loading…';
+        }
 
         try {
-            const res = await fetch(`${this.apiBase}/skills/${encodeURIComponent(skill.id)}/content`);
+            const url = format === 'rendered'
+                ? `${this.apiBase}/skills/${encodeURIComponent(skillId)}/content?format=html`
+                : `${this.apiBase}/skills/${encodeURIComponent(skillId)}/content`;
+            const res = await fetch(url);
             const data = await res.json().catch(() => null);
             // The drawer may have been closed/reopened on a different skill
             // while this request was in flight.
-            if (this._openSkillId !== skill.id) return;
-            const contentEl = document.getElementById('drawer-content');
-            if (!contentEl) return;
+            if (this._openSkillId !== skillId) return;
+
             if (res.ok && data && data.success && data.data) {
-                // textContent, not innerHTML: the SKILL.md body is untrusted
-                // content (SEC-7) and must never be interpreted as markup.
-                contentEl.textContent = data.data.content != null ? data.data.content : '';
+                const value = data.data.content != null ? data.data.content : '';
+                cache[format] = value;
+                this.applyContentToDom(format, value);
             } else {
-                contentEl.textContent = `Error loading content: ${(data && data.error && data.error.message) || res.status}`;
+                const msg = `Error loading content: ${(data && data.error && data.error.message) || res.status}`;
+                if (format === 'raw') { if (contentEl) contentEl.textContent = msg; }
+                else if (renderedEl) renderedEl.textContent = msg;
             }
         } catch (err) {
+            if (this._openSkillId !== skillId) return;
+            const msg = `Error: ${err.message}`;
+            if (format === 'raw') { if (contentEl) contentEl.textContent = msg; }
+            else if (renderedEl) renderedEl.textContent = msg;
+        }
+    }
+
+    applyContentToDom(format, value) {
+        const contentEl = document.getElementById('drawer-content');
+        const renderedEl = document.getElementById('drawer-content-rendered');
+        if (format === 'raw') {
+            if (!contentEl) return;
+            // textContent, not innerHTML: the SKILL.md raw body is untrusted
+            // content (SEC-7) and must never be interpreted as markup.
+            contentEl.textContent = value;
+            return;
+        }
+        if (!renderedEl) return;
+        // INTENTIONAL innerHTML, NOT a SEC-7 violation: `value` here is the
+        // *rendered* markdown response (`?format=html`), which the Rust
+        // server already ran through comrak + an ammonia allowlist
+        // sanitizer before sending it — it is not the raw untrusted
+        // SKILL.md text. Re-escaping it here would print literal `

` + // tags as text instead of rendering them. Do NOT change this to + // textContent/escapeHtml; if you need to distrust this value, fix + // the sanitization server-side instead. + renderedEl.innerHTML = value; + // Extra client-side safety net on top of server sanitization: force + // every link to open in a new tab without leaking a window.opener + // handle back to the linked page. Operates on the already-sanitized + // DOM (not on raw untrusted text), so this is safe regardless of + // what the server did or didn't set. + renderedEl.querySelectorAll('a[href]').forEach((a) => { + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + }); + } + + // ---- version picker (repository-origin skills only) ------------------- + + async loadVersionPicker(skill) { + const section = document.getElementById('drawer-version-section'); + if (!section) return; + + const select = document.getElementById('drawer-version-select'); + const applyBtn = document.getElementById('drawer-version-apply'); + const note = document.getElementById('drawer-version-note'); + const writable = !!(this.status && this.status.writable); + + try { + const res = await fetch(`${this.apiBase}/registry/skills/${encodeURIComponent(skill.id)}/versions`); + const data = await res.json().catch(() => null); if (this._openSkillId !== skill.id) return; - const contentEl = document.getElementById('drawer-content'); - if (contentEl) contentEl.textContent = `Error: ${err.message}`; + + const versions = (res.ok && data && data.success && data.data && Array.isArray(data.data.versions)) + ? data.data.versions + : []; + + if (versions.length === 0) { + if (select) select.hidden = true; + if (applyBtn) applyBtn.hidden = true; + if (note) { + note.hidden = false; + note.textContent = 'No other versions available'; + } + return; + } + + const meta = skill.metadata || {}; + const origin = meta.origin || {}; + const current = origin.version || meta.version || null; + + if (select) { + select.innerHTML = versions.map((v) => { + const val = this.escapeHtml(v.version); + const selected = current && v.version === current ? ' selected' : ''; + return ``; + }).join(''); + select.disabled = !writable; + select.hidden = false; + } + if (applyBtn) { + applyBtn.disabled = !writable; + applyBtn.hidden = false; + } + if (note) note.hidden = true; + } catch (err) { + if (this._openSkillId !== skill.id) return; + if (select) select.hidden = true; + if (applyBtn) applyBtn.hidden = true; + if (note) { + note.hidden = false; + note.textContent = `Error loading versions: ${err.message}`; + } + } + } + + async applyVersionChange(skill) { + if (!(this.status && this.status.writable)) { + this.toast('Read-only — start with --enable-write to update skills', 'error'); + return; + } + + const select = document.getElementById('drawer-version-select'); + const applyBtn = document.getElementById('drawer-version-apply'); + if (!select || !select.value) return; + + if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Applying…'; } + try { + const res = await fetch(`${this.apiBase}/skills/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skillId: skill.id, version: select.value }), + }); + const data = await res.json().catch(() => null); + if (res.ok && data && data.success && Array.isArray(data.data)) { + this.toast(this.summarizeUpdateResults(data.data), 'success'); + await this.loadAll(); + // Refresh the drawer in place so metadata/version reflect + // the just-applied change, if the skill is still installed. + const refreshed = this.skills.find((s) => s.id === skill.id); + if (refreshed) await this.openDrawer(refreshed); + } else if (res.status === 403) { + this.toast('Read-only — start with --enable-write to update skills', 'error'); + } else { + this.toast((data && data.error && data.error.message) || 'Version update failed', 'error'); + } + } catch (err) { + this.toast(`Error: ${err.message}`, 'error'); + } finally { + // No-op if openDrawer already replaced the drawer body above. + const btn = document.getElementById('drawer-version-apply'); + if (btn) { btn.disabled = !(this.status && this.status.writable); btn.textContent = 'Apply'; } } } @@ -533,6 +737,7 @@ class FastSkillApp { drawer.classList.remove('open'); drawer.setAttribute('aria-hidden', 'true'); this._openSkillId = null; + this._contentCache = null; } // ---- toasts ----------------------------------------------------------- diff --git a/crates/fastskill-core/src/http/static/styles.css b/crates/fastskill-core/src/http/static/styles.css index 448b9a8..1867c3a 100644 --- a/crates/fastskill-core/src/http/static/styles.css +++ b/crates/fastskill-core/src/http/static/styles.css @@ -448,6 +448,160 @@ body { overflow-y: auto; } +/* ---- Content format toggle (Raw / Rendered) ---- */ + +.content-toggle { + display: flex; + gap: 4px; + margin-bottom: 8px; +} + +.toggle-btn { + padding: 5px 12px; + font-size: 0.8125rem; + font-weight: 500; + border: 1px solid #dee2e6; + background: #fff; + color: #495057; + cursor: pointer; + border-radius: 4px; +} + +.toggle-btn:hover { + background: #f1f3f5; +} + +.toggle-btn.active { + background: #eef0fb; + border-color: #5c6bc0; + color: #3f51b5; +} + +/* ---- Rendered SKILL.md (server-sanitized HTML) ---- */ + +.markdown-body { + font-size: 0.875rem; + line-height: 1.6; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 12px 16px; + max-height: 60vh; + overflow-y: auto; + word-wrap: break-word; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4 { + margin: 16px 0 8px; + font-weight: 600; + line-height: 1.3; +} + +.markdown-body h1:first-child, +.markdown-body h2:first-child, +.markdown-body h3:first-child { + margin-top: 0; +} + +.markdown-body p { + margin: 8px 0; +} + +.markdown-body ul, +.markdown-body ol { + margin: 8px 0 8px 20px; +} + +.markdown-body li { + margin: 2px 0; +} + +.markdown-body code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8125rem; + background: #f1f3f5; + padding: 1px 5px; + border-radius: 3px; +} + +.markdown-body pre { + background: #f1f3f5; + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; + margin: 8px 0; +} + +.markdown-body pre code { + background: none; + padding: 0; +} + +.markdown-body blockquote { + margin: 8px 0; + padding: 4px 12px; + border-left: 3px solid #dee2e6; + color: #6c757d; +} + +.markdown-body table { + border-collapse: collapse; + margin: 8px 0; + width: 100%; +} + +.markdown-body th, +.markdown-body td { + border: 1px solid #e9ecef; + padding: 6px 10px; + text-align: left; +} + +.markdown-body th { + background: #f1f3f5; + font-weight: 600; +} + +.markdown-body a { + color: #5c6bc0; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body hr { + border: none; + border-top: 1px solid #e9ecef; + margin: 16px 0; +} + +.markdown-body img { + max-width: 100%; +} + +/* ---- Version picker (repository-origin skills only) ---- */ + +.version-picker { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.version-select { + padding: 6px 10px; + border: 1px solid #dee2e6; + border-radius: 4px; + font-size: 0.8125rem; + min-width: 140px; +} + /* ---- Toasts ---- */ .toast-container { From 2ae20aa5b28a2285f43b7791c626b791404499ef Mon Sep 17 00:00:00 2001 From: Alex Oliveira <4482374+aroff@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:58:24 +0000 Subject: [PATCH 2/2] feat(http): registry versions endpoint, sanitized SKILL.md HTML render, version-pinned update (spec 003 v2) Adds the three backend v2 add-ons for spec 003 browser skill management: GET /api/v1/registry/skills/{id}/versions (sourced from PackageResolver over the same SourcesManager the other /registry/* browse routes use, sorted descending by semver), GET /api/v1/skills/{id}/content?format=html (comrak render + ammonia allowlist-sanitize, safe for innerHTML), and version-pinned POST /api/v1/skills/update via a new `version` field that resolves to an exact VersionConstraint pin on a repository-origin skill's Origin. --- Cargo.lock | 413 +++++++++++++++++- Cargo.toml | 4 + crates/fastskill-core/Cargo.toml | 4 + .../src/http/handlers/registry.rs | 57 +++ .../src/http/handlers/skills.rs | 84 +++- crates/fastskill-core/src/http/models.rs | 69 ++- crates/fastskill-core/src/http/server.rs | 4 + .../tests/http_handler_route_tests.rs | 334 ++++++++++++++ 8 files changed, 961 insertions(+), 8 deletions(-) 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 {