diff --git a/CHANGELOG.md b/CHANGELOG.md index 445480d..922c49d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- The `agentskills` provider installs Agent Skills-compatible `SKILL.md` files under `.agents/skills//SKILL.md`, with `agents` as an alias for `--provider agents` and an Agent Skills frontmatter whitelist. +- `forge adopt ` 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 ""` 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 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 ` 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 ` commands now dispatch to external `forge-` scripts from the module `commands/` directory, configured extension directories, or `PATH`, keeping new capabilities out of the Rust kernel. diff --git a/Cargo.toml b/Cargo.toml index 7b7bdae..94daafd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ chrono = "0.4" regex = "1" console = "0.15" dirs = "6" +ureq = { version = "3", default-features = false, features = ["rustls"] } # git gix = { version = "0.66", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] } diff --git a/defaults.yaml b/defaults.yaml index 5f6e218..ad5c71c 100644 --- a/defaults.yaml +++ b/defaults.yaml @@ -68,6 +68,17 @@ providers: fast: [gemini-2.5-flash] light: [gemini-2.0-flash] + agentskills: + target: ".agents" + aliases: + - agents + assembly: + - strip-links + keep_fields: + agents: [name, description] + skills: [name, description, license, compatibility, metadata, allowed-tools] + rules: [] + codex: target: ".codex" model: gpt-5.5 diff --git a/docs/decisions/CLI-0016 Forge Adopt Provenance Mechanism.md b/docs/decisions/CLI-0016 Forge Adopt Provenance Mechanism.md new file mode 100644 index 0000000..d23e402 --- /dev/null +++ b/docs/decisions/CLI-0016 Forge Adopt Provenance Mechanism.md @@ -0,0 +1,67 @@ +--- +title: "Forge Adopt Provenance Mechanism" +description: "forge adopt fetches upstream artifacts synchronously and records adopt/v1 provenance" +type: adr +category: cli +tags: + - cli + - adopt + - provenance + - dependencies +status: accepted +created: 2026-07-09 +updated: 2026-07-09 +author: "@N4M3Z" +project: forge-cli +related: + - "ASSEMBLY-0010 Copy Provenance" + - "CLI-0011 Watchlist Monitored-Source Registry" + - "RUST-0006 Synchronous Core" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: [] +informed: [] +upstream: [] +--- + +# Forge Adopt Provenance Mechanism + +## Context and Problem Statement + +Forge modules need a repeatable way to bring in useful upstream skills without losing the source URL, fetched digest, or local transform history. Manual adoption leaves review notes outside the repository and makes later drift checks dependent on memory. The command also needs to fit Forge's synchronous CLI architecture and the existing source-side `.provenance/` sidecar scan. + +## Decision Drivers + +- Preserve upstream attribution and digest pins in typed provenance sidecars +- Keep the command synchronous and available under the default `full` feature +- Avoid network access in unit tests +- Reject path traversal before writing into a module +- Use a small blocking HTTP client instead of adding an async runtime + +## Considered Options + +1. **Keep adoption as a manual skill workflow.** This keeps Rust smaller, but provenance depends on a human writing matching sidecars correctly. +2. **Use `reqwest`.** It is familiar and already appears transitively through `gix`, but direct use would add an async-first client surface or extra blocking feature choices. +3. **Use `ureq` v3 with rustls.** It provides a blocking API, follows redirects by configuration, and reuses the rustls ecosystem already present through the git stack. + +## Decision Outcome + +Chosen option: **Option 3**. + +`forge adopt ` classifies anchored HTTPS, GitHub blob/raw, and hermetic `file://` fixture URLs. It fetches bytes through `ureq` for HTTPS, rejects non-UTF-8 bodies, applies the `align` transform, writes the artifact under the requested module, and records a source-side `.provenance/.yaml` sidecar. GitHub URLs must carry a full 40-hex commit in the URL before Forge records `externalParameters.upstream_commit`; plain HTTPS sources record an empty commit field. + +The sidecar uses the existing manifest provenance types with `buildType: adopt/v1`, `externalParameters.upstream_url`, `externalParameters.transforms_applied: ["align"]`, the landed subject digest, and one `resolvedDependencies` entry named `upstream` containing the fetched-body digest. Unit tests inject fetched bytes directly, and an ignored smoke can cover a real hosted skill separately. + +## Consequences + +- Adoption becomes reproducible enough for source-side provenance verification and drift detection. +- `ureq` adds a direct dependency, but it avoids tokio in `full` and keeps HTTP behavior blocking. +- Plain HTTPS adoption pins content by digest but cannot independently prove a source commit. +- GitHub branch or tag URLs are rejected until Forge has a commit-resolution path that does not weaken the pin. +- The dashboard's private attribution model remains separate from the manifest provenance model. + +## More Information + +- [ASSEMBLY-0010 Copy Provenance](ASSEMBLY-0010%20Copy%20Provenance.md) +- [CLI-0011 Watchlist Monitored-Source Registry](CLI-0011%20Watchlist%20Monitored-Source%20Registry.md) +- [RUST-0006 Synchronous Core](RUST-0006%20Synchronous%20Core.md) diff --git a/docs/decisions/CLI-0017 Agentskills Provider.md b/docs/decisions/CLI-0017 Agentskills Provider.md new file mode 100644 index 0000000..e7e873b --- /dev/null +++ b/docs/decisions/CLI-0017 Agentskills Provider.md @@ -0,0 +1,64 @@ +--- +title: "Agentskills Provider" +description: "The Agent Skills provider uses the agentskills key and deploys to .agents" +type: adr +category: cli +tags: + - cli + - providers + - agentskills + - assembly +status: accepted +created: 2026-07-09 +updated: 2026-07-09 +author: "@N4M3Z" +project: forge-cli +related: + - "ASSEMBLY-0011 Provider and Model Identifiers" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: [] +informed: [] +upstream: + - "https://agentskills.io/specification" +--- + +# Agentskills Provider + +## Context and Problem Statement + +Agent Skills-compatible clients load skill directories from `.agents`, with each skill represented by a `SKILL.md` file and YAML frontmatter. Forge already has a generic provider deployment model where content lands at `//`, so `.agents` support should be provider data rather than another path-specific branch in deployment code. The provider name also participates in qualifier directory discovery for `rules/` and `agents/`, which creates a collision if the provider key is literally `agents`. + +## Decision Drivers + +- Add `.agents` deployment without provider-specific path code +- Preserve `forge install --provider agents` as a user-facing alias +- Avoid treating `rules/agents/` or `agents/agents/` as provider qualifier directories +- Keep Agent Skills frontmatter compatible with the published specification +- Avoid adding new assembly transforms + +## Considered Options + +1. **Name the provider `agents`.** This matches the target concept, but it makes `agents` a valid qualifier directory for non-skill content and can swallow ordinary nested content. +2. **Name the provider `agentskills` with alias `agents`.** This avoids qualifier collision while preserving the expected install selector. +3. **Special-case `.agents` in deployment code.** This works, but duplicates behavior already covered by provider `target` data. + +## Decision Outcome + +Chosen option: **Option 2**. + +Forge defines provider key `agentskills`, target `.agents`, alias `agents`, and `assembly: [strip-links]`. The generic deployment path places a skill at `.agents/skills//SKILL.md` with no path-code changes. Qualifier discovery sees `agentskills` as the provider name and never sees the alias, so `rules/agents/` is not a provider qualifier directory. + +The Agent Skills specification requires `name` and `description`. It also recognizes top-level `license`, `compatibility`, `metadata`, and experimental `allowed-tools`; `version` is shown as `metadata.version`, not a top-level field. The provider keeps the recognized top-level fields and does not invent a top-level `version` field. + +## Consequences + +- Users can select the provider with either `agentskills` or `agents`. +- Existing provider assembly and deployment code handles the target path. +- The provider whitelist may need revision if the Agent Skills specification changes. +- Optional fields survive when authored, but unsupported Forge-specific fields are still stripped. + +## More Information + +- [Agent Skills specification](https://agentskills.io/specification) +- [ASSEMBLY-0011 Provider and Model Identifiers](ASSEMBLY-0011%20Provider%20and%20Model%20Identifiers.md) diff --git a/src/cli/adopt/mod.rs b/src/cli/adopt/mod.rs new file mode 100644 index 0000000..01abb02 --- /dev/null +++ b/src/cli/adopt/mod.rs @@ -0,0 +1,551 @@ +use clap::ValueEnum; +use commands::manifest; +use commands::parse; +use regex::Regex; +use std::fs; +use std::io::Read as _; +use std::path::{Component, Path, PathBuf}; +use std::sync::LazyLock; + +/// Hard cap on an adopted upstream body (10 MiB) so an adversarial or +/// misconfigured server cannot exhaust memory. +const MAX_ADOPT_BYTES: u64 = 10 * 1024 * 1024; + +#[cfg(test)] +mod tests; + +static GITHUB_BLOB_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^https://github\.com/([^/]+)/([^/]+)/blob/([0-9a-f]{40})/(.+)$") + .expect("anchored GitHub blob regex compiles") +}); +static GITHUB_RAW_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([0-9a-f]{40})/(.+)$") + .expect("anchored GitHub raw regex compiles") +}); +static HTTPS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^https://[^[:space:]]+$").expect("anchored HTTPS regex compiles") +}); +static FILE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^file:///.+$").expect("anchored file URL regex compiles")); +static PASCAL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[A-Z][A-Za-z0-9]*$").expect("anchored PascalCase regex compiles") +}); + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub(crate) enum Kind { + Skill, +} + +pub fn execute( + url: &str, + module: &str, + name: Option<&str>, + companion: Option<&str>, + kind: Kind, + dry_run: bool, +) -> Result { + execute_with_fetcher(url, module, name, companion, kind, dry_run, fetch) +} + +fn execute_with_fetcher( + url: &str, + module: &str, + name: Option<&str>, + companion: Option<&str>, + kind: Kind, + dry_run: bool, + fetcher: F, +) -> Result +where + F: Fn(&ClassifiedUrl) -> Result, String>, +{ + let source = classify_url(url)?; + let fetched_bytes = fetcher(&source)?; + let upstream_body = String::from_utf8(fetched_bytes) + .map_err(|error| format!("upstream body is not valid UTF-8: {error}"))?; + let upstream_digest = manifest::content_sha256(&upstream_body); + + let module_root = canonical_module_root(Path::new(module))?; + let plan = build_plan( + &module_root, + &source, + &upstream_body, + &upstream_digest, + name, + companion, + kind, + )?; + + check_existing(&plan.artifact_path, &upstream_digest)?; + + if dry_run { + println!("fetch: {}", source.original_url); + println!("place: {}", plan.artifact_path.display()); + println!("{}", plan.sidecar_yaml); + return Ok(0); + } + + if let Some(parent) = plan.artifact_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + } + fs::write(&plan.artifact_path, &plan.content) + .map_err(|error| format!("cannot write {}: {error}", plan.artifact_path.display()))?; + + if let Some(parent) = plan.sidecar_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + } + fs::write(&plan.sidecar_path, &plan.sidecar_yaml) + .map_err(|error| format!("cannot write {}: {error}", plan.sidecar_path.display()))?; + + println!("adopted {}", plan.artifact_relative); + Ok(0) +} + +#[derive(Debug)] +struct Plan { + artifact_path: PathBuf, + artifact_relative: String, + sidecar_path: PathBuf, + content: String, + sidecar_yaml: String, +} + +fn build_plan( + module_root: &Path, + source: &ClassifiedUrl, + upstream_body: &str, + upstream_digest: &str, + name: Option<&str>, + companion: Option<&str>, + kind: Kind, +) -> Result { + match (kind, companion) { + (Kind::Skill, Some(relative)) => { + companion_plan(module_root, source, upstream_body, relative) + } + (Kind::Skill, None) => { + skill_plan(module_root, source, upstream_body, upstream_digest, name) + } + } +} + +fn skill_plan( + module_root: &Path, + source: &ClassifiedUrl, + upstream_body: &str, + upstream_digest: &str, + name: Option<&str>, +) -> Result { + let skill_name = match name { + Some(value) => validate_skill_name(value)?.to_string(), + None => infer_pascal_name(source)?, + }; + let content = align_skill(upstream_body, &skill_name, &source.original_url)?; + let artifact_relative = format!("skills/{skill_name}/SKILL.md"); + let artifact_path = contained_path(module_root, Path::new(&artifact_relative))?; + let subject_digest = manifest::content_sha256(&content); + let sidecar_path = module_root.join(manifest::provenance_path(&artifact_relative)); + let sidecar_yaml = manifest::generate_adopt_statement( + &artifact_relative, + &subject_digest, + &source.original_url, + source.commit.as_deref().unwrap_or(""), + upstream_digest, + ); + Ok(Plan { + artifact_path, + artifact_relative, + sidecar_path, + content, + sidecar_yaml, + }) +} + +fn companion_plan( + module_root: &Path, + source: &ClassifiedUrl, + upstream_body: &str, + companion: &str, +) -> Result { + let relative_path = validate_relative_path(companion)?; + let content = parse::frontmatter_body(upstream_body).to_string(); + let artifact_path = contained_path(module_root, &relative_path)?; + let artifact_relative = path_to_slash(&relative_path); + let subject_digest = manifest::content_sha256(&content); + let upstream_digest = manifest::content_sha256(upstream_body); + let sidecar_path = module_root.join(manifest::provenance_path(&artifact_relative)); + let sidecar_yaml = manifest::generate_adopt_statement( + &artifact_relative, + &subject_digest, + &source.original_url, + source.commit.as_deref().unwrap_or(""), + &upstream_digest, + ); + Ok(Plan { + artifact_path, + artifact_relative, + sidecar_path, + content, + sidecar_yaml, + }) +} + +fn align_skill(content: &str, skill_name: &str, source_url: &str) -> Result { + let (frontmatter, body) = parse::split_frontmatter(content).unwrap_or(("", content)); + let mut mapping = if frontmatter.trim().is_empty() { + serde_yaml::Mapping::new() + } else { + serde_yaml::from_str::(frontmatter) + .map_err(|error| format!("invalid upstream frontmatter: {error}"))? + }; + + mapping.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(skill_name.to_string()), + ); + let description = parse::frontmatter_value(content, "description") + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("Adopted from {source_url}")); + mapping.insert( + serde_yaml::Value::String("description".to_string()), + serde_yaml::Value::String(description), + ); + + let yaml = serde_yaml::to_string(&mapping) + .map_err(|error| format!("cannot serialize aligned frontmatter: {error}"))?; + Ok(format!("---\n{yaml}---\n{}", body.trim_start_matches('\n'))) +} + +fn check_existing(artifact_path: &Path, upstream_digest: &str) -> Result<(), String> { + // `symlink_metadata` does not traverse the final component, so a broken (or + // dangling) symlink at the destination is still detected as existing — + // unlike `exists()`, which resolves it and would let `fs::write` follow it + // to an arbitrary target (path-boundary validation). + let Ok(metadata) = fs::symlink_metadata(artifact_path) else { + return Ok(()); + }; + if metadata.file_type().is_symlink() { + return Err(format!( + "{} is a symlink; refusing to write through it", + artifact_path.display() + )); + } + let sidecar_path = source_sidecar_path(artifact_path); + if !sidecar_path.is_file() { + return Err(format!( + "{} already exists without an adopt sidecar; refusing to overwrite", + artifact_path.display() + )); + } + let sidecar = manifest::provenance::read(&sidecar_path)?; + // Refuse to clobber local edits: the on-disk file must still match the + // digest the sidecar recorded for it, or a hand-edited skill would be + // silently overwritten on re-adopt. + let local_body = fs::read_to_string(artifact_path) + .map_err(|error| format!("cannot read {}: {error}", artifact_path.display()))?; + let local_digest = manifest::content_sha256(&local_body); + let subject_digest = sidecar + .provenance + .subject + .first() + .map(|subject| subject.digest.sha256.as_str()) + .ok_or_else(|| format!("{} has no subject digest", sidecar_path.display()))?; + if local_digest != subject_digest { + return Err(format!( + "{} has local edits (sha256:{local_digest} != recorded sha256:{subject_digest}); refusing to overwrite", + artifact_path.display() + )); + } + let existing_digest = sidecar + .provenance + .predicate + .build_definition + .resolved_dependencies + .iter() + .find(|dependency| dependency.name == "upstream") + .map(|dependency| dependency.digest.sha256.as_str()) + .ok_or_else(|| { + format!( + "{} has no upstream dependency digest; refusing to overwrite", + sidecar_path.display() + ) + })?; + if existing_digest != upstream_digest { + return Err(format!( + "upstream digest mismatch for {}: existing sha256:{existing_digest}, fetched sha256:{upstream_digest}", + artifact_path.display() + )); + } + Ok(()) +} + +fn source_sidecar_path(artifact_path: &Path) -> PathBuf { + let parent = artifact_path.parent().unwrap_or_else(|| Path::new(".")); + let stem = artifact_path + .file_stem() + .unwrap_or_default() + .to_string_lossy(); + parent + .join(manifest::PROVENANCE_DIRECTORY) + .join(format!("{stem}.{}", manifest::SIDECAR_EXTENSION)) +} + +#[derive(Debug)] +struct ClassifiedUrl { + original_url: String, + fetch_url: FetchUrl, + commit: Option, + source_path: String, +} + +#[derive(Debug)] +enum FetchUrl { + Https(String), + File(PathBuf), +} + +fn classify_url(url: &str) -> Result { + if let Some(captures) = GITHUB_BLOB_RE.captures(url) { + let owner = capture(&captures, 1)?; + let repo = capture(&captures, 2)?; + let commit = capture(&captures, 3)?; + let source_path = capture(&captures, 4)?; + return Ok(ClassifiedUrl { + original_url: url.to_string(), + fetch_url: FetchUrl::Https(format!( + "https://raw.githubusercontent.com/{owner}/{repo}/{commit}/{source_path}" + )), + commit: Some(commit.to_string()), + source_path: source_path.to_string(), + }); + } + + if let Some(captures) = GITHUB_RAW_RE.captures(url) { + let commit = capture(&captures, 3)?; + let source_path = capture(&captures, 4)?; + return Ok(ClassifiedUrl { + original_url: url.to_string(), + fetch_url: FetchUrl::Https(url.to_string()), + commit: Some(commit.to_string()), + source_path: source_path.to_string(), + }); + } + + if FILE_RE.is_match(url) { + let path = url + .strip_prefix("file://") + .ok_or_else(|| format!("invalid file URL: {url}"))?; + return Ok(ClassifiedUrl { + original_url: url.to_string(), + fetch_url: FetchUrl::File(PathBuf::from(path)), + commit: None, + source_path: path.to_string(), + }); + } + + if url.starts_with("https://github.com/") + || url.starts_with("https://raw.githubusercontent.com/") + { + return Err(format!( + "GitHub URL '{url}' must pin a 40-char commit SHA (…/blob//… or raw.githubusercontent.com////…); branch and tag refs are rejected because they are not immutable" + )); + } + + if HTTPS_RE.is_match(url) { + return Ok(ClassifiedUrl { + original_url: url.to_string(), + fetch_url: FetchUrl::Https(url.to_string()), + commit: None, + source_path: https_path_or_host(url), + }); + } + + Err(format!( + "unsupported URL '{url}': use https:// or file:// for tests" + )) +} + +fn capture<'a>(captures: &'a regex::Captures<'a>, index: usize) -> Result<&'a str, String> { + captures + .get(index) + .map(|matched| matched.as_str()) + .ok_or_else(|| "internal URL capture error".to_string()) +} + +fn fetch(source: &ClassifiedUrl) -> Result, String> { + match &source.fetch_url { + FetchUrl::File(path) => { + fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display())) + } + FetchUrl::Https(url) => { + // Do not follow redirects: adopt pins an exact URL, and blindly + // following a redirect is an SSRF vector (a public host could bounce + // the client to a loopback or cloud-metadata endpoint). A 3xx is + // reported so the caller can re-run with the final URL. + let agent: ureq::Agent = ureq::Agent::config_builder() + .max_redirects(0) + .build() + .into(); + let mut response = agent + .get(url) + .call() + .map_err(|error| format!("GET {url} failed: {error}"))?; + if !response.status().is_success() { + return Err(format!( + "GET {url} returned {}; redirects are not followed — pass the final URL", + response.status() + )); + } + // Cap the body so an unbounded/adversarial stream cannot OOM the + // process. Read one byte past the limit to detect truncation. + let mut body = Vec::new(); + response + .body_mut() + .as_reader() + .take(MAX_ADOPT_BYTES + 1) + .read_to_end(&mut body) + .map_err(|error| format!("cannot read response body from {url}: {error}"))?; + if body.len() as u64 > MAX_ADOPT_BYTES { + return Err(format!( + "upstream body from {url} exceeds the {MAX_ADOPT_BYTES}-byte adopt limit" + )); + } + Ok(body) + } + } +} + +fn canonical_module_root(module: &Path) -> Result { + let root = fs::canonicalize(module) + .map_err(|error| format!("cannot canonicalize module {}: {error}", module.display()))?; + if !root.is_dir() { + return Err(format!("module is not a directory: {}", root.display())); + } + Ok(root) +} + +fn contained_path(module_root: &Path, relative_path: &Path) -> Result { + let safe_relative = validate_relative_path(&path_to_slash(relative_path))?; + let target = module_root.join(&safe_relative); + let parent = target + .parent() + .ok_or_else(|| format!("{} has no parent directory", target.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + let canonical_parent = fs::canonicalize(parent) + .map_err(|error| format!("cannot canonicalize {}: {error}", parent.display()))?; + if !canonical_parent.starts_with(module_root) { + return Err(format!( + "target escapes module root: {}", + relative_path.display() + )); + } + Ok(target) +} + +fn validate_relative_path(path: &str) -> Result { + let relative = Path::new(path); + if relative.as_os_str().is_empty() || relative.is_absolute() { + return Err(format!("path must be relative to the module: {path}")); + } + let mut normalized = PathBuf::new(); + for component in relative.components() { + match component { + Component::Normal(segment) => normalized.push(segment), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(format!("path traversal is not allowed: {path}")); + } + } + } + if normalized.as_os_str().is_empty() { + return Err(format!("path must name a file: {path}")); + } + Ok(normalized) +} + +fn validate_skill_name(name: &str) -> Result<&str, String> { + if !PASCAL_RE.is_match(name) { + return Err(format!( + "--name must be a single PascalCase path segment, got '{name}'" + )); + } + Ok(name) +} + +fn infer_pascal_name(source: &ClassifiedUrl) -> Result { + let path = Path::new(&source.source_path); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let raw_name = if file_name == "SKILL.md" { + path.parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| first_host_label(&source.original_url)) + } else { + path.file_stem() + .and_then(|stem| stem.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| first_host_label(&source.original_url)) + }; + let pascal = to_pascal_case(raw_name); + validate_skill_name(&pascal)?; + Ok(pascal) +} + +fn first_host_label(url: &str) -> &str { + let host = url + .strip_prefix("https://") + .and_then(|rest| rest.split('/').next()) + .unwrap_or("AdoptedSkill"); + host.split('.').next().unwrap_or("AdoptedSkill") +} + +fn https_path_or_host(url: &str) -> String { + let rest = url.strip_prefix("https://").unwrap_or(url); + if let Some((host, path)) = rest.split_once('/') { + if path.is_empty() { + host.to_string() + } else { + path.to_string() + } + } else { + rest.to_string() + } +} + +fn to_pascal_case(input: &str) -> String { + let mut output = String::new(); + for segment in input.split(|character: char| !character.is_ascii_alphanumeric()) { + if segment.is_empty() { + continue; + } + let mut chars = segment.chars(); + if let Some(first) = chars.next() { + output.push(first.to_ascii_uppercase()); + for character in chars { + output.push(character.to_ascii_lowercase()); + } + } + } + if output.is_empty() { + "AdoptedSkill".to_string() + } else { + output + } +} + +fn path_to_slash(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + Component::Normal(segment) => Some(segment.to_string_lossy().to_string()), + _ => None, + }) + .collect::>() + .join("/") +} diff --git a/src/cli/adopt/tests.rs b/src/cli/adopt/tests.rs new file mode 100644 index 0000000..1578c73 --- /dev/null +++ b/src/cli/adopt/tests.rs @@ -0,0 +1,225 @@ +use super::*; + +const UPSTREAM: &str = "---\nname: upstream-skill\ndescription: Use when adopting fixtures.\nlicense: MIT\n---\n\n# Upstream\n\nBody.\n"; + +fn module() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("module.yaml"), "name: fixture\n").expect("module"); + dir +} + +#[test] +fn adopt_skill_writes_aligned_artifact_and_sidecar() { + let dir = module(); + let fetched_digest = manifest::content_sha256(UPSTREAM); + + execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + Some("AdoptedSkill"), + None, + Kind::Skill, + false, + |_| Ok(UPSTREAM.as_bytes().to_vec()), + ) + .expect("adopt succeeds"); + + let artifact = dir.path().join("skills/AdoptedSkill/SKILL.md"); + let content = std::fs::read_to_string(&artifact).expect("artifact"); + assert!(content.contains("name: AdoptedSkill")); + assert!(content.contains("description: Use when adopting fixtures.")); + + let sidecar_path = dir + .path() + .join("skills/AdoptedSkill/.provenance/SKILL.yaml"); + let sidecar = manifest::provenance::read(&sidecar_path).expect("sidecar parses"); + let definition = &sidecar.provenance.predicate.build_definition; + assert_eq!(definition.build_type, "adopt/v1"); + assert_eq!( + definition.external_parameters.upstream_url, + "https://example.test/RemoteSkill/SKILL.md" + ); + assert_eq!( + definition.external_parameters.upstream_commit, + Some(String::new()) + ); + assert_eq!( + definition.external_parameters.transforms_applied, + vec!["align".to_string()] + ); + assert_eq!(definition.resolved_dependencies[0].name, "upstream"); + assert_eq!( + definition.resolved_dependencies[0].digest.sha256, + fetched_digest + ); + assert_eq!( + sidecar.provenance.subject[0].digest.sha256, + manifest::content_sha256(&content) + ); +} + +#[test] +fn adopt_companion_strips_frontmatter() { + let dir = module(); + + execute_with_fetcher( + "file:///tmp/Companion.md", + dir.path().to_str().expect("utf8 temp path"), + None, + Some("skills/AdoptedSkill/REFERENCE.md"), + Kind::Skill, + false, + |_| Ok(UPSTREAM.as_bytes().to_vec()), + ) + .expect("adopt companion succeeds"); + + let companion = std::fs::read_to_string(dir.path().join("skills/AdoptedSkill/REFERENCE.md")) + .expect("companion"); + assert!(!companion.contains("name: upstream-skill")); + assert!(companion.contains("# Upstream")); +} + +#[test] +fn adopt_rejects_changed_upstream_digest() { + let dir = module(); + + execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + Some("AdoptedSkill"), + None, + Kind::Skill, + false, + |_| Ok(UPSTREAM.as_bytes().to_vec()), + ) + .expect("first adopt succeeds"); + + let result = execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + Some("AdoptedSkill"), + None, + Kind::Skill, + false, + |_| Ok(b"changed upstream\n".to_vec()), + ); + + assert!( + result + .expect_err("changed upstream digest must fail") + .contains("upstream digest mismatch") + ); +} + +#[test] +fn adopt_rejects_path_traversal() { + let dir = module(); + + let result = execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + None, + Some("../escape.md"), + Kind::Skill, + false, + |_| Ok(UPSTREAM.as_bytes().to_vec()), + ); + + assert!( + result + .expect_err("path traversal must fail") + .contains("path traversal") + ); +} + +#[test] +fn adopt_rejects_non_utf8_upstream() { + let dir = module(); + + let result = execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + Some("AdoptedSkill"), + None, + Kind::Skill, + false, + |_| Ok(vec![0xff, 0xfe]), + ); + + assert!( + result + .expect_err("non-utf8 upstream must fail") + .contains("not valid UTF-8") + ); +} + +#[test] +fn github_blob_classification_records_commit_and_raw_fetch_url() { + let source = classify_url( + "https://github.com/N4M3Z/forge-core/blob/0123456789abcdef0123456789abcdef01234567/skills/Demo/SKILL.md", + ) + .expect("github blob"); + + assert_eq!( + source.commit, + Some("0123456789abcdef0123456789abcdef01234567".to_string()) + ); + match source.fetch_url { + FetchUrl::Https(fetch_url) => assert_eq!( + fetch_url, + "https://raw.githubusercontent.com/N4M3Z/forge-core/0123456789abcdef0123456789abcdef01234567/skills/Demo/SKILL.md" + ), + FetchUrl::File(_) => panic!("expected https fetch"), + } +} + +#[test] +fn github_branch_url_is_rejected_not_fetched_as_html() { + let error = classify_url("https://github.com/N4M3Z/forge-core/blob/main/skills/Demo/SKILL.md") + .expect_err("branch ref must be rejected"); + assert!(error.contains("40-char commit SHA")); +} + +#[test] +fn local_edits_block_readopt() { + let dir = module(); + let adopt = || { + execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + Some("AdoptedSkill"), + None, + Kind::Skill, + false, + |_| Ok(UPSTREAM.as_bytes().to_vec()), + ) + }; + adopt().expect("first adopt succeeds"); + let artifact = dir.path().join("skills/AdoptedSkill/SKILL.md"); + std::fs::write(&artifact, "hand-edited\n").expect("edit artifact"); + + let error = adopt().expect_err("re-adopt over local edits must fail"); + assert!(error.contains("local edits"), "got: {error}"); +} + +#[cfg(unix)] +#[test] +fn symlink_destination_is_rejected() { + let dir = module(); + let skill_dir = dir.path().join("skills/AdoptedSkill"); + std::fs::create_dir_all(&skill_dir).expect("skill dir"); + std::os::unix::fs::symlink("/tmp/adopt-symlink-target", skill_dir.join("SKILL.md")) + .expect("symlink"); + + let error = execute_with_fetcher( + "https://example.test/RemoteSkill/SKILL.md", + dir.path().to_str().expect("utf8 temp path"), + Some("AdoptedSkill"), + None, + Kind::Skill, + false, + |_| Ok(UPSTREAM.as_bytes().to_vec()), + ) + .expect_err("symlink destination must be rejected"); + assert!(error.contains("symlink"), "got: {error}"); +} diff --git a/src/cli/assemble/sources.rs b/src/cli/assemble/sources.rs index 8c8e60f..ef79ac8 100644 --- a/src/cli/assemble/sources.rs +++ b/src/cli/assemble/sources.rs @@ -431,6 +431,14 @@ mod tests { assert!(qualifiers.contains("codex")); } + #[test] + fn build_qualifiers_uses_agentskills_key_not_agents_alias() { + let providers = vec!["agentskills".to_string(), "claude".to_string()]; + let qualifiers = build_valid_qualifiers(&providers, &make_models()); + assert!(qualifiers.contains("agentskills")); + assert!(!qualifiers.contains("agents")); + } + #[test] fn build_qualifiers_uses_exact_model_ids_not_segments() { let providers = vec!["claude".to_string()]; diff --git a/src/cli/find/mod.rs b/src/cli/find/mod.rs new file mode 100644 index 0000000..157eb96 --- /dev/null +++ b/src/cli/find/mod.rs @@ -0,0 +1,319 @@ +use clap::ValueEnum; +use commands::parse; +use commands::provider::ContentKind; +use serde::Serialize; +use std::cmp::Ordering; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub(crate) enum KindFilter { + Skills, + Agents, + Rules, +} + +impl KindFilter { + fn as_str(self) -> &'static str { + match self { + Self::Skills => "skills", + Self::Agents => "agents", + Self::Rules => "rules", + } + } +} + +#[derive(Debug, Serialize)] +pub struct FindResult { + name: String, + kind: String, + module: String, + path: String, + description: String, + score: f64, + source: String, +} + +struct Candidate { + name: String, + kind: ContentKind, + module: String, + path: PathBuf, + description: String, + triggers: String, +} + +pub fn execute(query: &str, kind: Option, json: bool) -> Result { + let cwd = std::env::current_dir().map_err(|error| format!("cannot read cwd: {error}"))?; + let watched_locations = super::watchlist::watched_locations(); + let modules = discover_modules(&cwd, &watched_locations); + let results = search_modules(&modules, query, kind); + if json { + print_json(&results)?; + } else { + print_console(&results); + } + Ok(0) +} + +fn discover_modules(root: &Path, watched_locations: &[PathBuf]) -> Vec { + let mut modules = Vec::new(); + let mut seen = HashSet::new(); + push_module(root, &mut modules, &mut seen); + + let local_repos = commands::services::discover_local_repos(root, watched_locations); + for repo in local_repos.values() { + push_module(repo, &mut modules, &mut seen); + } + for location in watched_locations { + push_module(location, &mut modules, &mut seen); + } + modules +} + +fn push_module(path: &Path, modules: &mut Vec, seen: &mut HashSet) { + let Ok(canonical) = fs::canonicalize(path) else { + return; + }; + if !canonical.join("module.yaml").is_file() || !seen.insert(canonical.clone()) { + return; + } + modules.push(canonical); +} + +fn search_modules( + modules: &[PathBuf], + query: &str, + kind_filter: Option, +) -> Vec { + let query_tokens = tokenize(query); + if query_tokens.is_empty() { + return Vec::new(); + } + + let mut results: Vec = modules + .iter() + .flat_map(|module| collect_candidates(module)) + .filter(|candidate| { + kind_filter.is_none_or(|filter| candidate.kind.as_str() == filter.as_str()) + }) + .filter_map(|candidate| score_candidate(candidate, &query_tokens)) + .collect(); + + results.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.path.cmp(&right.path)) + }); + results +} + +fn collect_candidates(module_root: &Path) -> Vec { + let module_name = module_name(module_root); + let mut candidates = Vec::new(); + candidates.extend(collect_flat_kind( + module_root, + &module_name, + ContentKind::Agents, + )); + candidates.extend(collect_flat_kind( + module_root, + &module_name, + ContentKind::Rules, + )); + candidates.extend(collect_skills(module_root, &module_name)); + candidates +} + +fn collect_flat_kind(module_root: &Path, module_name: &str, kind: ContentKind) -> Vec { + let mut files = Vec::new(); + collect_markdown_files(&module_root.join(kind.as_str()), &mut files); + files + .into_iter() + .filter_map(|path| candidate_from_file(module_root, module_name, kind, &path)) + .collect() +} + +fn collect_markdown_files(dir: &Path, files: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + collect_markdown_files(&path, files); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) == Some("md") { + files.push(path); + } + } +} + +fn collect_skills(module_root: &Path, module_name: &str) -> Vec { + let skills_root = module_root.join("skills"); + let Ok(entries) = fs::read_dir(&skills_root) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.path().join("SKILL.md")) + .filter(|path| path.is_file()) + .filter_map(|path| { + candidate_from_file(module_root, module_name, ContentKind::Skills, &path) + }) + .collect() +} + +fn candidate_from_file( + module_root: &Path, + module_name: &str, + kind: ContentKind, + path: &Path, +) -> Option { + let content = fs::read_to_string(path).ok()?; + let fallback_name = fallback_name(kind, path)?; + let name = parse::frontmatter_value(&content, "name").unwrap_or(fallback_name); + let mut description = String::new(); + if let Some(value) = parse::frontmatter_value(&content, "description") { + description = value; + } + let triggers = trigger_text(&content); + Some(Candidate { + name, + kind, + module: module_name.to_string(), + path: path.strip_prefix(module_root).unwrap_or(path).to_path_buf(), + description, + triggers, + }) +} + +fn fallback_name(kind: ContentKind, path: &Path) -> Option { + if kind == ContentKind::Skills { + return path + .parent() + .and_then(Path::file_name) + .map(|name| name.to_string_lossy().to_string()); + } + path.file_stem() + .map(|stem| stem.to_string_lossy().to_string()) +} + +fn trigger_text(content: &str) -> String { + let mut parts = Vec::new(); + for key in ["when_to_use", "use_when", "trigger", "triggers", "USE-WHEN"] { + if let Some(value) = parse::frontmatter_value(content, key) { + parts.push(value); + } + if let Some(value) = parse::frontmatter_list(content, key) { + parts.push(value); + } + } + let body = parse::frontmatter_body(content); + for line in body.lines() { + let lower = line.to_ascii_lowercase(); + if lower.contains("use when") || lower.contains("use-when") || lower.contains("trigger") { + parts.push(line.to_string()); + } + } + parts.join(" ") +} + +fn score_candidate(candidate: Candidate, query_tokens: &HashSet) -> Option { + let name_tokens = tokenize(&candidate.name); + let trigger_tokens = tokenize(&candidate.triggers); + let description_tokens = tokenize(&candidate.description); + + let name_hits = overlap(query_tokens, &name_tokens); + let trigger_hits = overlap(query_tokens, &trigger_tokens); + let description_hits = overlap(query_tokens, &description_tokens); + let score = f64::from(name_hits * 3 + trigger_hits * 2 + description_hits); + if score == 0.0 { + return None; + } + + Some(FindResult { + name: candidate.name, + kind: candidate.kind.as_str().to_string(), + module: candidate.module, + path: path_to_slash(&candidate.path), + description: candidate.description, + score, + source: "local".to_string(), + }) +} + +fn tokenize(value: &str) -> HashSet { + value + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +fn overlap(query: &HashSet, field: &HashSet) -> u32 { + query + .iter() + .filter(|token| field.contains(*token)) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + +fn module_name(module_root: &Path) -> String { + match commands::module::load(module_root) { + Ok(manifest) => manifest.name, + Err(_) => module_root.file_name().map_or_else( + || "module".to_string(), + |name| name.to_string_lossy().to_string(), + ), + } +} + +fn print_json(results: &[FindResult]) -> Result<(), String> { + let json = serde_json::to_string_pretty(results) + .map_err(|error| format!("cannot serialize find results: {error}"))?; + println!("{json}"); + Ok(()) +} + +fn print_console(results: &[FindResult]) { + if results.is_empty() { + println!("No matches."); + return; + } + for result in results { + println!( + "{:.1} {} {} {}", + result.score, result.kind, result.name, result.path + ); + if !result.description.is_empty() { + println!(" {}", result.description); + } + } +} + +fn path_to_slash(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + std::path::Component::Normal(segment) => Some(segment.to_string_lossy().to_string()), + _ => None, + }) + .collect::>() + .join("/") +} diff --git a/src/cli/find/tests.rs b/src/cli/find/tests.rs new file mode 100644 index 0000000..80c17d0 --- /dev/null +++ b/src/cli/find/tests.rs @@ -0,0 +1,128 @@ +use super::*; + +fn write_module(root: &Path, module_name: &str) { + std::fs::write( + root.join("module.yaml"), + format!("name: {module_name}\nversion: 0.1.0\ndescription: test\nevents: []\n"), + ) + .expect("module"); +} + +fn write_skill(root: &Path, name: &str, description: &str, body: &str) { + let skill_dir = root.join("skills").join(name); + std::fs::create_dir_all(&skill_dir).expect("skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"), + ) + .expect("skill"); +} + +fn write_rule(root: &Path, name: &str, description: &str) { + let rules_dir = root.join("rules"); + std::fs::create_dir_all(&rules_dir).expect("rules dir"); + std::fs::write( + rules_dir.join(format!("{name}.md")), + format!("---\nname: {name}\ndescription: {description}\n---\n\nRule body.\n"), + ) + .expect("rule"); +} + +#[test] +fn find_matches_trigger_word_across_modules() { + let first = tempfile::tempdir().expect("first"); + let second = tempfile::tempdir().expect("second"); + write_module(first.path(), "first-module"); + write_module(second.path(), "second-module"); + write_skill( + first.path(), + "AlphaSkill", + "General helper", + "## USE-WHEN\nUse when handling invoices.", + ); + write_skill( + second.path(), + "BetaSkill", + "General helper", + "## USE-WHEN\nUse when handling ledgers.", + ); + + let modules = vec![first.path().to_path_buf(), second.path().to_path_buf()]; + let results = search_modules(&modules, "ledgers", Some(KindFilter::Skills)); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "BetaSkill"); + assert_eq!(results[0].module, "second-module"); + assert_eq!(results[0].path, "skills/BetaSkill/SKILL.md"); + assert_eq!(results[0].source, "local"); +} + +#[test] +fn kind_filter_excludes_other_kinds() { + let module = tempfile::tempdir().expect("module"); + write_module(module.path(), "fixture"); + write_skill( + module.path(), + "InvoiceSkill", + "invoice workflow", + "Use when handling invoices.", + ); + write_rule(module.path(), "InvoiceRule", "invoice workflow"); + + let modules = vec![module.path().to_path_buf()]; + let results = search_modules(&modules, "invoice", Some(KindFilter::Rules)); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "InvoiceRule"); + assert_eq!(results[0].kind, "rules"); +} + +#[test] +fn json_shape_contains_expected_fields() { + let module = tempfile::tempdir().expect("module"); + write_module(module.path(), "fixture"); + write_skill( + module.path(), + "JsonSkill", + "json result fixture", + "Use when testing json output.", + ); + + let modules = vec![module.path().to_path_buf()]; + let results = search_modules(&modules, "json", Some(KindFilter::Skills)); + let value: serde_json::Value = serde_json::to_value(&results).expect("json"); + + assert_eq!(value[0]["name"], "JsonSkill"); + assert_eq!(value[0]["kind"], "skills"); + assert_eq!(value[0]["module"], "fixture"); + assert_eq!(value[0]["path"], "skills/JsonSkill/SKILL.md"); + assert_eq!(value[0]["description"], "json result fixture"); + assert_eq!(value[0]["source"], "local"); + assert!(value[0]["score"].as_f64().expect("score") > 0.0); +} + +#[test] +fn deterministic_order_uses_name_tiebreak() { + let module = tempfile::tempdir().expect("module"); + write_module(module.path(), "fixture"); + write_skill( + module.path(), + "ZuluSkill", + "shared keyword", + "Body without extra match.", + ); + write_skill( + module.path(), + "AlphaSkill", + "shared keyword", + "Body without extra match.", + ); + + let modules = vec![module.path().to_path_buf()]; + let results = search_modules(&modules, "keyword", Some(KindFilter::Skills)); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].name, "AlphaSkill"); + assert_eq!(results[1].name, "ZuluSkill"); + assert!((results[0].score - results[1].score).abs() < f64::EPSILON); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e43c153..eb12792 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,4 @@ +mod adopt; mod assemble; pub(crate) mod config; mod copy; @@ -8,6 +9,7 @@ mod dispatch; mod dotforge; mod drift; mod exec; +mod find; mod init; mod install; mod ontology; @@ -232,6 +234,42 @@ enum Command { /// Show the resolved forge ontology and configuration Config, + /// Adopt an upstream skill artifact into a module with provenance + Adopt { + /// HTTPS URL of the upstream artifact. file:// is allowed for tests. + url: String, + + /// Target module root. Defaults to the current directory. + #[arg(long, value_name = "DIR", default_value = ".")] + module: String, + + /// Skill name to place under skills//SKILL.md. + #[arg(long, value_name = "PascalCase")] + name: Option, + + /// Place the fetched body as this companion file instead of a skill. + #[arg(long, value_name = "FILE")] + companion: Option, + + /// Artifact kind to adopt. + #[arg(long, value_enum, default_value_t = adopt::Kind::Skill)] + kind: adopt::Kind, + + /// Print the planned fetch, placement, and sidecar without writing files. + #[arg(long)] + dry_run: bool, + }, + + /// Find local skills, agents, and rules by relevance + Find { + /// Search query. + query: String, + + /// Restrict results to one artifact kind. + #[arg(long, value_enum)] + kind: Option, + }, + /// Run a script bundled with a forge skill #[command( after_help = "EXEC OPTIONS:\n --script Script name or relative path inside the skill directory\n --json JSON object passed to the child on stdin and as INPUT_* variables\n --dry-run Print the resolved command and injected environment without spawning\n -- ARGS... Arguments passed to the skill script" @@ -402,6 +440,24 @@ pub fn run() -> i32 { "cleaned", ), Command::Config => return exit_code(ontology::show(args.json)), + Command::Adopt { + url, + module, + name, + companion, + kind, + dry_run, + } => { + return exit_code(adopt::execute( + &url, + &module, + name.as_deref(), + companion.as_deref(), + kind, + dry_run, + )); + } + Command::Find { query, kind } => return exit_code(find::execute(&query, kind, args.json)), Command::Exec { skill, rest } => { return exit_code(exec::execute_cli(&skill, args.json, &rest)); } diff --git a/src/cli/validate/tools.rs b/src/cli/validate/tools.rs index f614e87..0a4328a 100644 --- a/src/cli/validate/tools.rs +++ b/src/cli/validate/tools.rs @@ -4,6 +4,12 @@ use std::process::Command; use crate::cli::config; +const SEMGREP_ENVIRONMENT: &[(&str, &str)] = &[ + ("OTEL_SDK_DISABLED", "true"), + ("SEMGREP_ENABLE_VERSION_CHECK", "0"), + ("SEMGREP_SEND_METRICS", "off"), +]; + pub fn run_external_checks(module_root: &Path, result: &mut ActionResult) { let exclude_patterns = load_exclude_patterns(module_root); @@ -198,12 +204,12 @@ fn check_ruff(module_root: &Path, result: &mut ActionResult) { } fn check_semgrep(module_root: &Path, result: &mut ActionResult) { - if !has_tool("semgrep") { + if !has_usable_semgrep(module_root) { return; } println!(" semgrep OWASP"); - if !run_command( + if !run_command_with_env( "semgrep", &[ "scan", @@ -213,6 +219,7 @@ fn check_semgrep(module_root: &Path, result: &mut ActionResult) { ".", ], module_root, + SEMGREP_ENVIRONMENT, ) { result.errors.push("semgrep found issues".to_string()); } @@ -240,6 +247,18 @@ fn has_tool(name: &str) -> bool { .is_ok_and(|output| output.status.success()) } +fn has_usable_semgrep(working_directory: &Path) -> bool { + if !has_tool("semgrep") { + return false; + } + let mut command = Command::new("semgrep"); + command + .arg("--version") + .current_dir(working_directory) + .envs(SEMGREP_ENVIRONMENT.iter().copied()); + command.output().is_ok_and(|output| output.status.success()) +} + fn run_command(program: &str, arguments: &[&str], working_directory: &Path) -> bool { Command::new(program) .args(arguments) @@ -248,6 +267,20 @@ fn run_command(program: &str, arguments: &[&str], working_directory: &Path) -> b .is_ok_and(|status| status.success()) } +fn run_command_with_env( + program: &str, + arguments: &[&str], + working_directory: &Path, + environment: &[(&str, &str)], +) -> bool { + let mut command = Command::new(program); + command.args(arguments).current_dir(working_directory); + for (key, value) in environment { + command.env(key, value); + } + command.status().is_ok_and(|status| status.success()) +} + fn find_text_files(module_root: &Path) -> Vec { let text_extensions = [ "md", "yaml", "yml", "toml", "json", "sh", "rs", "py", "ts", "tsx", "js", diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 8e65cb9..9f25a71 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -11,7 +11,7 @@ use sha2::{Digest, Sha256}; pub use read::read; pub use staleness::check_sources; -pub use statement::generate_statement; +pub use statement::{generate_adopt_statement, generate_statement}; pub use status::status; pub use write::write; diff --git a/src/manifest/provenance.rs b/src/manifest/provenance.rs index 8f446dc..db6c0c6 100644 --- a/src/manifest/provenance.rs +++ b/src/manifest/provenance.rs @@ -90,16 +90,17 @@ impl BuildDefinition { /// External build parameters. `source` is the forge-side origin URI; /// `upstream_url` / `upstream_commit` / `transforms_applied` appear instead on -/// `adopt/v1` sidecars. All but `source` are skipped on serialization when -/// empty so generated `assemble/v1` sidecars are unchanged. +/// `adopt/v1` sidecars. Adopt statements set `upstream_commit` to `Some("")` +/// for plain HTTPS so the empty pin is explicit; generated `assemble/v1` +/// sidecars leave it as `None` so their output is unchanged. #[derive(Debug, Default, Deserialize, Serialize)] pub struct ExternalParameters { #[serde(default)] pub source: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub upstream_url: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub upstream_commit: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_commit: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub transforms_applied: Vec, } diff --git a/src/manifest/statement.rs b/src/manifest/statement.rs index d50b842..aa99595 100644 --- a/src/manifest/statement.rs +++ b/src/manifest/statement.rs @@ -56,3 +56,54 @@ pub fn generate_statement( serde_yaml::to_string(&sidecar) .expect("ProvenanceSidecar serialization is infallible by construction") } + +pub fn generate_adopt_statement( + subject_name: &str, + subject_digest: &str, + upstream_url: &str, + upstream_commit: &str, + upstream_digest: &str, +) -> String { + let sidecar = ProvenanceSidecar { + provenance: ProvenanceStatement { + statement_type: STATEMENT_TYPE.to_string(), + subject: vec![Subject { + name: subject_name.to_string(), + digest: DigestMap { + sha256: subject_digest.to_string(), + }, + }], + predicate: Predicate { + build_definition: BuildDefinition { + build_type: "adopt/v1".to_string(), + external_parameters: ExternalParameters { + upstream_url: upstream_url.to_string(), + upstream_commit: Some(upstream_commit.to_string()), + transforms_applied: vec!["align".to_string()], + ..ExternalParameters::default() + }, + resolved_dependencies: vec![Dependency { + name: "upstream".to_string(), + uri: upstream_url.to_string(), + digest: DigestMap { + sha256: upstream_digest.to_string(), + }, + }], + }, + run_details: RunDetails { + builder: Builder { + id: "forge-cli".to_string(), + version: BuilderVersion { + forge: env!("CARGO_PKG_VERSION").to_string(), + }, + }, + metadata: Metadata { + started_on: chrono::Utc::now().to_rfc3339(), + }, + }, + }, + }, + }; + serde_yaml::to_string(&sidecar) + .expect("ProvenanceSidecar serialization is infallible by construction") +} diff --git a/src/provider/tests.rs b/src/provider/tests.rs index d0619ef..dc16a43 100644 --- a/src/provider/tests.rs +++ b/src/provider/tests.rs @@ -16,6 +16,7 @@ fn load_providers_parses_all_providers() { assert!(providers.contains_key("claude")); assert!(providers.contains_key("gemini")); + assert!(providers.contains_key("agentskills")); assert!(providers.contains_key("codex")); assert!(providers.contains_key("opencode")); } @@ -26,6 +27,7 @@ fn load_providers_reads_target() { assert_eq!(providers["claude"].target, ".claude"); assert_eq!(providers["gemini"].target, ".gemini"); + assert_eq!(providers["agentskills"].target, ".agents"); } #[test] @@ -42,6 +44,26 @@ fn load_providers_reads_assembly_steps() { assert!(claude.assembly.is_none()); } +#[test] +fn load_providers_reads_agentskills_skill_whitelist() { + let providers = load_providers(DEFAULTS).unwrap(); + + let agentskills = &providers["agentskills"]; + let keep_fields = agentskills.keep_fields.as_ref().unwrap(); + assert_eq!( + keep_fields.get("skills"), + Some(&vec![ + "name".to_string(), + "description".to_string(), + "license".to_string(), + "compatibility".to_string(), + "metadata".to_string(), + "allowed-tools".to_string(), + ]) + ); + assert!(agentskills.matches_target("agents", "agentskills")); +} + #[test] fn load_providers_deploy_is_none_when_absent() { let providers = load_providers(DEFAULTS).unwrap(); diff --git a/templates/init/defaults.yaml b/templates/init/defaults.yaml index d916de6..63e4842 100644 --- a/templates/init/defaults.yaml +++ b/templates/init/defaults.yaml @@ -1,3 +1,14 @@ providers: claude: target: ".claude" + + agentskills: + target: ".agents" + aliases: + - agents + assembly: + - strip-links + keep_fields: + agents: [name, description] + skills: [name, description, license, compatibility, metadata, allowed-tools] + rules: [] diff --git a/tests/deploy.rs b/tests/deploy.rs index 5cc9747..85115a7 100644 --- a/tests/deploy.rs +++ b/tests/deploy.rs @@ -380,6 +380,33 @@ fn install_deploys_skill_with_companion() { ); } +#[test] +fn install_deploys_skill_to_agentskills_provider() { + let module_directory = tempfile::tempdir().unwrap(); + let target_directory = tempfile::tempdir().unwrap(); + + scaffold_module(module_directory.path()); + create_skill(module_directory.path(), "AgentSkill"); + + forge() + .args([ + "install", + "--source", + module_directory.path().to_str().unwrap(), + "--target", + target_directory.path().to_str().unwrap(), + "--provider", + "agents", + ]) + .assert() + .success(); + + let deployed = target_directory + .path() + .join(".agents/skills/AgentSkill/SKILL.md"); + assert!(deployed.is_file(), "expected {}", deployed.display()); +} + // --- Manifest tests --- #[test] diff --git a/tests/fixtures/configs/defaults-basic.yaml b/tests/fixtures/configs/defaults-basic.yaml index aa9659c..0262d88 100644 --- a/tests/fixtures/configs/defaults-basic.yaml +++ b/tests/fixtures/configs/defaults-basic.yaml @@ -8,6 +8,17 @@ providers: - kebab-case-agents - remap-tools + agentskills: + target: ".agents" + aliases: + - agents + assembly: + - strip-links + keep_fields: + agents: [name, description] + skills: [name, description, license, compatibility, metadata, allowed-tools] + rules: [] + codex: target: ".codex" assembly: