diff --git a/README.md b/README.md
index 86d093e..abc0b3c 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,17 @@ providers:
- strip-links
```
+`target` may also be a map when a provider needs different roots for different
+content kinds. Missing kinds fall back to `default`; unknown keys are rejected:
+
+```yaml
+providers:
+ example:
+ target:
+ default: ".example"
+ skills: ".agents"
+```
+
## Usage
Every command takes its inputs as named flags. Source modules use `--source
` (defaults to `.` for in-tree commands), targets use `--target `, upstreams use `--upstream `. There are no positional path arguments.
diff --git a/src/assemble/pipeline_tests.rs b/src/assemble/pipeline_tests.rs
index 5a3372a..bf86f35 100644
--- a/src/assemble/pipeline_tests.rs
+++ b/src/assemble/pipeline_tests.rs
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use super::pipeline::{self, SourceFile};
-use crate::provider::ProviderConfig;
+use crate::provider::{ProviderConfig, ProviderTarget};
fn make_source<'a>(relative_path: &'a str, content: &'a str, passthrough: bool) -> SourceFile<'a> {
SourceFile {
@@ -14,7 +14,7 @@ fn make_source<'a>(relative_path: &'a str, content: &'a str, passthrough: bool)
fn make_provider(assembly: Option>) -> ProviderConfig {
ProviderConfig {
- target: ".test".to_string(),
+ target: ProviderTarget::Single(".test".to_string()),
assembly: assembly.map(|v| v.into_iter().map(String::from).collect()),
deploy: None,
keep_fields: None,
@@ -155,7 +155,7 @@ fn unknown_rule_collected_as_error() {
providers.insert(
"bad".to_string(),
ProviderConfig {
- target: ".bad".to_string(),
+ target: ProviderTarget::Single(".bad".to_string()),
assembly: Some(vec!["nonexistent-rule".to_string()]),
deploy: None,
keep_fields: None,
diff --git a/src/cli/config/tests.rs b/src/cli/config/tests.rs
index 046dad0..51e3aee 100644
--- a/src/cli/config/tests.rs
+++ b/src/cli/config/tests.rs
@@ -52,7 +52,7 @@ fn load_providers_returns_embedded_defaults() {
fn load_providers_module_config_overrides_target() {
let module_config = "providers:\n claude:\n target: .custom-claude\n";
let providers = load_providers(module_config).unwrap();
- assert_eq!(providers["claude"].target, ".custom-claude");
+ assert_eq!(providers["claude"].default_target(), ".custom-claude");
}
#[test]
diff --git a/src/cli/dashboard/server.rs b/src/cli/dashboard/server.rs
index 417ec5e..96bf630 100644
--- a/src/cli/dashboard/server.rs
+++ b/src/cli/dashboard/server.rs
@@ -70,7 +70,7 @@ fn load_provider_targets(root: &Path) -> Vec<(String, String)> {
};
let mut targets: Vec<(String, String)> = providers
.into_iter()
- .map(|(name, config)| (name, config.target))
+ .map(|(name, config)| (name, config.default_target().to_string()))
.collect();
targets.sort_by(|a, b| a.0.cmp(&b.0));
targets
diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs
index 3ff9324..688960b 100644
--- a/src/cli/deploy/mod.rs
+++ b/src/cli/deploy/mod.rs
@@ -4,7 +4,7 @@ use commands::result::{ActionResult, DeployedFile, PrunedFile, SkipReason, Skipp
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::fs;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::cli::config;
@@ -62,160 +62,83 @@ pub fn execute(
continue;
}
- let target_base = match effective_target {
- Some(dir) => Path::new(dir).join(&provider_config.target),
- None => Path::new(&provider_config.target).to_path_buf(),
- };
+ let mut manifests: HashMap> =
+ HashMap::new();
+ let mut deployed_by_root: HashMap> = HashMap::new();
- if let Some(dir) = effective_target {
- validate_target_boundary(&target_base, Path::new(dir))?;
+ for target_root in provider_config.target_roots() {
+ let target_base = resolve_target_base(target_root, effective_target);
+ if let Some(dir) = effective_target {
+ validate_target_boundary(&target_base, Path::new(dir))?;
+ }
+ deployed_by_root.entry(target_base.clone()).or_default();
+ manifests
+ .entry(target_base.clone())
+ .or_insert_with(|| load_deployed_manifest(&target_base));
}
- let mut existing_manifest = load_deployed_manifest(&target_base);
- let mut deployed_keys: HashSet = HashSet::new();
-
- deploy_provider_files(
- &build_provider_dir,
- &target_base,
- &mut existing_manifest,
- &mut deployed_keys,
- &mut result,
- provider_name,
- force,
- )?;
-
- // Stale detection only when prune enabled. Pruned files are
- // quarantined to /.trash// rather than deleted so
- // accidental prunes are recoverable with a single mv.
- if prune {
- let stale_keys: Vec = existing_manifest
- .iter()
- .filter(|(key, _)| !deployed_keys.contains(*key))
- .filter(|(key, _)| {
- ["agents/", "skills/", "rules/"]
- .iter()
- .any(|prefix| key.starts_with(prefix))
- })
- .filter(|(_, entry)| {
- is_owned_by_module(entry, &target_base, module_name.as_deref())
- })
- .map(|(key, _)| key.clone())
- .collect();
-
- if !stale_keys.is_empty() {
- let stamp = chrono::Utc::now().format("%Y-%m-%d-%H%MZ").to_string();
- let trash_root = target_base.join(".trash").join(&stamp);
-
- let mut skipped_modified = 0;
- for stale_key in &stale_keys {
- let stale_path = target_base.join(stale_key);
- let trash_dest = trash_root.join(stale_key);
-
- // Refuse to prune a file whose on-disk content no longer
- // matches the fingerprint forge recorded — that signals
- // local edits the user might want to keep. Same semantic as
- // the deploy path uses to skip overwriting modified files;
- // --force overrides both.
- if !force
- && stale_path.is_file()
- && let Some(expected) = existing_manifest
- .get(stale_key)
- .map(|entry| entry.fingerprint.clone())
- && let Ok(current) = fs::read_to_string(&stale_path)
- && manifest::content_sha256(¤t) != expected
- {
- eprintln!(
- "forge prune: skipping {} (modified locally; pass --force to prune)",
- stale_path.display()
- );
- skipped_modified += 1;
- continue;
- }
-
- if dry_run {
- eprintln!(
- "forge prune: would move {} -> {}",
- stale_path.display(),
- trash_dest.display()
- );
- continue;
- }
-
- if let Some(parent) = trash_dest.parent()
- && let Err(error) = fs::create_dir_all(parent)
- {
- eprintln!(
- "warning: cannot create quarantine dir {}: {error}",
- parent.display()
- );
- continue;
- }
-
- if stale_path.is_file() {
- if let Err(error) = fs::rename(&stale_path, &trash_dest) {
- eprintln!(
- "warning: cannot quarantine {}: {error}",
- stale_path.display()
- );
- continue;
- }
- prune_empty_parents(stale_path.parent(), &target_base);
- }
-
- let provenance_rel = manifest::provenance_path(stale_key);
- let provenance_path = target_base.join(&provenance_rel);
- if provenance_path.is_file() {
- let provenance_trash = trash_root.join(&provenance_rel);
- if let Some(parent) = provenance_trash.parent() {
- let _ = fs::create_dir_all(parent);
- }
- let _ = fs::rename(&provenance_path, &provenance_trash);
- prune_empty_parents(provenance_path.parent(), &target_base);
- }
-
- existing_manifest.remove(stale_key);
- result.pruned.push(PrunedFile {
- target: stale_path.to_string_lossy().to_string(),
- provider: provider_name.to_owned(),
- });
- }
+ for kind in commands::provider::ContentKind::ALL {
+ let kind_dir = build_provider_dir.join(kind.as_str());
+ if !kind_dir.is_dir() {
+ continue;
+ }
- let action = if dry_run { "would move" } else { "moved" };
- let pruned_count = stale_keys.len() - skipped_modified;
- if pruned_count > 0 {
- let entry_label = if pruned_count == 1 {
- "entry"
- } else {
- "entries"
- };
- eprintln!(
- "forge prune: {action} {pruned_count} stale {entry_label} to {}/.trash/{}/; recoverable via mv",
- target_base.display(),
- stamp
- );
- }
- if skipped_modified > 0 {
- let entry_label = if skipped_modified == 1 {
- "entry"
- } else {
- "entries"
- };
- eprintln!(
- "forge prune: skipped {skipped_modified} modified {entry_label}; pass --force to prune"
- );
- }
+ let target_base =
+ resolve_target_base(provider_config.target_for_kind(*kind), effective_target);
+ if let Some(dir) = effective_target {
+ validate_target_boundary(&target_base, Path::new(dir))?;
}
+
+ let existing_manifest = manifests
+ .entry(target_base.clone())
+ .or_insert_with(|| load_deployed_manifest(&target_base));
+ let deployed_keys = deployed_by_root.entry(target_base.clone()).or_default();
+
+ deploy_provider_kind_files(
+ &kind_dir,
+ *kind,
+ &target_base,
+ existing_manifest,
+ deployed_keys,
+ &mut result,
+ provider_name,
+ force,
+ )?;
}
- write_manifest(&target_base, &existing_manifest)?;
+ for (target_base, mut existing_manifest) in manifests {
+ let deployed_keys = deployed_by_root.remove(&target_base).unwrap_or_default();
+ if prune {
+ prune_stale_files(
+ &target_base,
+ &mut existing_manifest,
+ &deployed_keys,
+ &mut result,
+ provider_name,
+ module_name.as_deref(),
+ force,
+ dry_run,
+ );
+ }
+ write_manifest(&target_base, &existing_manifest)?;
+ }
}
Ok(result)
}
-/// Deploy all content kinds (agents, skills, rules) for a single provider.
-fn deploy_provider_files(
- build_provider_dir: &Path,
+fn resolve_target_base(target_root: &str, effective_target: Option<&str>) -> PathBuf {
+ match effective_target {
+ Some(dir) => Path::new(dir).join(target_root),
+ None => Path::new(target_root).to_path_buf(),
+ }
+}
+
+/// Deploy one content kind for a single provider.
+#[allow(clippy::too_many_arguments)]
+fn deploy_provider_kind_files(
+ kind_dir: &Path,
+ kind: commands::provider::ContentKind,
target_base: &Path,
new_manifest: &mut HashMap,
deployed_keys: &mut HashSet,
@@ -223,47 +146,71 @@ fn deploy_provider_files(
provider_name: &str,
force: bool,
) -> Result<(), Error> {
- for kind in commands::provider::ContentKind::ALL {
- let kind_dir = build_provider_dir.join(kind.as_str());
- if !kind_dir.is_dir() {
+ let files = collect_files_recursive(kind_dir)?;
+
+ for build_path in files {
+ if build_path.extension().unwrap_or_default() == "yaml" {
continue;
}
- let files = collect_files_recursive(&kind_dir)?;
+ let relative = build_path
+ .strip_prefix(kind_dir)
+ .unwrap_or(&build_path)
+ .to_string_lossy()
+ .to_string();
+ let manifest_key = format!("{kind}/{relative}");
+ deployed_keys.insert(manifest_key.clone());
+ let target_path = target_base.join(kind.as_str()).join(&relative);
+
+ let build_content = config::read_file(&build_path)?;
+ let build_fingerprint = manifest::content_sha256(&build_content);
+ let provenance_relative = manifest::provenance_path(&manifest_key);
+ let sidecar_source = manifest::sidecar_path(&build_path);
+
+ if sidecar_source.is_file() {
+ let provenance_target = target_base.join(&provenance_relative);
+ let _ = copy_file(&sidecar_source, &provenance_target);
+ }
- for build_path in files {
- if build_path.extension().unwrap_or_default() == "yaml" {
- continue;
+ let target_content = fs::read_to_string(&target_path).ok();
+ let status = manifest::status(
+ target_content.as_deref(),
+ new_manifest.get(&manifest_key),
+ &build_fingerprint,
+ );
+
+ match status {
+ manifest::FileStatus::New | manifest::FileStatus::Stale => {
+ copy_file(&build_path, &target_path)?;
+ new_manifest.insert(
+ manifest_key,
+ manifest::ManifestEntry {
+ fingerprint: build_fingerprint.clone(),
+ provenance: Some(provenance_relative.clone()),
+ },
+ );
+ result.installed.push(DeployedFile {
+ source: build_path.to_string_lossy().to_string(),
+ target: target_path.to_string_lossy().to_string(),
+ provider: provider_name.to_owned(),
+ });
}
-
- let relative = build_path
- .strip_prefix(&kind_dir)
- .unwrap_or(&build_path)
- .to_string_lossy()
- .to_string();
- let manifest_key = format!("{kind}/{relative}");
- deployed_keys.insert(manifest_key.clone());
- let target_path = target_base.join(kind.as_str()).join(&relative);
-
- let build_content = config::read_file(&build_path)?;
- let build_fingerprint = manifest::content_sha256(&build_content);
- let provenance_relative = manifest::provenance_path(&manifest_key);
- let sidecar_source = manifest::sidecar_path(&build_path);
-
- if sidecar_source.is_file() {
- let provenance_target = target_base.join(&provenance_relative);
- let _ = copy_file(&sidecar_source, &provenance_target);
+ manifest::FileStatus::Unchanged => {
+ new_manifest.insert(
+ manifest_key,
+ manifest::ManifestEntry {
+ fingerprint: build_fingerprint.clone(),
+ provenance: Some(provenance_relative.clone()),
+ },
+ );
+ result.skipped.push(SkippedFile {
+ target: target_path.to_string_lossy().to_string(),
+ provider: provider_name.to_owned(),
+ reason: SkipReason::Unchanged,
+ });
}
-
- let target_content = fs::read_to_string(&target_path).ok();
- let status = manifest::status(
- target_content.as_deref(),
- new_manifest.get(&manifest_key),
- &build_fingerprint,
- );
-
- match status {
- manifest::FileStatus::New | manifest::FileStatus::Stale => {
+ manifest::FileStatus::Modified => {
+ if force {
copy_file(&build_path, &target_path)?;
new_manifest.insert(
manifest_key,
@@ -277,50 +224,148 @@ fn deploy_provider_files(
target: target_path.to_string_lossy().to_string(),
provider: provider_name.to_owned(),
});
- }
- manifest::FileStatus::Unchanged => {
- new_manifest.insert(
- manifest_key,
- manifest::ManifestEntry {
- fingerprint: build_fingerprint.clone(),
- provenance: Some(provenance_relative.clone()),
- },
- );
+ } else {
result.skipped.push(SkippedFile {
target: target_path.to_string_lossy().to_string(),
provider: provider_name.to_owned(),
- reason: SkipReason::Unchanged,
+ reason: SkipReason::UserModified,
});
}
- manifest::FileStatus::Modified => {
- if force {
- copy_file(&build_path, &target_path)?;
- new_manifest.insert(
- manifest_key,
- manifest::ManifestEntry {
- fingerprint: build_fingerprint.clone(),
- provenance: Some(provenance_relative.clone()),
- },
- );
- result.installed.push(DeployedFile {
- source: build_path.to_string_lossy().to_string(),
- target: target_path.to_string_lossy().to_string(),
- provider: provider_name.to_owned(),
- });
- } else {
- result.skipped.push(SkippedFile {
- target: target_path.to_string_lossy().to_string(),
- provider: provider_name.to_owned(),
- reason: SkipReason::UserModified,
- });
- }
- }
}
}
}
Ok(())
}
+#[allow(clippy::too_many_arguments)]
+#[allow(clippy::too_many_lines)]
+fn prune_stale_files(
+ target_base: &Path,
+ existing_manifest: &mut HashMap,
+ deployed_keys: &HashSet,
+ result: &mut ActionResult,
+ provider_name: &str,
+ module_name: Option<&str>,
+ force: bool,
+ dry_run: bool,
+) {
+ let stale_keys: Vec = existing_manifest
+ .iter()
+ .filter(|(key, _)| !deployed_keys.contains(*key))
+ .filter(|(key, _)| {
+ ["agents/", "skills/", "rules/"]
+ .iter()
+ .any(|prefix| key.starts_with(prefix))
+ })
+ .filter(|(_, entry)| is_owned_by_module(entry, target_base, module_name))
+ .map(|(key, _)| key.clone())
+ .collect();
+
+ if stale_keys.is_empty() {
+ return;
+ }
+
+ let stamp = chrono::Utc::now().format("%Y-%m-%d-%H%MZ").to_string();
+ let trash_root = target_base.join(".trash").join(&stamp);
+ let mut skipped_modified = 0;
+
+ for stale_key in &stale_keys {
+ let stale_path = target_base.join(stale_key);
+ let trash_dest = trash_root.join(stale_key);
+
+ // Refuse to prune a file whose on-disk content no longer matches the
+ // recorded fingerprint: that signals local edits the user might want
+ // to keep. --force overrides both deploy and prune protection.
+ if !force
+ && stale_path.is_file()
+ && let Some(expected) = existing_manifest
+ .get(stale_key)
+ .map(|entry| entry.fingerprint.clone())
+ && let Ok(current) = fs::read_to_string(&stale_path)
+ && manifest::content_sha256(¤t) != expected
+ {
+ eprintln!(
+ "forge prune: skipping {} (modified locally; pass --force to prune)",
+ stale_path.display()
+ );
+ skipped_modified += 1;
+ continue;
+ }
+
+ if dry_run {
+ eprintln!(
+ "forge prune: would move {} -> {}",
+ stale_path.display(),
+ trash_dest.display()
+ );
+ continue;
+ }
+
+ if let Some(parent) = trash_dest.parent()
+ && let Err(error) = fs::create_dir_all(parent)
+ {
+ eprintln!(
+ "warning: cannot create quarantine dir {}: {error}",
+ parent.display()
+ );
+ continue;
+ }
+
+ if stale_path.is_file() {
+ if let Err(error) = fs::rename(&stale_path, &trash_dest) {
+ eprintln!(
+ "warning: cannot quarantine {}: {error}",
+ stale_path.display()
+ );
+ continue;
+ }
+ prune_empty_parents(stale_path.parent(), target_base);
+ }
+
+ let provenance_rel = manifest::provenance_path(stale_key);
+ let provenance_path = target_base.join(&provenance_rel);
+ if provenance_path.is_file() {
+ let provenance_trash = trash_root.join(&provenance_rel);
+ if let Some(parent) = provenance_trash.parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+ let _ = fs::rename(&provenance_path, &provenance_trash);
+ prune_empty_parents(provenance_path.parent(), target_base);
+ }
+
+ existing_manifest.remove(stale_key);
+ result.pruned.push(PrunedFile {
+ target: stale_path.to_string_lossy().to_string(),
+ provider: provider_name.to_owned(),
+ });
+ }
+
+ let action = if dry_run { "would move" } else { "moved" };
+ let pruned_count = stale_keys.len() - skipped_modified;
+ if pruned_count > 0 {
+ let entry_label = if pruned_count == 1 {
+ "entry"
+ } else {
+ "entries"
+ };
+ eprintln!(
+ "forge prune: {action} {pruned_count} stale {entry_label} to {}/.trash/{}/; recoverable via mv",
+ target_base.display(),
+ stamp
+ );
+ }
+ if skipped_modified > 0 {
+ let entry_label = if skipped_modified == 1 {
+ "entry"
+ } else {
+ "entries"
+ };
+ eprintln!(
+ "forge prune: skipped {skipped_modified} modified {entry_label}; pass --force to prune"
+ );
+ }
+}
+
/// Keep only the provider entries the user requested. Each requested name is
/// matched against provider keys, target directories, and aliases (the same
/// rules `ProviderConfig::matches_target` uses elsewhere). Unknown names
diff --git a/src/cli/drift/scope.rs b/src/cli/drift/scope.rs
index fe91291..55c14ba 100644
--- a/src/cli/drift/scope.rs
+++ b/src/cli/drift/scope.rs
@@ -16,6 +16,7 @@ use std::path::Path;
use super::{DriftEntry, DriftResult, DriftStatus, compare_file_content, print_drift_result};
use crate::cli::config;
use crate::cli::deploy::{is_owned_by_module, load_deployed_manifest};
+use commands::provider::{ContentKind, ProviderConfig};
const CONTENT_PREFIXES: [&str; 3] = ["agents/", "skills/", "rules/"];
@@ -57,7 +58,8 @@ pub fn execute(
compare_provider(
&mut result,
&build_dir,
- &base.join(&provider_config.target),
+ base,
+ provider_config,
provider_name,
module_name,
&ignored,
@@ -97,7 +99,8 @@ pub fn execute(
fn compare_provider(
result: &mut DriftResult,
build_dir: &Path,
- deployed_base: &Path,
+ target_base: &Path,
+ provider_config: &ProviderConfig,
provider_name: &str,
module_name: Option<&str>,
ignored: &HashSet<&str>,
@@ -105,6 +108,10 @@ fn compare_provider(
let build_files = collect_content_files(build_dir);
for (relative, build_content) in &build_files {
+ let Some(kind) = kind_for_relative(relative) else {
+ continue;
+ };
+ let deployed_base = target_base.join(provider_config.target_for_kind(kind));
let deployed_path = deployed_base.join(relative);
match fs::read_to_string(&deployed_path) {
Ok(deployed_content) => {
@@ -126,14 +133,23 @@ fn compare_provider(
// This module's deployed files (per the target manifest + provenance) that
// are no longer built — stale deployments that should be pruned.
- for (key, entry) in load_deployed_manifest(deployed_base) {
- if build_files.contains_key(&key) || !is_content_key(&key) {
- continue;
- }
- if is_owned_by_module(&entry, deployed_base, module_name) {
- result
- .entries
- .push(only_entry(&key, DriftStatus::UpstreamOnly, provider_name));
+ for target_root in provider_config.target_roots() {
+ let deployed_base = target_base.join(target_root);
+ for (key, entry) in load_deployed_manifest(&deployed_base) {
+ if !is_content_key(&key) {
+ continue;
+ }
+ if let Some(kind) = kind_for_relative(&key) {
+ let expected_base = target_base.join(provider_config.target_for_kind(kind));
+ if expected_base == deployed_base && build_files.contains_key(&key) {
+ continue;
+ }
+ }
+ if is_owned_by_module(&entry, &deployed_base, module_name) {
+ result
+ .entries
+ .push(only_entry(&key, DriftStatus::UpstreamOnly, provider_name));
+ }
}
}
}
@@ -155,6 +171,15 @@ fn is_content_key(key: &str) -> bool {
.any(|prefix| key.starts_with(prefix))
}
+fn kind_for_relative(relative: &str) -> Option {
+ match relative.split_once('/').map(|(kind, _)| kind) {
+ Some("agents") => Some(ContentKind::Agents),
+ Some("skills") => Some(ContentKind::Skills),
+ Some("rules") => Some(ContentKind::Rules),
+ _ => None,
+ }
+}
+
/// Collect content files under a provider build directory, keyed by their path
/// relative to it. Sidecar directories, `.manifest`, and dotfiles are skipped.
fn collect_content_files(build_dir: &Path) -> BTreeMap {
diff --git a/src/cli/drift/scope/tests.rs b/src/cli/drift/scope/tests.rs
index 68b64ab..ecafe53 100644
--- a/src/cli/drift/scope/tests.rs
+++ b/src/cli/drift/scope/tests.rs
@@ -1,6 +1,7 @@
use super::super::{DriftResult, DriftStatus};
use super::*;
use commands::manifest;
+use commands::provider::{ProviderConfig, ProviderTarget};
use tempfile::TempDir;
fn write(path: &std::path::Path, content: &str) {
@@ -43,10 +44,21 @@ fn deploy_owned_file(
fn run(build: &std::path::Path, deployed: &std::path::Path, module: &str) -> DriftResult {
let mut result = DriftResult::default();
+ let provider_config = ProviderConfig {
+ target: ProviderTarget::Single(".".to_string()),
+ assembly: None,
+ deploy: None,
+ keep_fields: None,
+ models: None,
+ effort: None,
+ aliases: None,
+ model: None,
+ };
compare_provider(
&mut result,
build,
deployed,
+ &provider_config,
"claude",
Some(module),
&HashSet::new(),
diff --git a/src/cli/release/mod.rs b/src/cli/release/mod.rs
index 8904fcf..e29b674 100644
--- a/src/cli/release/mod.rs
+++ b/src/cli/release/mod.rs
@@ -62,7 +62,7 @@ pub fn execute(path: &str, embed: bool) -> Result {
})?;
for (provider_name, provider_config) in &providers {
- let staged_provider = staging_dir.join(&provider_config.target);
+ let staged_provider = staging_dir.join(provider_config.default_target());
if !staged_provider.is_dir() {
continue;
}
@@ -81,7 +81,7 @@ pub fn execute(path: &str, embed: bool) -> Result {
})?;
// Move installed provider tree (including .manifest) into wrapper
- let dotfolder = wrapper_dir.join(&provider_config.target);
+ let dotfolder = wrapper_dir.join(provider_config.default_target());
fs::rename(&staged_provider, &dotfolder).map_err(|error| {
Error::new(
ErrorKind::Io,
@@ -109,7 +109,7 @@ pub fn execute(path: &str, embed: bool) -> Result {
let _ = fs::remove_dir_all(&wrapper_dir);
result.installed.push(DeployedFile {
- source: provider_config.target.clone(),
+ source: provider_config.default_target().to_string(),
target: tarball_path.to_string_lossy().to_string(),
provider: provider_name.clone(),
});
diff --git a/src/provider/mod.rs b/src/provider/mod.rs
index 53eae6e..0c32f72 100644
--- a/src/provider/mod.rs
+++ b/src/provider/mod.rs
@@ -54,7 +54,7 @@ impl AssemblyRule {
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderConfig {
- pub target: String,
+ pub target: ProviderTarget,
pub assembly: Option>,
pub deploy: Option>,
pub keep_fields: Option>>,
@@ -68,12 +68,28 @@ pub struct ProviderConfig {
}
impl ProviderConfig {
+ pub fn default_target(&self) -> &str {
+ self.target.default_target()
+ }
+
+ pub fn target_for_kind(&self, kind: ContentKind) -> &str {
+ self.target.target_for_kind(kind)
+ }
+
+ pub fn target_roots(&self) -> Vec<&str> {
+ self.target.roots()
+ }
+
pub fn matches_target(&self, target_name: &str, provider_key: &str) -> bool {
if target_name == provider_key {
return true;
}
- if target_name == self.target || target_name == self.target.trim_start_matches('.') {
+ if self
+ .target_roots()
+ .iter()
+ .any(|target| target_name == *target || target_name == target.trim_start_matches('.'))
+ {
return true;
}
@@ -83,6 +99,57 @@ impl ProviderConfig {
}
}
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+#[serde(untagged)]
+pub enum ProviderTarget {
+ Single(String),
+ ByKind(ProviderTargetMap),
+}
+
+impl ProviderTarget {
+ pub fn default_target(&self) -> &str {
+ match self {
+ Self::Single(target) => target,
+ Self::ByKind(targets) => &targets.default,
+ }
+ }
+
+ pub fn target_for_kind(&self, kind: ContentKind) -> &str {
+ match self {
+ Self::Single(target) => target,
+ Self::ByKind(targets) => match kind {
+ ContentKind::Agents => targets.agents.as_deref().unwrap_or(&targets.default),
+ ContentKind::Skills => targets.skills.as_deref().unwrap_or(&targets.default),
+ ContentKind::Rules => targets.rules.as_deref().unwrap_or(&targets.default),
+ },
+ }
+ }
+
+ pub fn roots(&self) -> Vec<&str> {
+ let mut roots = vec![self.default_target()];
+ if let Self::ByKind(targets) = self {
+ for target in [&targets.agents, &targets.skills, &targets.rules]
+ .into_iter()
+ .flatten()
+ {
+ if !roots.contains(&target.as_str()) {
+ roots.push(target.as_str());
+ }
+ }
+ }
+ roots
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ProviderTargetMap {
+ pub default: String,
+ pub agents: Option,
+ pub skills: Option,
+ pub rules: Option,
+}
+
// --- Loading ---
#[derive(Deserialize)]
diff --git a/src/provider/tests.rs b/src/provider/tests.rs
index dc16a43..566651b 100644
--- a/src/provider/tests.rs
+++ b/src/provider/tests.rs
@@ -25,9 +25,32 @@ fn load_providers_parses_all_providers() {
fn load_providers_reads_target() {
let providers = load_providers(DEFAULTS).unwrap();
- assert_eq!(providers["claude"].target, ".claude");
- assert_eq!(providers["gemini"].target, ".gemini");
- assert_eq!(providers["agentskills"].target, ".agents");
+ assert_eq!(providers["claude"].default_target(), ".claude");
+ assert_eq!(providers["gemini"].default_target(), ".gemini");
+ assert_eq!(providers["agentskills"].default_target(), ".agents");
+}
+
+#[test]
+fn load_providers_reads_target_map() {
+ let providers = load_providers(
+ "providers:\n codex:\n target:\n default: .codex\n skills: .agents\n",
+ )
+ .unwrap();
+
+ let codex = &providers["codex"];
+ assert_eq!(codex.default_target(), ".codex");
+ assert_eq!(codex.target_for_kind(ContentKind::Agents), ".codex");
+ assert_eq!(codex.target_for_kind(ContentKind::Rules), ".codex");
+ assert_eq!(codex.target_for_kind(ContentKind::Skills), ".agents");
+}
+
+#[test]
+fn load_providers_rejects_unknown_target_map_key() {
+ let result = load_providers(
+ "providers:\n codex:\n target:\n default: .codex\n skillz: .agents\n",
+ );
+
+ assert!(result.is_err());
}
#[test]
@@ -182,7 +205,7 @@ fn map_tool_passes_through_unmapped() {
fn provider_with_aliases(target: &str, aliases: Vec<&str>) -> ProviderConfig {
ProviderConfig {
- target: target.to_string(),
+ target: ProviderTarget::Single(target.to_string()),
assembly: None,
deploy: None,
keep_fields: None,
@@ -226,7 +249,7 @@ fn matches_target_rejects_unknown() {
#[test]
fn matches_target_no_aliases() {
let config = ProviderConfig {
- target: ".opencode".to_string(),
+ target: ProviderTarget::Single(".opencode".to_string()),
assembly: None,
deploy: None,
keep_fields: None,
diff --git a/src/tui/app.rs b/src/tui/app.rs
index 0a42caa..88388eb 100644
--- a/src/tui/app.rs
+++ b/src/tui/app.rs
@@ -1758,7 +1758,7 @@ pub fn load_provider_targets(root: &Path) -> Vec<(String, String)> {
};
let mut targets: Vec<(String, String)> = providers
.into_iter()
- .map(|(name, config)| (name, config.target))
+ .map(|(name, config)| (name, config.default_target().to_string()))
.collect();
targets.sort_by(|a, b| a.0.cmp(&b.0));
targets
diff --git a/src/yaml/merge.rs b/src/yaml/merge.rs
index 42324ec..32d05bb 100644
--- a/src/yaml/merge.rs
+++ b/src/yaml/merge.rs
@@ -65,11 +65,19 @@ fn merge_value(base: &mut Value, overlay: Value, key_path: &str) {
}
}
}
- (Value::Mapping(_), overlay_value) => {
- warn_type_conflict(key_path, "mapping", describe_value(&overlay_value));
+ (base_value @ Value::Mapping(_), overlay_value) => {
+ if replace_on_type_conflict(key_path) {
+ *base_value = overlay_value;
+ } else {
+ warn_type_conflict(key_path, "mapping", describe_value(&overlay_value));
+ }
}
- (base_value, Value::Mapping(_)) => {
- warn_type_conflict(key_path, describe_value(base_value), "mapping");
+ (base_value, overlay_value @ Value::Mapping(_)) => {
+ if replace_on_type_conflict(key_path) {
+ *base_value = overlay_value;
+ } else {
+ warn_type_conflict(key_path, describe_value(base_value), "mapping");
+ }
}
(base_value, overlay) => {
*base_value = overlay;
@@ -77,6 +85,10 @@ fn merge_value(base: &mut Value, overlay: Value, key_path: &str) {
}
}
+fn replace_on_type_conflict(key_path: &str) -> bool {
+ key_path.starts_with("providers.") && key_path.ends_with(".target")
+}
+
fn warn_type_conflict(key_path: &str, base_type: &str, overlay_type: &str) {
let location = if key_path.is_empty() {
"".to_string()
diff --git a/tests/deploy.rs b/tests/deploy.rs
index 85115a7..7bc902e 100644
--- a/tests/deploy.rs
+++ b/tests/deploy.rs
@@ -409,6 +409,57 @@ fn install_deploys_skill_to_agentskills_provider() {
// --- Manifest tests ---
+#[test]
+fn install_routes_content_kinds_to_target_map_roots() {
+ let module_directory = tempfile::tempdir().unwrap();
+ let target_directory = tempfile::tempdir().unwrap();
+
+ scaffold_module(module_directory.path());
+ fs::write(
+ module_directory.path().join("defaults.yaml"),
+ "providers:\n claude:\n target:\n default: .claude\n skills: .agents\n",
+ )
+ .unwrap();
+ create_skill(module_directory.path(), "MappedSkill");
+ create_rule(module_directory.path(), "MappedRule");
+
+ forge()
+ .args([
+ "install",
+ "--source",
+ module_directory.path().to_str().unwrap(),
+ "--target",
+ target_directory.path().to_str().unwrap(),
+ "--provider",
+ "claude",
+ ])
+ .assert()
+ .success();
+
+ assert!(
+ target_directory
+ .path()
+ .join(".agents/skills/MappedSkill/SKILL.md")
+ .is_file(),
+ "target.skills override should route skills to .agents"
+ );
+ assert!(
+ target_directory
+ .path()
+ .join(".claude/rules/MappedRule.md")
+ .is_file(),
+ "missing target.rules should fall back to target.default"
+ );
+ assert!(
+ target_directory.path().join(".agents/.manifest").is_file(),
+ "mapped skill root should get its own manifest"
+ );
+ assert!(
+ target_directory.path().join(".claude/.manifest").is_file(),
+ "default root should get its own manifest"
+ );
+}
+
#[test]
fn install_creates_nested_manifest() {
let module_directory = tempfile::tempdir().unwrap();