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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ fastskill install --lock
- [Quick Start](webdocs/quickstart.mdx)
- [Installation](webdocs/installation.mdx)
- [CLI Reference](webdocs/cli-reference/overview.mdx)
- [Registry Guide](docs/REGISTRY.md)
- [Registry Guide](webdocs/registry/overview.mdx)

## Crates

Expand Down
10 changes: 6 additions & 4 deletions crates/fastskill-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ path = "src/main.rs"

[dependencies]
# Core library
fastskill-core = { path = "../fastskill-core", features = ["filesystem-storage", "registry-publish", "hot-reload"] }
fastskill-core = { path = "../fastskill-core", default-features = false, features = ["filesystem-storage", "hot-reload"] }

# CLI framework for AppBuilder, command registry, MCP server, and doctor
cli-framework = { workspace = true, default-features = false, features = ["mcp-server", "mcp-install", "doctor", "testkit"] }
Expand Down Expand Up @@ -88,9 +88,9 @@ dirs.workspace = true
# Semantic versioning
semver.workspace = true

# AWS S3 storage (for registry publishing)
aws-sdk-s3.workspace = true
aws-config.workspace = true
# AWS S3 storage (optional; only compiled with registry publishing)
aws-sdk-s3 = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }

# Vendored OpenSSL — required for musl cross-compilation because cli-framework and
# aikit-sdk depend on reqwest without default-features = false, which unifies
Expand All @@ -99,6 +99,8 @@ aws-config.workspace = true
openssl = { version = "0.10", optional = true, features = ["vendored"] }

[features]
default = []
registry-publish = ["fastskill-core/registry-publish", "dep:aws-sdk-s3", "dep:aws-config"]
vendored-openssl = ["dep:openssl"]

[dev-dependencies]
Expand Down
4 changes: 4 additions & 0 deletions crates/fastskill-cli/src/auth_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[cfg(feature = "registry-publish")]
use std::time::{SystemTime, UNIX_EPOCH};

/// Authentication configuration structure
Expand Down Expand Up @@ -150,6 +151,7 @@ fn decode_token_unsafe(token: &str) -> Option<Claims> {
}

