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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
413 changes: 412 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions crates/fastskill-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 57 additions & 0 deletions crates/fastskill-core/src/http/handlers/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
Path(skill_id): Path<String>,
) -> HttpResult<axum::Json<ApiResponse<SkillVersionsResponse>>> {
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)
Expand Down
84 changes: 82 additions & 2 deletions crates/fastskill-core/src/http/handlers/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -95,6 +97,7 @@ pub async fn get_skill(
pub async fn get_skill_content(
State(state): State<AppState>,
Path(skill_id): Path<String>,
Query(query): Query<ContentQuery>,
) -> HttpResult<axum::Json<ApiResponse<SkillContentResponse>>> {
let skill_id_parsed = crate::core::service::SkillId::new(skill_id.clone())
.map_err(|_| HttpError::BadRequest("Invalid skill ID format".to_string()))?;
Expand Down Expand Up @@ -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 `<script>`, no `on*` handlers, no `javascript:`/`data:`
/// URLs (ammonia's default strict allowlist strips all three).
fn render_skill_markdown_html(markdown: &str) -> String {
let unsafe_html = comrak::markdown_to_html(markdown, &comrak::Options::default());
ammonia::clean(&unsafe_html)
}

/// DELETE /api/skills/{id} - Delete skill (remove from manifest and storage, unregister)
pub async fn delete_skill(
State(state): State<AppState>,
Expand Down Expand Up @@ -305,6 +325,66 @@ pub async fn update_skills(
.skill_id
.as_deref()
.filter(|s| !s.is_empty() && *s != "all");

// Version pin (spec 003 v2 / Phase 4 version picker): only meaningful
// together with a single named `skillId`, and only in apply mode — a
// `check: true` dry-run reports the ordinary preflight verdict regardless
// of `version` (unaffected, per spec).
if let Some(version) = payload.version.as_deref() {
if !payload.check {
let Some(id) = filter_id else {
return Err(HttpError::BadRequest(
"`version` requires `skillId` (version pin only applies to a single named \
skill)"
.to_string(),
));
};
let entry = entries
.iter()
.find(|e| e.id == id)
.ok_or_else(|| HttpError::NotFound(format!("Unknown skill: {}", id)))?;

let Origin::Repository { repo, skill, .. } = &entry.origin else {
return Err(HttpError::BadRequest(format!(
"version pin only applies to repository-origin skills; '{}' is not a \
repository-origin skill",
id
)));
};

let constraint = VersionConstraint::parse(version).map_err(|e| {
HttpError::BadRequest(format!("Invalid version '{}': {}", version, e))
})?;
let pinned_origin = Origin::Repository {
repo: repo.clone(),
skill: skill.clone(),
version: Some(constraint),
};
let groups = entry.groups.clone();
let entry_id = entry.id.clone();

let result = match state
.service
.add_from_origin(pinned_origin, AddMode::Update, groups)
.await
{
Ok(outcome) => SkillUpdateResult {
id: entry_id,
outcome: "updated".to_string(),
reason: None,
resolved_version: Some(outcome.resolved.version),
},
Err(e) => SkillUpdateResult {
id: entry_id,
outcome: "error".to_string(),
reason: Some(e.to_string()),
resolved_version: None,
},
};
return Ok(axum::Json(ApiResponse::success(vec![result])));
}
}

if let Some(id) = filter_id {
entries.retain(|e| e.id == id);
if entries.is_empty() {
Expand Down
69 changes: 64 additions & 5 deletions crates/fastskill-core/src/http/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,26 +273,85 @@ pub struct InstallSkillResponse {

/// POST /api/v1/skills/update request body. `skill_id` omitted (or `"all"`)
/// updates every skill recorded in the project; `check` reports the preflight
/// verdict for each without applying anything.
/// verdict for each without applying anything. `version` (v2, spec 003 Phase 4)
/// pins a single `repository`-origin skill to an exact version: only valid
/// together with `skill_id` and only when that skill's recorded `Origin` is
/// `Repository` (see `handlers::skills::update_skills`).
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSkillsRequest {
pub skill_id: Option<String>,
#[serde(default)]
pub check: bool,
pub version: Option<String>,
}

/// Query parameters for `GET /api/v1/skills/{id}/content`.
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct ContentQuery {
pub format: Option<ContentFormat>,
}

/// `?format=` value for `GET /api/v1/skills/{id}/content` (spec 003 v2 / Phase
/// 4). `Raw` (the default) is today's behavior; `Html` renders the Markdown
/// server-side (`comrak`) and allowlist-sanitizes the result (`ammonia`) before
/// returning it — safe to assign to `innerHTML` (no `<script>`, no `on*`
/// handlers, no `javascript:`/`data:` URLs).
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ContentFormat {
#[default]
Raw,
Html,
}

impl ContentFormat {
pub fn as_str(&self) -> &'static str {
match self {
ContentFormat::Raw => "raw",
ContentFormat::Html => "html",
}
}
}

/// GET /api/v1/skills/{id}/content response: the installed skill's `SKILL.md`
/// as raw text (path-confined to the skills directory; see
/// `handlers::skills::get_skill_content`). Rendered HTML-escaped by the UI —
/// no Markdown rendering (spec 003 §5 / SEC-7).
/// GET /api/v1/skills/{id}/content response: the installed skill's `SKILL.md`,
/// path-confined to the skills directory (see
/// `handlers::skills::get_skill_content`). `format` echoes the resolved
/// `?format=` value (`"raw"` or `"html"`); `content` is the raw file text for
/// `raw`, or sanitized HTML for `html` (spec 003 §5 / SEC-7 — the UI still
/// HTML-escapes/renders `raw` content itself; `html` content is already safe to
/// insert directly).
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SkillContentResponse {
pub path: String,
pub format: String,
pub content: String,
}

/// A single version available for a skill in the registry (spec 003 v2 /
/// Phase 4 version picker). `repo` is the concrete Repository/source name that
/// offers this version, when known.
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VersionInfo {
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
}

/// GET /api/v1/registry/skills/{id}/versions response. `versions` is sorted
/// descending (newest first); empty (not 404) when the registry has no
/// candidates for `id` (no registry configured, or the id is unknown there) —
/// see `handlers::registry::list_skill_versions`.
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SkillVersionsResponse {
pub id: String,
pub versions: Vec<VersionInfo>,
}

/// Per-skill outcome of a `POST /api/v1/skills/update` call.
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
Expand Down
4 changes: 4 additions & 0 deletions crates/fastskill-core/src/http/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ impl FastSkillServer {
.route("/registry/index/skills", get(registry::list_index_skills))
.route("/registry/sources", get(registry::list_sources))
.route("/registry/skills", get(registry::list_all_skills))
.route(
"/registry/skills/{id}/versions",
get(registry::list_skill_versions),
)
.route(
"/registry/sources/{name}/skills",
get(registry::list_source_skills),
Expand Down
Loading
Loading