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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- The `agentskills` provider installs Agent Skills-compatible `SKILL.md` files under `.agents/skills/<Name>/SKILL.md`, with `agents` as an alias for `--provider agents` and an Agent Skills frontmatter whitelist.
- `forge adopt <url>` fetches an upstream HTTPS artifact (or `file://` fixture), applies the `align` transform into a module skill or companion file, and writes an `adopt/v1` provenance sidecar with the upstream digest pin.
- `forge find "<query>"` scans local modules, discovered repos, and already-cached watchlist sources for skills, agents, and rules, ranking matches by name, trigger text, and description with optional JSON output.
- `forge install` now warns prominently and refuses by default when the source git checkout is confirmed behind local `origin/main` or `origin/master`; `--allow-stale` overrides the refusal while still printing the warning. Prune is qualifier-aware: a deployed base file whose source counterpart exists only under a qualifier directory is pruned, while a correctly-resolved qualifier deployment is kept. (#73)
- `forge config` prints the resolved forge ontology from `~/.config/forge/config.yaml`, with `FORGE_*` environment overrides taking precedence over file values and built-in defaults. The legacy `project.yaml` file remains as a deprecated fallback for one release.
- `forge exec <skill>` runs skill-bundled scripts through a small synchronous runtime table (`uv run`, `bash`, `deno run`, `node`), injects `FORGE_*`/`INPUT_*` environment, supports JSON stdin/stdout wrapping, dry-runs, and output schema validation.
- Unknown `forge <verb>` commands now dispatch to external `forge-<verb>` scripts from the module `commands/` directory, configured extension directories, or `PATH`, keeping new capabilities out of the Rust kernel.
Expand All @@ -33,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Fixed

- Prune is qualifier-aware, so base files are removed when the only remaining source match is an inactive qualifier variant while correctly resolved qualifier output is kept. (closes #73)
- `forge provenance` verifies source-side `.provenance/` sidecars, not just deployed targets. Pointed at a source repository or an artifact subdirectory (detected by a `module.yaml` at or above the path), it walks `.provenance/*.yaml`, resolves each `subject.name` to a repo-relative file, recomputes its SHA-256 against the recorded digest, and verifies the digests of in-repo `resolvedDependencies` (the remote `upstream` dependency is left to its recorded pin). Previously the strict typed sidecar model rejected `adopt/v1` sidecars (which carry `upstream_url` rather than `source`) and the directory walk only iterated provider kind roots, so `forge provenance skills/Foo` reported "No provenance found" and source-side drift went undetected. The model now tolerates both `assemble/v1` and `adopt/v1` schemas without changing generated sidecar output, and `--json` emits a machine-readable per-sidecar report. (#44)
- Assembly preserves Claude-native `SKILL.md` frontmatter for the claude provider. `claude.keep_fields.skills` previously kept only `name`, `description`, and `version`, so `forge assemble` stripped `allowed-tools` (and the rest of the Claude Code optional skill fields) from deployed skills, breaking tool pre-approval and gating dynamic context injection (`!` command lines). The claude skill keep-list now mirrors the skill mdschema whitelist (`allowed-tools`, `argument-hint`, `arguments`, `disable-model-invocation`, `user-invocable`, `model`, `effort`, `context`, `agent`, `paths`, `shell`). The frontmatter stripper also retains multi-line block values for kept fields, so a list-form `allowed-tools:` survives intact instead of collapsing to a valueless key. (#69)

Expand Down
68 changes: 56 additions & 12 deletions src/cli/assemble/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,7 @@ fn assemble_source_for_provider(
models: &std::collections::HashMap<String, Vec<String>>,
source_uri: &str,
) -> Result<Option<DeployedFile>, Error> {
if source
.qualifier
.as_ref()
.is_some_and(|qualifier| !qualifier_matches_provider(qualifier, provider_name, models))
{
if !source_qualifier_matches_provider(source, provider_name, active_model, models) {
return Ok(None);
}

Expand Down Expand Up @@ -245,24 +241,72 @@ fn assemble_source_for_provider(
}))
}

/// Check whether a qualifier directory matches a given provider.
/// Check whether a qualifier-only source applies to a provider/model target.
///
/// A qualifier matches if it is either the provider name itself, or if
/// any model ID for that provider contains the qualifier as a substring.
fn qualifier_matches_provider(
qualifier: &str,
/// Provider-only files such as `rules/claude/Foo.md` apply to that provider
/// for every model. Model-only files such as
/// `rules/claude/claude-sonnet-4-6/Foo.md` apply only when the provider and
/// active model both match, so a non-active model variant cannot keep a stale
/// deployed base file alive during prune.
fn source_qualifier_matches_provider(
source: &sources::SourceFile,
provider_name: &str,
active_model: Option<&str>,
models: &std::collections::HashMap<String, Vec<String>>,
) -> bool {
if qualifier == provider_name {
if source.qualifier.is_none() {
return true;
}

let segments = qualifier_segments(source);
match segments.as_slice() {
[provider] => {
provider == provider_name
|| active_model_matches(provider, active_model, provider_name, models)
}
[provider, model, ..] => provider == provider_name && active_model == Some(model.as_str()),
_ => false,
}
}

fn qualifier_segments(source: &sources::SourceFile) -> Vec<String> {
let stripped_kind = source
.relative_path
.strip_prefix(&format!("{}/", source.kind))
.unwrap_or(&source.relative_path);
let mut segments: Vec<String> = stripped_kind.split('/').map(str::to_string).collect();
let _ = segments.pop();
segments
}

fn active_model_matches(
qualifier: &str,
active_model: Option<&str>,
provider_name: &str,
models: &std::collections::HashMap<String, Vec<String>>,
) -> bool {
if active_model != Some(qualifier) {
return false;
}
if let Some(model_ids) = models.get(provider_name) {
return model_ids.iter().any(|id| id.contains(qualifier));
return model_ids.iter().any(|id| id == qualifier);
}
false
}

#[cfg(test)]
fn qualifier_matches_provider(
qualifier: &str,
provider_name: &str,
active_model: Option<&str>,
models: &std::collections::HashMap<String, Vec<String>>,
) -> bool {
if qualifier == provider_name {
return true;
}
active_model_matches(qualifier, active_model, provider_name, models)
}

/// Apply kebab-case transformation to each segment of a path.
#[cfg(test)]
fn apply_kebab_case_to_path(path: &str) -> String {
Expand Down
82 changes: 69 additions & 13 deletions src/cli/assemble/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,97 @@ fn make_models() -> HashMap<String, Vec<String>> {
#[test]
fn direct_provider_name_matches() {
let models = make_models();
assert!(qualifier_matches_provider("claude", "claude", &models));
assert!(qualifier_matches_provider("codex", "codex", &models));
assert!(qualifier_matches_provider(
"claude", "claude", None, &models
));
assert!(qualifier_matches_provider("codex", "codex", None, &models));
}

#[test]
fn model_tier_matches_provider_with_that_model() {
fn active_model_matches_provider_with_that_model() {
let models = make_models();
assert!(qualifier_matches_provider("sonnet", "claude", &models));
assert!(qualifier_matches_provider("opus", "claude", &models));
assert!(qualifier_matches_provider(
"claude-sonnet-4-6",
"claude",
Some("claude-sonnet-4-6"),
&models
));
assert!(qualifier_matches_provider(
"claude-opus-4-6",
"claude",
Some("claude-opus-4-6"),
&models
));
}

#[test]
fn model_tier_matches_across_providers() {
fn active_model_matches_across_providers_that_list_it() {
let models = make_models();
assert!(qualifier_matches_provider("sonnet", "opencode", &models));
assert!(qualifier_matches_provider(
"claude-sonnet-4-6",
"opencode",
Some("claude-sonnet-4-6"),
&models
));
}

#[test]
fn model_tier_does_not_match_unrelated_provider() {
fn inactive_model_does_not_match_provider() {
let models = make_models();
assert!(!qualifier_matches_provider("sonnet", "codex", &models));
assert!(!qualifier_matches_provider("opus", "codex", &models));
assert!(!qualifier_matches_provider(
"claude-sonnet-4-6",
"claude",
Some("claude-opus-4-6"),
&models
));
assert!(!qualifier_matches_provider(
"claude-opus-4-6",
"claude",
None,
&models
));
}

#[test]
fn model_does_not_match_unrelated_provider() {
let models = make_models();
assert!(!qualifier_matches_provider(
"claude-sonnet-4-6",
"codex",
Some("claude-sonnet-4-6"),
&models
));
assert!(!qualifier_matches_provider(
"claude-opus-4-6",
"codex",
Some("claude-opus-4-6"),
&models
));
}

#[test]
fn unknown_qualifier_does_not_match() {
let models = make_models();
assert!(!qualifier_matches_provider("gpt5", "claude", &models));
assert!(!qualifier_matches_provider(
"gpt5",
"claude",
Some("gpt5"),
&models
));
}

#[test]
fn provider_not_in_models_only_matches_by_name() {
let models = make_models();
assert!(qualifier_matches_provider("gemini", "gemini", &models));
assert!(!qualifier_matches_provider("sonnet", "gemini", &models));
assert!(qualifier_matches_provider(
"gemini", "gemini", None, &models
));
assert!(!qualifier_matches_provider(
"claude-sonnet-4-6",
"gemini",
Some("claude-sonnet-4-6"),
&models
));
}

#[test]
Expand Down
105 changes: 105 additions & 0 deletions src/cli/install/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
use commands::error::Error;
use commands::error::ErrorKind;
use commands::result::ActionResult;
use std::path::Path;

use super::assemble;
use super::config;
use super::deploy;

struct StaleSource {
trunk: String,
commits_behind: usize,
}

/// Assemble and deploy module content to provider directories.
///
/// ```text
Expand All @@ -22,7 +30,9 @@ pub fn execute(
interactive: bool,
dry_run: bool,
model: Option<&str>,
allow_stale: bool,
) -> Result<ActionResult, Error> {
warn_or_block_stale_source(Path::new(path), allow_stale)?;
assemble::execute_with_model(path, model)?;
deploy::execute(
path,
Expand All @@ -35,5 +45,100 @@ pub fn execute(
)
}

fn warn_or_block_stale_source(module_root: &Path, allow_stale: bool) -> Result<(), Error> {
let module_label = module_label(module_root);
let stale = match detect_stale_source(module_root) {
Ok(Some(stale)) => stale,
Ok(None) => return Ok(()),
Err(error) => {
eprintln!(
"warning: cannot determine git freshness for {module_label}: {error}; continuing"
);
return Ok(());
}
};

let count = match stale.commits_behind {
1 => "1 commit".to_string(),
count => format!("{count} commits"),
};
let warning = format!(
"WARNING: source module {module_label} is {count} behind {}; deploying it may resurrect stale content",
stale.trunk
);
eprintln!("{warning}");

if allow_stale {
return Ok(());
}

Err(Error::new(
ErrorKind::Config,
format!("{warning}. Refusing to install; pass --allow-stale to continue."),
))
}

fn module_label(module_root: &Path) -> String {
let source_uri = config::load_source_uri(module_root);
if source_uri.is_empty() {
module_root.display().to_string()
} else {
source_uri
}
}

fn detect_stale_source(module_root: &Path) -> Result<Option<StaleSource>, String> {
if !module_root.join(".git").exists() {
return Ok(None);
}

let repo = gix::open(module_root).map_err(|error| error.to_string())?;
let head_id = repo.head_id().map_err(|error| error.to_string())?.detach();

// Deliberately do not fetch here. `forge install` compares against the
// last-known origin trunk ref so the check stays cheap and offline-safe.
let Some((trunk_name, trunk_ref)) = trunk_reference(&repo)? else {
return Ok(None);
};
let trunk_id = trunk_ref
.into_fully_peeled_id()
.map_err(|error| error.to_string())?;

if trunk_id == head_id {
return Ok(None);
}

for (commits_behind, item) in trunk_id
.ancestors()
.all()
.map_err(|error| error.to_string())?
.enumerate()
{
let info = item.map_err(|error| error.to_string())?;
if info.id == head_id {
return Ok(Some(StaleSource {
trunk: trunk_name.to_string(),
commits_behind,
}));
}
}

Ok(None)
}

fn trunk_reference(
repo: &gix::Repository,
) -> Result<Option<(&'static str, gix::Reference<'_>)>, String> {
for name in ["refs/remotes/origin/main", "refs/remotes/origin/master"] {
if let Some(reference) = repo
.try_find_reference(name)
.map_err(|error| error.to_string())?
{
return Ok(Some((name, reference)));
}
}
Ok(None)
}

#[cfg(test)]
mod tests;
5 changes: 5 additions & 0 deletions src/cli/install/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ fn execute_errors_on_missing_module() {
false,
false,
None,
false,
);
assert!(result.is_err());
}
Expand All @@ -36,6 +37,7 @@ fn execute_errors_on_directory_without_module_yaml() {
false,
false,
None,
false,
);
let error = result.expect_err("expected install to refuse non-module directory");
let message = error.to_string();
Expand Down Expand Up @@ -69,6 +71,7 @@ fn execute_succeeds_on_empty_module() {
false,
false,
None,
false,
);
assert!(result.is_ok());
}
Expand All @@ -88,6 +91,7 @@ fn execute_unknown_provider_lists_available_choices() {
false,
false,
None,
false,
);
let error = result.expect_err("unknown provider must error");
let message = error.to_string();
Expand Down Expand Up @@ -123,6 +127,7 @@ fn execute_provider_filter_skips_unrequested_providers() {
false,
false,
None,
false,
)
.expect("install should succeed for known provider");

Expand Down
Loading