/// Check if a JWT token is expired or expiring soon
#[cfg(feature = "registry-publish")]
fn is_token_expired_or_expiring_soon(token: &str, buffer_seconds: u64) -> CliResult<bool> {
match decode_token_unsafe(token) {
Some(claims) => {
Expand Down Expand Up @@ -209,6 +211,7 @@ pub fn get_token_for_registry(registry_url: &str) -> CliResult<Option<String>> {
}

/// Get token with automatic refresh if needed
#[cfg(feature = "registry-publish")]
pub async fn get_token_with_refresh(registry_url: &str) -> CliResult<Option<String>> {
// Check environment variable first
if let Ok(token) = std::env::var("FASTSKILL_API_TOKEN") {
Expand Down Expand Up @@ -283,6 +286,7 @@ pub async fn get_token_with_refresh(registry_url: &str) -> CliResult<Option<Stri
}

/// Refresh token for a registry by calling /auth/token endpoint
#[cfg(feature = "registry-publish")]
async fn refresh_token_for_registry(registry_url: &str, role: &str) -> CliResult<String> {
let client = reqwest::Client::new();
let token_url = format!("{}/auth/token", registry_url);
Expand Down
2 changes: 2 additions & 0 deletions crates/fastskill-cli/src/commands/add/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ version = "1.0.0"

#[cfg(unix)]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_add_editable_local_creates_symlink() {
let _lock = fastskill_core::test_utils::DIR_MUTEX
.lock()
Expand Down Expand Up @@ -440,6 +441,7 @@ skills_directory = ".claude/skills"
}

#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_add_non_editable_local_creates_directory() {
let _lock = fastskill_core::test_utils::DIR_MUTEX
.lock()
Expand Down
1 change: 1 addition & 0 deletions crates/fastskill-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod install;
pub mod list;
pub mod marketplace;
pub mod package;
#[cfg(feature = "registry-publish")]
pub mod publish;
pub mod read;
pub mod registry;
Expand Down
12 changes: 3 additions & 9 deletions crates/fastskill-cli/src/commands/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,17 +296,11 @@ pub async fn execute_publish(args: PublishArgs) -> CliResult<()> {
/// Publish to API with authentication
async fn publish_to_api_with_auth(context: &PublishContext) -> CliResult<()> {
let token = crate::auth_config::get_token_with_refresh(&context.target).await?;
let token_str = token.ok_or_else(|| {
CliError::Validation(format!(
"No authentication token found for registry: {}. Run `fastskill auth login` to authenticate.",
context.target
))
})?;

publish_to_api(
&context.target,
&context.packages,
Some(&token_str),
token.as_deref(),
context.wait,
context.max_wait,
)
Expand Down Expand Up @@ -473,7 +467,7 @@ async fn publish_single_package(
println!(
"{}",
messages::info(&format!(
"Check status with: GET {}/api/registry/publish/status/{}",
"Check status with: GET {}/api/v1/registry/publish/status/{}",
api_url, response.job_id
))
);
Expand Down Expand Up @@ -595,7 +589,7 @@ fn extract_api_url_from_repository(repo: &RepositoryDefinition) -> CliResult<Str
// For non-HTTP URLs, we can't determine the API URL
Err(CliError::Config(format!(
"Cannot determine API URL for http-registry '{}'. \
Please specify storage.base_url in repositories.toml or use --target flag.",
Please specify storage.base_url in repositories.toml or use --api-url.",
repo.name
)))
}
Expand Down
24 changes: 14 additions & 10 deletions crates/fastskill-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ fn ctx_skills_dir(ctx: &dyn AppContext) -> Option<std::path::PathBuf> {
})
}

#[cfg(feature = "registry-publish")]
use commands::publish;
use commands::{
add, analyze, auth, doctor, eval, init, install, list, marketplace, package, publish, read,
reindex, remove, repos, search, serve, skillopt, update,
add, analyze, auth, doctor, eval, init, install, list, marketplace, package, read, reindex,
remove, repos, search, serve, skillopt, update,
};

#[tokio::main]
Expand Down Expand Up @@ -208,16 +210,18 @@ fn build_app(builder: AppBuilder, state: Arc<FsState>) -> anyhow::Result<AppBuil
.await
.map_err(anyhow::Error::from)
},
)?
.register(
path!["publish"],
|_ctx, args: publish::PublishArgs| async move {
publish::execute_publish(args)
.await
.map_err(anyhow::Error::from)
},
)?;

#[cfg(feature = "registry-publish")]
let builder = builder.register(
path!["publish"],
|_ctx, args: publish::PublishArgs| async move {
publish::execute_publish(args)
.await
.map_err(anyhow::Error::from)
},
)?;

// ── Typed commands that need FsState (service injection) ─────────────────
let builder = {
let state_list = Arc::clone(&state);
Expand Down
1 change: 1 addition & 0 deletions crates/fastskill-cli/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Utility functions for CLI operations

#[cfg(feature = "registry-publish")]
pub mod api_client;
pub mod install_utils;
pub mod manifest_utils;
Expand Down
7 changes: 5 additions & 2 deletions crates/fastskill-cli/src/utils/api_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ impl ApiClient {
/// Publish a package to the registry API
/// The server will extract the scope from the JWT token's user account
pub async fn publish_package(&self, package_path: &Path) -> CliResult<PublishApiResponse> {
let url = format!("{}/api/registry/publish", self.base_url);
let url = format!("{}/api/v1/registry/publish", self.base_url);

// Read package file
let package_data = std::fs::read(package_path).map_err(CliError::Io)?;
Expand Down Expand Up @@ -139,7 +139,10 @@ impl ApiClient {

/// Get publish job status
pub async fn get_publish_status(&self, job_id: &str) -> CliResult<PublishStatusApiResponse> {
let url = format!("{}/api/registry/publish/status/{}", self.base_url, job_id);
let url = format!(
"{}/api/v1/registry/publish/status/{}",
self.base_url, job_id
);

// Build request
let mut request = self.client.get(&url);
Expand Down
2 changes: 1 addition & 1 deletion crates/fastskill-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ assert_cmd = "2.2"
predicates = "3.0"

[features]
default = ["filesystem-storage", "registry-publish"]
default = ["filesystem-storage"]

# Storage backends
filesystem-storage = []
Expand Down
2 changes: 1 addition & 1 deletion crates/fastskill-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub async fn retrieve_skills_for_turn(
## Feature flags

- `filesystem-storage` (default): local storage backend.
- `registry-publish` (default): registry publishing support.
- `registry-publish`: opt-in registry publishing support.
- `hot-reload`: filesystem watch support for skill changes.

## Core capabilities
Expand Down
7 changes: 5 additions & 2 deletions crates/fastskill-core/src/core/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,12 @@ mod tests {
let result = parse_yaml_frontmatter(content);
assert!(
result.is_ok(),
"parse_yaml_frontmatter should succeed with legacy enabled key"
"parse_yaml_frontmatter should succeed with legacy enabled key: {:?}",
result.as_ref().err()
);
let fm = result.unwrap();
let Ok(fm) = result else {
return;
};
assert!(
fm.extra.contains_key("enabled"),
"extra map should contain the enabled key"
Expand Down
100 changes: 91 additions & 9 deletions crates/fastskill-core/src/core/registry/index_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,17 @@ impl IndexManager {
}
}

// Step 4: Acquire exclusive file lock with timeout
// Step 4: Acquire exclusive lock with timeout. Use a sidecar lock file
// because the index file is replaced by atomic rename during update.
let lock_path = index_path.with_extension("lock");
let lock_start = Instant::now();
let file = loop {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&index_path)
.truncate(false)
.open(&lock_path)
.map_err(ServiceError::Io)?;

// Try to acquire exclusive lock
Expand All @@ -122,12 +124,12 @@ impl IndexManager {
let elapsed = lock_start.elapsed();
if elapsed.as_millis() > 0 {
info!(
"Acquired lock on index file: {:?} (waited {}ms)",
"Acquired lock for index file: {:?} (waited {}ms)",
index_path,
elapsed.as_millis()
);
} else {
info!("Acquired lock on index file: {:?}", index_path);
info!("Acquired lock for index file: {:?}", index_path);
}
break file;
}
Expand All @@ -137,20 +139,20 @@ impl IndexManager {
if elapsed >= self.lock_timeout {
warn!(
"Lock timeout exceeded for {:?} after {} seconds",
index_path,
lock_path,
self.lock_timeout.as_secs()
);
return Err(ServiceError::Custom(format!(
"Timeout waiting for file lock on {:?} (exceeded {} seconds)",
index_path,
lock_path,
self.lock_timeout.as_secs()
)));
}
// Log if we've been waiting a while
if elapsed.as_secs() > 0 && elapsed.as_secs().is_multiple_of(5) {
info!(
"Waiting for lock on {:?} ({}s elapsed, timeout: {}s)",
index_path,
lock_path,
elapsed.as_secs(),
self.lock_timeout.as_secs()
);
Expand All @@ -160,7 +162,7 @@ impl IndexManager {
continue;
}
Err(e) => {
warn!("Failed to acquire lock on {:?}: {}", index_path, e);
warn!("Failed to acquire lock on {:?}: {}", lock_path, e);
return Err(ServiceError::Io(e));
}
}
Expand Down Expand Up @@ -387,3 +389,83 @@ impl IndexManager {
Ok(entries)
}
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::core::registry_index::VersionEntry;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::TempDir;

fn entry(version: &str) -> VersionEntry {
VersionEntry {
name: "testorg/test-skill".to_string(),
vers: version.to_string(),
deps: Vec::new(),
cksum: format!("checksum-{version}"),
features: HashMap::new(),
yanked: false,
links: None,
download_url: format!("https://example.com/test-skill-{version}.zip"),
published_at: "2024-01-01T00:00:00Z".to_string(),
metadata: None,
scoped_name: None,
}
}

fn read_entries(registry_path: PathBuf) -> Vec<VersionEntry> {
let index_path = get_skill_index_path(&registry_path, "testorg/test-skill").unwrap();
IndexManager::read_entries_from_path(&index_path).unwrap()
}

#[test]
fn atomic_update_preserves_existing_entries() {
let temp_dir = TempDir::new().unwrap();
let registry_path = temp_dir.path().to_path_buf();
let manager = IndexManager::new(registry_path.clone());

for i in 0..25 {
let version = format!("1.0.{i}");
let update = entry(&version);
let result = manager.atomic_update("testorg/test-skill", &version, &update);
assert!(
result.is_ok(),
"atomic_update failed for {version}: {result:?}"
);
}

let entries = read_entries(registry_path);
assert_eq!(entries.len(), 25);
}

#[test]
fn atomic_update_serializes_concurrent_same_skill_writes() {
let temp_dir = TempDir::new().unwrap();
let registry_path = temp_dir.path().to_path_buf();
let manager = Arc::new(IndexManager::new(registry_path.clone()));

let handles: Vec<_> = (0..10)
.map(|i| {
let manager = Arc::clone(&manager);
std::thread::spawn(move || {
let version = format!("1.0.{i}");
let update = entry(&version);
manager.atomic_update("testorg/test-skill", &version, &update)
})
})
.collect();

for handle in handles {
let result = handle.join().unwrap();
assert!(
result.is_ok(),
"concurrent atomic_update failed: {result:?}"
);
}

let entries = read_entries(registry_path);
assert_eq!(entries.len(), 10);
}
}
2 changes: 1 addition & 1 deletion crates/fastskill-core/src/core/repository/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ impl CratesRegistryClient {

// Build the API endpoint URL
let base_url = self.index_url.trim_end_matches('/');
let mut url = format!("{}/api/registry/index/skills", base_url);
let mut url = format!("{}/api/v1/registry/index/skills", base_url);

// Add query parameters
let mut query_params = Vec::new();
Expand Down
Loading
Loading