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
5 changes: 2 additions & 3 deletions crates/fastskill-cli/src/commands/add/origin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,9 +534,8 @@ fn build_explicit_origin(source: &SkillSource, args: &AddArgs) -> CliResult<Orig
.map_err(|error| {
CliError::Config(format!("Invalid version constraint: {error}"))
})?;
let manager = RepositoryManager::from_definitions(
crate::config::load_repositories_from_project()?,
);
let manager =
RepositoryManager::from_definitions(crate::config::load_resolution_repositories()?);
let repository = if let Some(repository) = &args.repository {
manager.get_repository(repository).ok_or_else(|| {
CliError::Config(format!("Repository '{repository}' is not configured"))
Expand Down
62 changes: 43 additions & 19 deletions crates/fastskill-cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,50 @@ use tracing::debug;

/// Load repositories from skill-project.toml [tool.fastskill.repositories]
/// Returns empty vector if skill-project.toml not found or no repositories configured
///
/// Only the project's own entries: `repo add`/`update`/`remove` save this list
/// back, and must never copy a composed Manifest's repositories into it.
pub fn load_repositories_from_project() -> CliResult<Vec<RepositoryDefinition>> {
let Some((project, _)) = load_current_project()? else {
return Ok(Vec::new());
};
let repositories = project
.tool
.and_then(|t| t.fastskill)
.and_then(|f| f.repositories)
.unwrap_or_default();
Ok(repositories
.into_iter()
.map(convert_repository_definition)
.collect())
}

/// Repositories that resolve dependency origins: the project's own plus those
/// of every Manifest it composes through [tool.fastskill.manifests], so a
/// composed repository origin finds the catalog its own Manifest names.
pub fn load_resolution_repositories() -> CliResult<Vec<RepositoryDefinition>> {
let Some((project, project_path)) = load_current_project()? else {
return Ok(Vec::new());
};
let project_dir = project_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
Ok(project
.composed_repositories(project_dir)
.map_err(CliError::Config)?
.into_iter()
.map(convert_repository_definition)
.collect())
}

/// The skill-project.toml found from the current directory, if any.
fn load_current_project() -> CliResult<Option<(SkillProjectToml, PathBuf)>> {
let current_dir = env::current_dir()
.map_err(|e| CliError::Config(format!("Failed to get current directory: {}", e)))?;

// Try to find skill-project.toml
let project_file = project::resolve_project_file(&current_dir);
if !project_file.found {
return Ok(Vec::new()); // No skill-project.toml found
return Ok(None);
}
let project_path = project_file.path;

Expand All @@ -32,20 +68,7 @@ pub fn load_repositories_from_project() -> CliResult<Vec<RepositoryDefinition>>
e
))
})?;

// Extract repositories from [tool.fastskill]
let repositories = project
.tool
.and_then(|t| t.fastskill)
.and_then(|f| f.repositories)
.unwrap_or_default();

// Convert manifest::RepositoryDefinition to repository::RepositoryDefinition
let converted_repos = repositories
.into_iter()
.map(convert_repository_definition)
.collect();
Ok(converted_repos)
Ok(Some((project, project_path)))
}

/// Convert manifest::RepositoryDefinition to repository::RepositoryDefinition
Expand Down Expand Up @@ -237,8 +260,9 @@ pub fn create_service_config(
/// Inject the CLI-edge dependencies the core install/reindex seams need (ADR-0005):
/// an embedding provider (only ever constructed here, where the API key is loaded)
/// and a repository manager (resolved from the project's
/// `[tool.fastskill.repositories]`). Core never loads the key or reads the repos
/// config itself — only the edge (CLI/serve) does.
/// `[tool.fastskill.repositories]` and those of the Manifests it composes).
/// Core never loads the key or reads the repos config itself — only the edge
/// (CLI/serve) does.
///
/// Mirrors the construction `add_from_registry` already did inline: load
/// repositories → `RepositoryManager::from_definitions`. When no `OPENAI_API_KEY`
Expand All @@ -255,7 +279,7 @@ pub fn inject_edge_services(mut service: FastSkillService) -> CliResult<FastSkil
}
}

let repositories = load_repositories_from_project()?;
let repositories = load_resolution_repositories()?;
let repo_manager = RepositoryManager::from_definitions(repositories);
service = service.with_repository_manager(Arc::new(repo_manager));

Expand Down
139 changes: 139 additions & 0 deletions crates/fastskill-cli/tests/manifest_composition_cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,142 @@ fn project_install_rejects_manifest_cycles_before_creating_a_lock() {
);
assert!(!root.path().join("skills.lock").exists());
}

/// A shared Manifest whose repository origin names a catalog it declares
/// itself, next to a consuming project that composes it.
fn shared_manifest_with_its_own_catalog(root: &std::path::Path) {
let catalog = root.join("shared/catalog/review");
fs::create_dir_all(&catalog).unwrap();
fs::write(
catalog.join("SKILL.md"),
"---\nname: review\nversion: 1.0.0\ndescription: shared catalog\n---\n# Review\n",
)
.unwrap();
fs::write(
root.join("shared/skill-project.toml"),
"[dependencies]\n\
review = { origin = { type = \"repository\", repo = \"team\", skill = \"review\" } }\n\
[[tool.fastskill.repositories]]\n\
name = \"team\"\n\
type = \"local\"\n\
priority = 1\n\
path = \"catalog\"\n",
)
.unwrap();
}

fn install(root: &std::path::Path) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_fastskill"))
.current_dir(root)
.args(["project", "install", "--no-reindex"])
.output()
.unwrap()
}

#[test]
fn a_composed_repository_origin_resolves_through_the_repositories_its_manifest_declares() {
let root = TempDir::new().unwrap();
shared_manifest_with_its_own_catalog(root.path());
fs::write(
root.path().join("skill-project.toml"),
"[dependencies]\n[tool.fastskill]\nskills_directory = \"skills\"\n\
[tool.fastskill.manifests]\nteam = \"shared\"\n",
)
.unwrap();

let output = install(root.path());

assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let installed = fs::read_to_string(root.path().join("skills/review/SKILL.md")).unwrap();
assert!(installed.contains("shared catalog"), "{installed}");
}

#[test]
fn a_repository_name_defined_differently_by_composed_manifests_is_refused() {
let root = TempDir::new().unwrap();
shared_manifest_with_its_own_catalog(root.path());
let other = root.path().join("catalog/review");
fs::create_dir_all(&other).unwrap();
fs::write(
other.join("SKILL.md"),
"---\nname: review\nversion: 1.0.0\ndescription: consumer catalog\n---\n# Review\n",
)
.unwrap();
fs::write(
root.path().join("skill-project.toml"),
"[dependencies]\n[tool.fastskill]\nskills_directory = \"skills\"\n\
[tool.fastskill.manifests]\nteam = \"shared\"\n\
[[tool.fastskill.repositories]]\n\
name = \"team\"\n\
type = \"local\"\n\
priority = 1\n\
path = \"catalog\"\n",
)
.unwrap();

let output = install(root.path());

let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert!(stderr.contains("Repository 'team'"), "{stderr}");
assert!(!root.path().join("skills/review").exists());
assert!(!root.path().join("skills.lock").exists());
}

#[test]
fn the_same_repository_declared_by_two_composed_manifests_is_one_repository() {
let root = TempDir::new().unwrap();
shared_manifest_with_its_own_catalog(root.path());
fs::write(
root.path().join("skill-project.toml"),
"[dependencies]\n[tool.fastskill]\nskills_directory = \"skills\"\n\
[tool.fastskill.manifests]\nteam = \"shared\"\n\
[[tool.fastskill.repositories]]\n\
name = \"team\"\n\
type = \"local\"\n\
priority = 1\n\
path = \"shared/catalog\"\n",
)
.unwrap();

let output = install(root.path());

assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(root.path().join("skills/review/SKILL.md").is_file());
}

#[test]
fn adding_a_repository_never_copies_composed_ones_into_the_project() {
let root = TempDir::new().unwrap();
shared_manifest_with_its_own_catalog(root.path());
fs::create_dir_all(root.path().join("mine")).unwrap();
fs::write(
root.path().join("skill-project.toml"),
"[dependencies]\n[tool.fastskill]\nskills_directory = \"skills\"\n\
[tool.fastskill.manifests]\nteam = \"shared\"\n",
)
.unwrap();

let output = Command::new(env!("CARGO_BIN_EXE_fastskill"))
.current_dir(root.path())
.args(["repo", "add", "mine", "mine", "--repo-type", "local"])
.output()
.unwrap();

assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let project = fs::read_to_string(root.path().join("skill-project.toml")).unwrap();
assert!(project.contains("name = \"mine\""), "{project}");
assert!(!project.contains("name = \"team\""), "{project}");
}
Loading
Loading