From 05b0e93784b376168f98ffdb8da02b7272851a26 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Thu, 9 Jul 2026 02:20:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20minimal=20kernel=20=E2=80=94=20unified?= =?UTF-8?q?=20config,=20forge=20exec,=20git-style=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commands::ontology loads ~/.config/forge/config.yaml (typed, FORGE_* env precedence, project.yaml fallback with deprecation warning). forge config prints the resolved ontology. forge exec runs skill-bundled scripts by extension (.py via uv run, .sh via bash, .ts via deno), injecting FORGE_*/ INPUT_* env and wrapping stdout/stderr with optional outputSchema validation. Unknown 'forge ' dispatches to an external forge- in the module commands/ dir, config extension dirs, then PATH — so new verbs are scripts, not Rust. ADRs CLI-0013/0014/0015. --- CHANGELOG.md | 3 + .../CLI-0013 Unified Config and Ontology.md | 65 ++ .../CLI-0014 Exec Runtime Contract.md | 68 +++ ...015 Git-Style External Command Dispatch.md | 66 +++ src/cli/dispatch.rs | 115 ++++ src/cli/dispatch/tests.rs | 30 + src/cli/exec/mod.rs | 556 ++++++++++++++++++ src/cli/exec/tests.rs | 176 ++++++ src/cli/mod.rs | 30 + src/cli/ontology.rs | 27 + src/commands.rs | 1 + src/ontology.rs | 396 +++++++++++++ src/ontology/tests.rs | 97 +++ tests/cli.rs | 78 +++ 14 files changed, 1708 insertions(+) create mode 100644 docs/decisions/CLI-0013 Unified Config and Ontology.md create mode 100644 docs/decisions/CLI-0014 Exec Runtime Contract.md create mode 100644 docs/decisions/CLI-0015 Git-Style External Command Dispatch.md create mode 100644 src/cli/dispatch.rs create mode 100644 src/cli/dispatch/tests.rs create mode 100644 src/cli/exec/mod.rs create mode 100644 src/cli/exec/tests.rs create mode 100644 src/cli/ontology.rs create mode 100644 src/ontology.rs create mode 100644 src/ontology/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e221b1f..445480d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- `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. - `forge tui` and bare `forge` under `--features tui` launch a ratatui terminal dashboard over the shared `commands::services` scan model, with artifacts, provenance, projects/ontology placeholder, sources/watch/find panes, and a command palette. - Model-level qualifier resolution for rules and agents (PROV-0005 Phase 1). Assembly now resolves the full `user/` > `provider//` > `provider/` > base precedence: a file at `rules///Rule.md` overrides the base for that provider and model, and the long-documented `user/` overlay is finally wired for rules and agents too. Each provider gains a default `model` in `defaults.yaml` (an exact ID from `config/models.yaml`); `forge assemble --model ` and `forge install --model ` override it for providers that list that model. Qualifier directory names are validated as exact model IDs: model IDs are no longer split into segments, so a directory named `4` or `6` (from `claude-opus-4-6`) is no longer a valid qualifier, and a model-only file under `rules///` is collected instead of silently dropped. An unrecognized model-qualifier subdirectory is skipped with a warning rather than dropped silently. Skill overlays, recording the model in `.manifest`/provenance sidecars, and the per-model release matrix are deferred to Phase 2. (#60) - `forge drift --target ` verifies a module's assembled `build/` against where it was deployed, scoped to the module's own files. It mirrors `forge install --target`: each provider's `build/` is compared to `/`, so files built but not yet deployed surface as local-only, deployment edits surface as frontmatter/body drift, and this module's deployed files (per the target `.manifest` plus provenance attribution) that are no longer built surface as drift. Unlike `--upstream` against a multi-module tree such as `~/.claude`, other modules' files are never reported. `--target` and `--upstream` are mutually exclusive. (#61) diff --git a/docs/decisions/CLI-0013 Unified Config and Ontology.md b/docs/decisions/CLI-0013 Unified Config and Ontology.md new file mode 100644 index 0000000..6ede331 --- /dev/null +++ b/docs/decisions/CLI-0013 Unified Config and Ontology.md @@ -0,0 +1,65 @@ +--- +title: "Unified Config and Ontology" +description: "Forge uses one typed config file for ontology paths and extension roots" +type: adr +category: cli +tags: + - cli + - config + - ontology +status: accepted +created: 2026-07-08 +updated: 2026-07-08 +author: "@N4M3Z" +project: forge-cli +related: + - "CLI-0011 Watchlist Monitored-Source Registry" + - "RUST-0001 Structured Errors with ErrorKind" + - "RUST-0006 Synchronous Core" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: [] +informed: [] +upstream: [] +--- + +# Unified Config and Ontology + +## Context and Problem Statement + +Forge commands need a shared vocabulary for user directories such as the workshop, archive, vault, data, mount, developer, documents, and git hooks locations. A shell-level `project.yaml` reader already carries part of that ontology, but Rust commands cannot rely on shell functions for child process execution, extension lookup, or machine-readable config output. The kernel also needs to stay small: config loading should provide facts to scripts without absorbing script-level capabilities into Rust. + +## Decision Drivers + +- One typed source of truth for ontology values +- Deterministic precedence for environment overrides, config files, and defaults +- Compatibility with the legacy `project.yaml` shape for one migration window +- Pure library logic with no command output side effects +- No async, network, or dynamic config dependencies + +## Considered Options + +1. **Keep `project.yaml` as the only config.** This avoids migration, but keeps the Rust CLI coupled to a file shaped for shell functions and leaves no top-level home for extensions, launch, and watch sections. +2. **Adopt `~/.config/forge/config.yaml`.** A typed user config can group ontology and extension roots while preserving deterministic environment override behavior. +3. **Use per-command config files.** Each feature could own a file, but scripts and external dispatch need the same ontology and would duplicate precedence rules. + +## Decision Outcome + +Chosen option: **Option 2**, because a single typed config gives the minimal Rust kernel enough shared facts to launch scripts and expose the resolved ontology while keeping behavior outside Rust. + +Forge reads `~/.config/forge/config.yaml` into typed structs. The top-level config denies unknown fields so mistakes fail loudly, while reserved `launch` and `watch` sections remain parse-tolerant for future use. Ontology accessors resolve values with `FORGE_*` environment variables first, config file values second, and built-in defaults last. Path-like ontology values expand a leading `~/` after resolution. + +If `config.yaml` is absent, Forge reads `project.yaml` from the same directory as a deprecated fallback and maps `defaults.domain` to the ontology `domain` key. The fallback emits one process-wide warning so interactive commands make the migration visible without spamming repeated loads. + +## Consequences + +- `forge config` can display the exact effective ontology, including provenance for each key. +- `forge exec` and external dispatch inject the same `FORGE_*` values into child processes. +- Legacy `project.yaml` users retain one release of overlap. +- Unknown top-level keys in `config.yaml` are errors, which prevents silent misspellings. +- Capability remains in scripts; Rust only loads config and reports resolved facts. + +## More Information + +- [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-0014 Exec Runtime Contract.md b/docs/decisions/CLI-0014 Exec Runtime Contract.md new file mode 100644 index 0000000..c32472a --- /dev/null +++ b/docs/decisions/CLI-0014 Exec Runtime Contract.md @@ -0,0 +1,68 @@ +--- +title: "Exec Runtime Contract" +description: "Forge executes skill-bundled scripts through a small synchronous runtime contract" +type: adr +category: cli +tags: + - cli + - exec + - skills + - scripts +status: accepted +created: 2026-07-08 +updated: 2026-07-08 +author: "@N4M3Z" +project: forge-cli +related: + - "CLI-0013 Unified Config and Ontology" + - "CLI-0015 Git-Style External Command Dispatch" + - "RUST-0006 Synchronous Core" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: [] +informed: [] +upstream: [] +--- + +# Exec Runtime Contract + +## Context and Problem Statement + +Skills often need small executable helpers for tasks such as data shaping, local file transforms, and provider-specific glue. Implementing those verbs in Rust would grow the kernel and make every capability a compile-time feature. Forge needs a stable way to run scripts bundled next to `SKILL.md` while providing enough context for scripts to behave consistently across modules and extensions. + +## Decision Drivers + +- Keep new capabilities in scripts, not Rust commands +- Reuse the existing skill directory convention of `skills//SKILL.md` +- Provide deterministic environment and stdin contracts for automation +- Support JSON validation without requiring scripts to link Rust code +- Stay synchronous and avoid venv or package-manager orchestration in Rust + +## Considered Options + +1. **Add one Rust subcommand per capability.** This is easy to type-check, but it makes the kernel responsible for feature growth. +2. **Execute scripts declared by skill frontmatter.** Skills carry their own helper scripts and Forge only resolves, launches, and validates them. +3. **Require every script to be directly executable.** This matches Unix habits, but it makes Windows and archive extraction behavior more fragile and duplicates interpreter selection. + +## Decision Outcome + +Chosen option: **Option 2**, because a frontmatter-declared script contract lets skills ship capability without expanding the Rust kernel. + +`forge exec ` locates a skill under `FORGE_ROOT/skills/` or configured extension skill roots. The script comes from `--script ` or from an `exec:` block in `SKILL.md` frontmatter with `script`, optional `runtime`, optional `inputSchema`, and optional `outputSchema` fields. Missing script declarations return exit code 3 with guidance to declare `exec:` or pass `--script`. + +Runtime dispatch is a small table. `.py` uses `uv run`, `.sh` and `.bash` use `bash`, `.ts` uses `deno run`, and `.js`/`.mjs` use `node`. TypeScript chooses Deno because it is a single self-contained runtime for scripts and does not imply `node_modules` management in Rust. + +Forge injects `FORGE_ROOT`, `FORGE_SKILL_DIR`, resolved ontology `FORGE_*` values, `CI=1`, and `INPUT_*` variables derived from the top-level JSON input object. In JSON mode, Forge captures child output and wraps it as `{ ok, exit_code, structured, stdout, stderr }`. When `outputSchema` is present, child stdout must parse as JSON and validate against the schema file relative to the skill directory. + +## Consequences + +- Skills can carry executable capability next to their instructions. +- Scripts do not need executable bits because Forge invokes them through interpreters. +- Rust does not create virtual environments, install packages, or fetch dependencies. +- JSON wrappers give automation a stable envelope while preserving raw stdout and stderr. +- Schema validation belongs to the kernel boundary because it is a contract check, not a capability implementation. + +## More Information + +- [CLI-0013 Unified Config and Ontology](CLI-0013%20Unified%20Config%20and%20Ontology.md) +- [RUST-0006 Synchronous Core](RUST-0006%20Synchronous%20Core.md) diff --git a/docs/decisions/CLI-0015 Git-Style External Command Dispatch.md b/docs/decisions/CLI-0015 Git-Style External Command Dispatch.md new file mode 100644 index 0000000..c856f9a --- /dev/null +++ b/docs/decisions/CLI-0015 Git-Style External Command Dispatch.md @@ -0,0 +1,66 @@ +--- +title: "Git-Style External Command Dispatch" +description: "Unknown forge verbs dispatch to forge- scripts before failing" +type: adr +category: cli +tags: + - cli + - dispatch + - extensions + - scripts +status: accepted +created: 2026-07-08 +updated: 2026-07-08 +author: "@N4M3Z" +project: forge-cli +related: + - "CLI-0013 Unified Config and Ontology" + - "CLI-0014 Exec Runtime Contract" + - "RUST-0006 Synchronous Core" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: [] +informed: [] +upstream: [] +--- + +# Git-Style External Command Dispatch + +## Context and Problem Statement + +Forge needs room for local verbs, module-specific workflows, and extension commands without adding a Rust enum variant for every new action. Git solves this by dispatching unknown verbs to `git-` executables. The same pattern fits Forge because the kernel only needs to provide config, process execution, and dispatch; real capability can live in scripts owned by modules or extensions. + +## Decision Drivers + +- Keep the Rust CLI small and stable +- Let modules and extensions add verbs without recompiling Forge +- Preserve clap handling for known subcommands +- Share ontology environment injection with `forge exec` +- Fail unknown commands with a clean message and deterministic exit code + +## Considered Options + +1. **Reject every unknown clap subcommand.** This is strict, but forces every verb into Rust. +2. **Use clap `external_subcommand` and spawn `forge-`.** Known commands still parse normally, while unknown commands become script dispatch. +3. **Make an explicit `forge run ` namespace.** This avoids fallback behavior, but it makes extension commands feel second-class and diverges from Git-style muscle memory. + +## Decision Outcome + +Chosen option: **Option 2**, because `external_subcommand` gives Forge a script extension point while preserving strong parsing for built-in commands. + +Unknown `forge ` invocations resolve to `forge-` by searching `FORGE_ROOT/commands/`, then each configured extension directory, then `PATH`. The first match wins. Forge passes remaining arguments verbatim, inherits stdio, injects `FORGE_ROOT`, resolved ontology `FORGE_*` values, and `CI=1`, then returns the child process exit code. + +If no script is found, Forge prints `error: unknown command 'forge ' (no forge- script found)` and exits 2. There is no clap panic fallback for unknown verbs. + +## Consequences + +- New verbs can be shipped as scripts in modules, extensions, or user `PATH`. +- The Rust command enum remains focused on kernel services and stable built-ins. +- External commands receive the same ontology context as exec scripts. +- Search order makes module-local commands override extensions, and extensions override ambient `PATH`. +- Script authors own their dependencies and behavior; Rust only dispatches. + +## More Information + +- [CLI-0013 Unified Config and Ontology](CLI-0013%20Unified%20Config%20and%20Ontology.md) +- [CLI-0014 Exec Runtime Contract](CLI-0014%20Exec%20Runtime%20Contract.md) diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs new file mode 100644 index 0000000..d045357 --- /dev/null +++ b/src/cli/dispatch.rs @@ -0,0 +1,115 @@ +use commands::ontology; +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; + +pub fn external(args: &[OsString]) -> Result { + external_with_context( + args, + &env::current_dir().map_err(|error| error.to_string())?, + ) +} + +fn external_with_context(args: &[OsString], cwd: &Path) -> Result { + let Some(verb) = args.first() else { + eprintln!("error: missing external command"); + return Ok(2); + }; + let Some(verb_text) = verb.to_str() else { + eprintln!("error: external command is not valid UTF-8"); + return Ok(2); + }; + if verb_text.contains(['/', '\\']) { + eprintln!( + "error: invalid command 'forge {verb_text}': name must not contain path separators" + ); + return Ok(2); + } + let root = forge_root_from(cwd)?; + let config = ontology::load().map_err(|error| error.to_string())?; + let command_name = format!("forge-{verb_text}"); + + let Some(executable) = resolve_external(&command_name, &root, &config.extensions) else { + eprintln!("error: unknown command 'forge {verb_text}' (no forge-{verb_text} script found)"); + return Ok(2); + }; + + let env = forge_env(&root, &config); + let mut command = ProcessCommand::new(executable); + command.args(&args[1..]); + apply_env(&mut command, &env); + + let status = command + .status() + .map_err(|error| format!("cannot run forge {verb_text}: {error}"))?; + Ok(status.code().unwrap_or(1)) +} + +pub(crate) fn forge_root_from(cwd: &Path) -> Result { + let absolute = absolutize(cwd)?; + for candidate in absolute.ancestors() { + if candidate.join("module.yaml").is_file() { + return Ok(candidate.to_path_buf()); + } + } + Ok(absolute) +} + +fn absolutize(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|error| error.to_string()) +} + +fn resolve_external(command_name: &str, root: &Path, extensions: &[PathBuf]) -> Option { + let local = root.join("commands").join(command_name); + if local.is_file() { + return Some(local); + } + for extension in extensions { + let candidate = extension.join(command_name); + if candidate.is_file() { + return Some(candidate); + } + } + resolve_on_path(command_name) +} + +fn resolve_on_path(command_name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|dir| dir.join(command_name)) + .find(|candidate| candidate.is_file()) +} + +pub(crate) fn forge_env( + root: &Path, + config: &ontology::ResolvedConfig, +) -> Vec<(OsString, OsString)> { + let mut env = vec![ + ( + OsString::from("FORGE_ROOT"), + root.as_os_str().to_os_string(), + ), + (OsString::from("CI"), OsString::from("1")), + ]; + env.extend( + ontology::env_vars(config) + .into_iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))), + ); + env +} + +pub(crate) fn apply_env(command: &mut ProcessCommand, env: &[(OsString, OsString)]) { + for (key, value) in env { + command.env(key, value); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/cli/dispatch/tests.rs b/src/cli/dispatch/tests.rs new file mode 100644 index 0000000..2e458c6 --- /dev/null +++ b/src/cli/dispatch/tests.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn forge_root_uses_nearest_ancestor_with_module_yaml() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("root"); + let child = root.join("a/b"); + std::fs::create_dir_all(&child).expect("create child"); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + + assert_eq!(forge_root_from(&child).expect("root"), root); +} + +#[test] +fn resolve_external_prefers_root_commands_then_extensions() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("root"); + let extension = dir.path().join("extension"); + std::fs::create_dir_all(root.join("commands")).expect("root commands"); + std::fs::create_dir_all(&extension).expect("extension"); + let root_command = root.join("commands/forge-hello"); + let extension_command = extension.join("forge-hello"); + std::fs::write(&root_command, "#!/usr/bin/env bash\n").expect("root script"); + std::fs::write(&extension_command, "#!/usr/bin/env bash\n").expect("extension script"); + + assert_eq!( + resolve_external("forge-hello", &root, &[extension]), + Some(root_command) + ); +} diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs new file mode 100644 index 0000000..857735a --- /dev/null +++ b/src/cli/exec/mod.rs @@ -0,0 +1,556 @@ +use crate::cli::dispatch; +use commands::ontology; +use commands::parse; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::ffi::{OsStr, OsString}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, Stdio}; + +const RUNTIMES: &[Runtime] = &[ + Runtime { + names: &["python", "py", "uv", "uv run"], + extensions: &[".py"], + argv_prefix: &["uv", "run"], + }, + Runtime { + names: &["bash", "sh", "shell"], + extensions: &[".sh", ".bash"], + argv_prefix: &["bash"], + }, + Runtime { + names: &["typescript", "ts", "deno", "deno run"], + extensions: &[".ts"], + argv_prefix: &["deno", "run"], + }, + Runtime { + names: &["javascript", "js", "mjs", "node"], + extensions: &[".js", ".mjs"], + argv_prefix: &["node"], + }, +]; + +pub fn execute_cli(skill: &str, json: bool, rest: &[OsString]) -> Result { + let parsed = parse_cli_tail(json, rest)?; + execute( + skill, + parsed.script.as_deref(), + parsed.input_json.as_deref(), + parsed.json, + parsed.dry_run, + &parsed.args, + ) +} + +fn execute( + skill: &str, + script: Option<&str>, + input_json: Option<&str>, + json: bool, + dry_run: bool, + args: &[OsString], +) -> Result { + let input = parse_input(input_json)?; + let context = ExecContext { + cwd: std::env::current_dir().map_err(|error| error.to_string())?, + config: ontology::load().map_err(|error| error.to_string())?, + }; + let options = ExecOptions { + skill: skill.to_string(), + script: script.map(str::to_string), + input, + json, + dry_run, + args: args.to_vec(), + }; + + match run(&options, &context) { + Ok(ExecResult::DryRun(text)) => { + println!("{text}"); + Ok(0) + } + Ok(ExecResult::Completed(result)) => { + for error in &result.validation_errors { + eprintln!("{error}"); + } + if options.json { + println!("{}", result.wrapper_json()); + } + Ok(result.exit_code) + } + Err(error) => { + eprintln!("error: {}", error.message); + Ok(error.code) + } + } +} + +#[derive(Debug, Default)] +struct ParsedCliTail { + script: Option, + input_json: Option, + json: bool, + dry_run: bool, + args: Vec, +} + +fn parse_cli_tail(global_json: bool, rest: &[OsString]) -> Result { + let mut parsed = ParsedCliTail { + json: global_json, + ..ParsedCliTail::default() + }; + let mut index = 0; + while index < rest.len() { + let item = rest[index].to_string_lossy(); + match item.as_ref() { + "--" => { + parsed.args.extend(rest[index + 1..].iter().cloned()); + return Ok(parsed); + } + "--script" => { + index += 1; + let Some(value) = rest.get(index) else { + return Err("--script requires a value".to_string()); + }; + parsed.script = Some(value.to_string_lossy().to_string()); + } + "--dry-run" => { + parsed.dry_run = true; + } + "--json" => { + parsed.json = true; + if rest.get(index + 1).is_some_and(|next| { + next != OsStr::new("--") && !next.to_string_lossy().starts_with("--") + }) { + index += 1; + parsed.input_json = Some(rest[index].to_string_lossy().to_string()); + } + } + value => { + if parsed.json && parsed.input_json.is_none() { + parsed.input_json = Some(value.to_string()); + } else { + return Err(format!( + "unexpected argument '{value}'; pass script args after --" + )); + } + } + } + index += 1; + } + Ok(parsed) +} + +#[derive(Debug)] +struct ExecOptions { + skill: String, + script: Option, + input: Value, + json: bool, + dry_run: bool, + args: Vec, +} + +#[derive(Debug)] +struct ExecContext { + cwd: PathBuf, + config: ontology::ResolvedConfig, +} + +#[derive(Debug)] +enum ExecResult { + DryRun(String), + Completed(CompletedExec), +} + +#[derive(Debug)] +struct CompletedExec { + exit_code: i32, + stdout: String, + stderr: String, + structured: Option, + validation_errors: Vec, +} + +impl CompletedExec { + fn wrapper_json(&self) -> String { + serde_json::json!({ + "ok": self.exit_code == 0, + "exit_code": self.exit_code, + "structured": self.structured, + "stdout": self.stdout, + "stderr": self.stderr, + }) + .to_string() + } +} + +#[derive(Debug)] +struct ExecError { + code: i32, + message: String, +} + +impl ExecError { + fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[derive(Debug)] +struct ResolvedExec { + skill_dir: PathBuf, + argv: Vec, + output_schema: Option, +} + +#[derive(Debug)] +struct Runtime { + names: &'static [&'static str], + extensions: &'static [&'static str], + argv_prefix: &'static [&'static str], +} + +fn run(options: &ExecOptions, context: &ExecContext) -> Result { + let root = dispatch::forge_root_from(&context.cwd).map_err(|error| ExecError::new(2, error))?; + let resolved = resolve_exec(options, &root, &context.config)?; + let env = build_env(&root, &resolved.skill_dir, &context.config, &options.input); + if options.dry_run { + return Ok(ExecResult::DryRun(format_dry_run(&resolved.argv, &env))); + } + + let capture = options.json || resolved.output_schema.is_some(); + let mut command = ProcessCommand::new(&resolved.argv[0]); + command.args(&resolved.argv[1..]); + dispatch::apply_env(&mut command, &env); + + let completed = if capture { + let input = serde_json::to_string(&options.input) + .map_err(|error| ExecError::new(2, format!("cannot serialize input JSON: {error}")))?; + let output = run_capture(command, &input)?; + let mut completed = completed_from_output(&output); + if let Some(schema) = &resolved.output_schema { + completed.validation_errors = validate_output(schema, &completed.stdout)?; + if !completed.validation_errors.is_empty() { + completed.exit_code = 1; + } + } + if !options.json { + print!("{}", completed.stdout); + eprint!("{}", completed.stderr); + } + completed + } else { + let status = command + .status() + .map_err(|error| ExecError::new(2, format!("cannot run child process: {error}")))?; + CompletedExec { + exit_code: status.code().unwrap_or(1), + stdout: String::new(), + stderr: String::new(), + structured: None, + validation_errors: Vec::new(), + } + }; + + Ok(ExecResult::Completed(completed)) +} + +fn resolve_exec( + options: &ExecOptions, + root: &Path, + config: &ontology::ResolvedConfig, +) -> Result { + if options.skill.contains(['/', '\\']) { + return Err(ExecError::new( + 2, + "skill name must not contain path separators", + )); + } + let skill_dir = + resolve_skill_dir(&options.skill, root, &config.extensions).ok_or_else(|| { + ExecError::new( + 2, + format!( + "skill '{}' not found under FORGE_ROOT or extensions", + options.skill + ), + ) + })?; + let spec = if options.script.is_none() { + read_exec_spec(&skill_dir)? + } else { + None + }; + let script_name = options + .script + .as_deref() + .or_else(|| spec.as_ref().map(|exec| exec.script.as_str())) + .ok_or_else(|| { + ExecError::new(3, "declare an `exec:` block in SKILL.md or pass --script") + })?; + let script_path = skill_dir.join(script_name); + if !script_path.is_file() { + return Err(ExecError::new( + 3, + format!("script not found: {}", script_path.display()), + )); + } + ensure_within(&skill_dir, &script_path)?; + let runtime = runtime_for( + &script_path, + spec.as_ref().and_then(|exec| exec.runtime.as_deref()), + ) + .ok_or_else(|| { + ExecError::new( + 2, + format!( + "cannot infer runtime for {}; pass a supported runtime hint", + script_path.display() + ), + ) + })?; + let mut argv = runtime + .argv_prefix + .iter() + .map(OsString::from) + .collect::>(); + argv.push(script_path.into_os_string()); + argv.extend(options.args.iter().cloned()); + let output_schema = spec + .and_then(|exec| exec.output_schema) + .map(|schema| skill_dir.join(schema)); + Ok(ResolvedExec { + skill_dir, + argv, + output_schema, + }) +} + +/// Reject a script that escapes its skill directory. Both paths are resolved to +/// their canonical form first so `..` components and symlinks cannot slip past +/// the containment check (path-boundary validation). +fn ensure_within(skill_dir: &Path, script_path: &Path) -> Result<(), ExecError> { + let base = std::fs::canonicalize(skill_dir) + .map_err(|error| ExecError::new(3, format!("cannot resolve skill directory: {error}")))?; + let target = std::fs::canonicalize(script_path) + .map_err(|error| ExecError::new(3, format!("cannot resolve script path: {error}")))?; + if target.starts_with(&base) { + Ok(()) + } else { + Err(ExecError::new( + 3, + format!("script escapes skill directory: {}", script_path.display()), + )) + } +} + +fn resolve_skill_dir(skill: &str, root: &Path, extensions: &[PathBuf]) -> Option { + let local = root + .join(commands::provider::ContentKind::Skills.as_str()) + .join(skill); + if is_skill_dir(&local) { + return Some(local); + } + extensions + .iter() + .map(|extension| { + extension + .join(commands::provider::ContentKind::Skills.as_str()) + .join(skill) + }) + .find(|candidate| is_skill_dir(candidate)) +} + +fn is_skill_dir(path: &Path) -> bool { + path.is_dir() && path.join("SKILL.md").is_file() +} + +fn read_exec_spec(skill_dir: &Path) -> Result, ExecError> { + let skill_file = skill_dir.join("SKILL.md"); + let content = std::fs::read_to_string(&skill_file).map_err(|error| { + ExecError::new(2, format!("cannot read {}: {error}", skill_file.display())) + })?; + let Some((frontmatter, _)) = parse::split_frontmatter(&content) else { + return Ok(None); + }; + if frontmatter.trim().is_empty() { + return Ok(None); + } + let metadata: SkillFrontmatter = serde_yaml::from_str(frontmatter).map_err(|error| { + ExecError::new( + 2, + format!("{} has invalid frontmatter: {error}", skill_file.display()), + ) + })?; + Ok(metadata.exec) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(default)] +struct SkillFrontmatter { + exec: Option, +} + +#[derive(Debug, Deserialize)] +struct ExecSpec { + script: String, + runtime: Option, + #[allow(dead_code)] + #[serde(rename = "inputSchema")] + input_schema: Option, + #[serde(rename = "outputSchema")] + output_schema: Option, +} + +fn runtime_for(script_path: &Path, hint: Option<&str>) -> Option<&'static Runtime> { + if let Some(hint) = hint { + let hint = hint.to_ascii_lowercase(); + return RUNTIMES + .iter() + .find(|runtime| runtime.names.iter().any(|name| name == &hint.as_str())); + } + let extension = script_path + .extension() + .and_then(OsStr::to_str) + .map(|extension| format!(".{extension}"))?; + RUNTIMES + .iter() + .find(|runtime| runtime.extensions.contains(&extension.as_str())) +} + +fn build_env( + root: &Path, + skill_dir: &Path, + config: &ontology::ResolvedConfig, + input: &Value, +) -> Vec<(OsString, OsString)> { + let mut env = dispatch::forge_env(root, config); + env.push(( + OsString::from("FORGE_SKILL_DIR"), + skill_dir.as_os_str().to_os_string(), + )); + if let Value::Object(object) = input { + env.extend(object.iter().map(|(key, value)| { + ( + OsString::from(format!("INPUT_{}", env_key(key))), + OsString::from(input_value(value)), + ) + })); + } + env +} + +fn env_key(key: &str) -> String { + key.chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +fn input_value(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +fn parse_input(input_json: Option<&str>) -> Result { + let Some(input_json) = input_json else { + return Ok(Value::Object(Map::new())); + }; + let value: Value = serde_json::from_str(input_json) + .map_err(|error| format!("invalid --json object: {error}"))?; + if value.is_object() { + Ok(value) + } else { + Err("--json must be a JSON object".to_string()) + } +} + +fn run_capture( + mut command: ProcessCommand, + input: &str, +) -> Result { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| ExecError::new(2, format!("cannot run child process: {error}")))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| ExecError::new(2, "cannot open child stdin"))?; + stdin + .write_all(input.as_bytes()) + .map_err(|error| ExecError::new(2, format!("cannot write child stdin: {error}")))?; + drop(stdin); + child + .wait_with_output() + .map_err(|error| ExecError::new(2, format!("cannot read child output: {error}"))) +} + +fn completed_from_output(output: &std::process::Output) -> CompletedExec { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let structured = serde_json::from_str(&stdout).ok(); + CompletedExec { + exit_code: output.status.code().unwrap_or(1), + stdout, + stderr, + structured, + validation_errors: Vec::new(), + } +} + +fn validate_output(schema_path: &Path, stdout: &str) -> Result, ExecError> { + let schema_content = std::fs::read_to_string(schema_path).map_err(|error| { + ExecError::new(1, format!("cannot read {}: {error}", schema_path.display())) + })?; + let schema: Value = serde_json::from_str(&schema_content).map_err(|error| { + ExecError::new( + 1, + format!("{} is invalid JSON: {error}", schema_path.display()), + ) + })?; + let output: Value = serde_json::from_str(stdout) + .map_err(|error| ExecError::new(1, format!("child stdout is not JSON: {error}")))?; + let validator = jsonschema::options() + .build(&schema) + .map_err(|error| ExecError::new(1, format!("cannot compile output schema: {error}")))?; + Ok(validator + .iter_errors(&output) + .map(|error| format!("outputSchema: {}: {}", error.instance_path(), error)) + .collect()) +} + +fn format_dry_run(argv: &[OsString], env: &[(OsString, OsString)]) -> String { + let argv = argv + .iter() + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" "); + let mut lines = vec![format!("argv: {argv}"), "env:".to_string()]; + lines.extend(env.iter().filter_map(|(key, value)| { + let key = key.to_string_lossy(); + if key == "CI" || key.starts_with("FORGE_") || key.starts_with("INPUT_") { + Some(format!(" {key}={}", value.to_string_lossy())) + } else { + None + } + })); + lines.join("\n") +} + +#[cfg(test)] +mod tests; diff --git a/src/cli/exec/tests.rs b/src/cli/exec/tests.rs new file mode 100644 index 0000000..9a99b7a --- /dev/null +++ b/src/cli/exec/tests.rs @@ -0,0 +1,176 @@ +use super::*; + +fn context(cwd: PathBuf) -> ExecContext { + ExecContext { + cwd, + config: ontology::ResolvedConfig::default(), + } +} + +fn make_skill(root: &Path, frontmatter: &str, script: &str) -> PathBuf { + let skill = root.join("skills/demo"); + std::fs::create_dir_all(&skill).expect("skill dir"); + std::fs::write( + skill.join("SKILL.md"), + format!("---\n{frontmatter}---\n# Demo\n"), + ) + .expect("skill file"); + std::fs::write(skill.join("run.sh"), script).expect("script"); + skill +} + +#[test] +fn extension_runtime_dispatch_picks_shell() { + let runtime = runtime_for(Path::new("run.sh"), None).expect("runtime"); + assert_eq!(runtime.argv_prefix, &["bash"]); +} + +#[test] +fn shell_fixture_round_trips_json_and_input_env() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + make_skill( + root, + "exec:\n script: run.sh\n", + "read payload\nif [ \"$payload\" != '{\"name\":\"Ada\"}' ]; then exit 8; fi\nif [ -z \"$FORGE_ROOT\" ]; then exit 9; fi\nprintf '{\"input\":\"%s\",\"arg\":\"%s\"}\\n' \"$INPUT_NAME\" \"$1\"\n", + ); + let options = ExecOptions { + skill: "demo".to_string(), + script: None, + input: serde_json::json!({ "name": "Ada" }), + json: true, + dry_run: false, + args: vec![OsString::from("x")], + }; + + let ExecResult::Completed(result) = run(&options, &context(root.to_path_buf())).expect("run") + else { + panic!("expected completed exec"); + }; + assert_eq!(result.exit_code, 0); + assert_eq!( + result.structured, + Some(serde_json::json!({ "input": "Ada", "arg": "x" })) + ); +} + +#[test] +fn output_schema_bad_payload_fails_validation() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + let skill = make_skill( + root, + "exec:\n script: run.sh\n outputSchema: schema.json\n", + "printf '{\"count\":\"no\"}\\n'\n", + ); + std::fs::write( + skill.join("schema.json"), + r#"{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}"#, + ) + .expect("schema"); + let options = ExecOptions { + skill: "demo".to_string(), + script: None, + input: serde_json::json!({}), + json: true, + dry_run: false, + args: Vec::new(), + }; + + let ExecResult::Completed(result) = run(&options, &context(root.to_path_buf())).expect("run") + else { + panic!("expected completed exec"); + }; + assert_eq!(result.exit_code, 1); + assert!(!result.validation_errors.is_empty()); +} + +#[test] +fn dry_run_emits_command_without_running() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + make_skill( + root, + "exec:\n script: run.sh\n", + "touch SHOULD_NOT_EXIST\n", + ); + let options = ExecOptions { + skill: "demo".to_string(), + script: None, + input: serde_json::json!({ "name": "Ada" }), + json: true, + dry_run: true, + args: Vec::new(), + }; + + let ExecResult::DryRun(text) = run(&options, &context(root.to_path_buf())).expect("dry run") + else { + panic!("expected dry run"); + }; + assert!(text.contains("argv: bash")); + assert!(text.contains("INPUT_NAME=Ada")); + assert!(!root.join("SHOULD_NOT_EXIST").exists()); +} + +#[test] +fn missing_script_guidance_exits_three() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + make_skill(root, "", "printf '{}\\n'\n"); + let options = ExecOptions { + skill: "demo".to_string(), + script: None, + input: serde_json::json!({}), + json: true, + dry_run: false, + args: Vec::new(), + }; + + let error = run(&options, &context(root.to_path_buf())).expect_err("missing exec"); + assert_eq!(error.code, 3); + assert!(error.message.contains("declare an `exec:` block")); +} + +#[test] +fn script_escaping_skill_dir_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + make_skill(root, "exec:\n script: run.sh\n", "printf '{}\\n'\n"); + std::fs::write(root.join("evil.sh"), "printf 'pwned\\n'\n").expect("evil"); + let options = ExecOptions { + skill: "demo".to_string(), + script: Some("../../evil.sh".to_string()), + input: serde_json::json!({}), + json: true, + dry_run: false, + args: Vec::new(), + }; + + let error = run(&options, &context(root.to_path_buf())).expect_err("traversal rejected"); + assert_eq!(error.code, 3); + assert!(error.message.contains("escapes skill directory")); +} + +#[test] +fn skill_name_with_separator_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("module.yaml"), "name: test\n").expect("module"); + let options = ExecOptions { + skill: "../../etc".to_string(), + script: None, + input: serde_json::json!({}), + json: true, + dry_run: false, + args: Vec::new(), + }; + + let error = run(&options, &context(root.to_path_buf())).expect_err("separator rejected"); + assert_eq!(error.code, 2); + assert!(error.message.contains("path separators")); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2c32aa5..e43c153 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,10 +4,13 @@ mod copy; #[cfg(feature = "dashboard")] mod dashboard; mod deploy; +mod dispatch; mod dotforge; mod drift; +mod exec; mod init; mod install; +mod ontology; mod output; mod provenance; mod release; @@ -22,6 +25,7 @@ use clap::CommandFactory; use clap::{Parser, Subcommand}; use commands::error::Error; use commands::result::ActionResult; +use std::ffi::OsString; #[derive(Parser)] #[command(name = "forge", about = "Forge module toolkit", version)] @@ -225,6 +229,22 @@ enum Command { target: Option, }, + /// Show the resolved forge ontology and configuration + Config, + + /// 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" + )] + Exec { + /// Skill name to execute. + skill: String, + + /// Exec options (`--script`, `--json`, `--dry-run`) and script args after `--`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + rest: Vec, + }, + /// Launch a read-only web dashboard showing artifact state, provenance, /// and deployment status across all providers #[cfg(feature = "dashboard")] @@ -254,6 +274,10 @@ enum Command { #[command(subcommand)] action: WatchAction, }, + + /// Fallback: run an external `forge-` executable with remaining args. + #[command(external_subcommand)] + External(Vec), } #[derive(Subcommand)] @@ -283,6 +307,7 @@ enum WatchAction { /// Parse CLI arguments, dispatch to subcommand, and return an exit code. /// /// Exit codes: 0 = success, 1 = errors occurred, 2 = fatal error. +#[allow(clippy::too_many_lines)] pub fn run() -> i32 { let args = Cli::parse(); @@ -376,10 +401,15 @@ pub fn run() -> i32 { deploy::execute(&source, target.as_deref(), &[], false, true, false, false), "cleaned", ), + Command::Config => return exit_code(ontology::show(args.json)), + Command::Exec { skill, rest } => { + return exit_code(exec::execute_cli(&skill, args.json, &rest)); + } #[cfg(feature = "dashboard")] Command::Dashboard { root, port } => return exit_code(dashboard::execute(&root, port)), Command::Release { source, embed } => (release::execute(&source, embed), "released"), Command::Watch { action } => return run_watch(action, args.json), + Command::External(external_args) => return exit_code(dispatch::external(&external_args)), }; report(result, args.json, verb) diff --git a/src/cli/ontology.rs b/src/cli/ontology.rs new file mode 100644 index 0000000..8f6b678 --- /dev/null +++ b/src/cli/ontology.rs @@ -0,0 +1,27 @@ +use commands::ontology::{self, Source}; + +pub fn show(json: bool) -> Result { + let config = ontology::load().map_err(|error| error.to_string())?; + if json { + let output = serde_json::to_string_pretty(&config) + .map_err(|error| format!("cannot serialize config: {error}"))?; + println!("{output}"); + return Ok(0); + } + + println!("{:<12} {:<8} value", "key", "source"); + for field in config.ontology.fields() { + let source = field.source.map_or("-", format_source); + let value = field.value.unwrap_or_default(); + println!("{:<12} {:<8} {value}", field.key, source); + } + Ok(0) +} + +fn format_source(source: Source) -> &'static str { + match source { + Source::Env => "env", + Source::Config => "config", + Source::Default => "default", + } +} diff --git a/src/commands.rs b/src/commands.rs index 9d52b6f..6bf0c15 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -5,6 +5,7 @@ pub const VALIDATE_SH_SHA: &str = env!("VALIDATE_SH_SHA"); pub mod error; pub mod manifest; pub mod module; +pub mod ontology; pub mod parse; pub mod provider; pub mod result; diff --git a/src/ontology.rs b/src/ontology.rs new file mode 100644 index 0000000..d8e5eab --- /dev/null +++ b/src/ontology.rs @@ -0,0 +1,396 @@ +use crate::error::{Error, ErrorKind}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Once; + +static PROJECT_WARNING: Once = Once::new(); + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub ontology: Ontology, + pub extensions: Vec, + pub launch: Launch, + pub watch: Watch, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct Ontology { + pub workshop: Option, + pub owner: Option, + pub archive: Option, + pub vault: Option, + pub work: Option, + pub data: Option, + pub mount: Option, + pub developer: Option, + pub documents: Option, + pub githooks: Option, + pub domain: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct Launch {} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct Watch {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Source { + Env, + Config, + Default, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResolvedField { + pub key: &'static str, + pub env: &'static str, + pub value: Option, + pub source: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct ResolvedOntology { + pub workshop: Option, + pub owner: Option, + pub archive: Option, + pub vault: Option, + pub work: Option, + pub data: Option, + pub mount: Option, + pub developer: Option, + pub documents: Option, + pub githooks: Option, + pub domain: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResolvedValue { + pub value: String, + pub source: Source, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct ResolvedConfig { + pub ontology: ResolvedOntology, + pub extensions: Vec, +} + +#[derive(Debug, Clone, Copy)] +enum Key { + Workshop, + Owner, + Archive, + Vault, + Work, + Data, + Mount, + Developer, + Documents, + Githooks, + Domain, +} + +impl Key { + const ALL: [Self; 11] = [ + Self::Workshop, + Self::Owner, + Self::Archive, + Self::Vault, + Self::Work, + Self::Data, + Self::Mount, + Self::Developer, + Self::Documents, + Self::Githooks, + Self::Domain, + ]; + + fn name(self) -> &'static str { + match self { + Self::Workshop => "workshop", + Self::Owner => "owner", + Self::Archive => "archive", + Self::Vault => "vault", + Self::Work => "work", + Self::Data => "data", + Self::Mount => "mount", + Self::Developer => "developer", + Self::Documents => "documents", + Self::Githooks => "githooks", + Self::Domain => "domain", + } + } + + fn env(self) -> &'static str { + match self { + Self::Workshop => "FORGE_WORKSHOP", + Self::Owner => "FORGE_OWNER", + Self::Archive => "FORGE_ARCHIVE", + Self::Vault => "FORGE_VAULT", + Self::Work => "FORGE_WORK", + Self::Data => "FORGE_DATA", + Self::Mount => "FORGE_MOUNT", + Self::Developer => "FORGE_DEVELOPER", + Self::Documents => "FORGE_DOCUMENTS", + Self::Githooks => "FORGE_GITHOOKS", + Self::Domain => "FORGE_DOMAIN", + } + } + + fn default(self) -> Option<&'static str> { + match self { + Self::Workshop => Some("~/Agents"), + Self::Archive => Some("~/Agents/archive"), + Self::Vault => Some("~/Atlas/Domains"), + Self::Data => Some("~/Data"), + Self::Mount => Some("~/Atlas"), + Self::Domain => Some("Technology"), + Self::Owner | Self::Work | Self::Developer | Self::Documents | Self::Githooks => None, + } + } + + fn configured(self, ontology: &Ontology) -> Option<&String> { + match self { + Self::Workshop => ontology.workshop.as_ref(), + Self::Owner => ontology.owner.as_ref(), + Self::Archive => ontology.archive.as_ref(), + Self::Vault => ontology.vault.as_ref(), + Self::Work => ontology.work.as_ref(), + Self::Data => ontology.data.as_ref(), + Self::Mount => ontology.mount.as_ref(), + Self::Developer => ontology.developer.as_ref(), + Self::Documents => ontology.documents.as_ref(), + Self::Githooks => ontology.githooks.as_ref(), + Self::Domain => ontology.domain.as_ref(), + } + } + + fn is_path(self) -> bool { + !matches!(self, Self::Owner | Self::Domain) + } +} + +impl ResolvedOntology { + pub fn fields(&self) -> Vec { + Key::ALL + .iter() + .map(|key| { + let resolved = self.value(*key); + ResolvedField { + key: key.name(), + env: key.env(), + value: resolved.map(|value| value.value.clone()), + source: resolved.map(|value| value.source), + } + }) + .collect() + } + + fn value(&self, key: Key) -> Option<&ResolvedValue> { + match key { + Key::Workshop => self.workshop.as_ref(), + Key::Owner => self.owner.as_ref(), + Key::Archive => self.archive.as_ref(), + Key::Vault => self.vault.as_ref(), + Key::Work => self.work.as_ref(), + Key::Data => self.data.as_ref(), + Key::Mount => self.mount.as_ref(), + Key::Developer => self.developer.as_ref(), + Key::Documents => self.documents.as_ref(), + Key::Githooks => self.githooks.as_ref(), + Key::Domain => self.domain.as_ref(), + } + } +} + +pub fn load() -> Result { + let config_dir = config_dir()?; + load_from_dir_with_env(&config_dir, &|name| std::env::var(name).ok()) +} + +pub fn config_dir() -> Result { + dirs::home_dir() + .map(|home| home.join(".config/forge")) + .ok_or_else(|| Error::new(ErrorKind::Config, "cannot resolve home directory")) +} + +pub fn env_vars(config: &ResolvedConfig) -> Vec<(String, String)> { + config + .ontology + .fields() + .into_iter() + .filter_map(|field| { + field + .value + .map(|value| (field.env.to_string(), value.clone())) + }) + .collect() +} + +pub fn expand_tilde(value: &str) -> PathBuf { + if let Some(rest) = value.strip_prefix("~/") { + return dirs::home_dir().map_or_else(|| PathBuf::from(value), |home| home.join(rest)); + } + PathBuf::from(value) +} + +fn load_from_dir_with_env( + config_dir: &Path, + env: &dyn Fn(&str) -> Option, +) -> Result { + let config = load_raw_config(config_dir)?; + Ok(resolve_config(&config, env)) +} + +fn load_raw_config(config_dir: &Path) -> Result { + let config_path = config_dir.join("config.yaml"); + match fs::read_to_string(&config_path) { + Ok(content) => parse_config(&content, &config_path), + Err(error) if error.kind() == io::ErrorKind::NotFound => load_project_fallback(config_dir), + Err(error) => Err(Error::new( + ErrorKind::Io, + format!("cannot read {}: {error}", config_path.display()), + )), + } +} + +fn parse_config(content: &str, path: &Path) -> Result { + serde_yaml::from_str(content).map_err(|error| { + Error::new( + ErrorKind::Config, + format!("{} is malformed: {error}", path.display()), + ) + }) +} + +fn load_project_fallback(config_dir: &Path) -> Result { + let project_path = config_dir.join("project.yaml"); + let content = match fs::read_to_string(&project_path) { + Ok(content) => content, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Config::default()), + Err(error) => { + return Err(Error::new( + ErrorKind::Io, + format!("cannot read {}: {error}", project_path.display()), + )); + } + }; + PROJECT_WARNING.call_once(|| { + eprintln!( + "warning: {} is deprecated; migrate to config.yaml", + project_path.display() + ); + }); + let project: ProjectConfig = serde_yaml::from_str(&content).map_err(|error| { + Error::new( + ErrorKind::Config, + format!("{} is malformed: {error}", project_path.display()), + ) + })?; + Ok(Config { + ontology: project.into_ontology(), + ..Config::default() + }) +} + +fn resolve_config(config: &Config, env: &dyn Fn(&str) -> Option) -> ResolvedConfig { + let ontology = ResolvedOntology { + workshop: resolve_key(Key::Workshop, &config.ontology, env), + owner: resolve_key(Key::Owner, &config.ontology, env), + archive: resolve_key(Key::Archive, &config.ontology, env), + vault: resolve_key(Key::Vault, &config.ontology, env), + work: resolve_key(Key::Work, &config.ontology, env), + data: resolve_key(Key::Data, &config.ontology, env), + mount: resolve_key(Key::Mount, &config.ontology, env), + developer: resolve_key(Key::Developer, &config.ontology, env), + documents: resolve_key(Key::Documents, &config.ontology, env), + githooks: resolve_key(Key::Githooks, &config.ontology, env), + domain: resolve_key(Key::Domain, &config.ontology, env), + }; + let extensions = config + .extensions + .iter() + .map(|extension| expand_tilde(extension)) + .collect(); + ResolvedConfig { + ontology, + extensions, + } +} + +fn resolve_key( + key: Key, + ontology: &Ontology, + env: &dyn Fn(&str) -> Option, +) -> Option { + if let Some(value) = env(key.env()) { + return Some(resolved_value(key, value, Source::Env)); + } + if let Some(value) = key.configured(ontology) { + return Some(resolved_value(key, value.clone(), Source::Config)); + } + key.default() + .map(|value| resolved_value(key, value.to_string(), Source::Default)) +} + +fn resolved_value(key: Key, value: String, source: Source) -> ResolvedValue { + let value = if key.is_path() { + expand_tilde(&value).to_string_lossy().to_string() + } else { + value + }; + ResolvedValue { value, source } +} + +#[derive(Debug, Deserialize, Default)] +#[serde(default)] +struct ProjectConfig { + workshop: Option, + owner: Option, + archive: Option, + vault: Option, + work: Option, + data: Option, + mount: Option, + githooks: Option, + developer: Option, + documents: Option, + defaults: ProjectDefaults, + exclude: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(default)] +struct ProjectDefaults { + domain: Option, +} + +impl ProjectConfig { + fn into_ontology(self) -> Ontology { + let _ = self.exclude; + Ontology { + workshop: self.workshop, + owner: self.owner, + archive: self.archive, + vault: self.vault, + work: self.work, + data: self.data, + mount: self.mount, + developer: self.developer, + documents: self.documents, + githooks: self.githooks, + domain: self.defaults.domain, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ontology/tests.rs b/src/ontology/tests.rs new file mode 100644 index 0000000..3445059 --- /dev/null +++ b/src/ontology/tests.rs @@ -0,0 +1,97 @@ +use super::*; + +fn no_env(_: &str) -> Option { + None +} + +fn env_from<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + |name| { + pairs + .iter() + .find_map(|(key, value)| (*key == name).then(|| (*value).to_string())) + } +} + +#[test] +fn env_beats_config_beats_default() { + let config = Config { + ontology: Ontology { + workshop: Some("/from-config".to_string()), + ..Ontology::default() + }, + ..Config::default() + }; + let resolved = resolve_config(&config, &env_from(&[("FORGE_WORKSHOP", "/from-env")])); + let workshop = resolved.ontology.workshop.expect("workshop resolved"); + assert_eq!(workshop.value, "/from-env"); + assert_eq!(workshop.source, Source::Env); +} + +#[test] +fn config_beats_default() { + let config = Config { + ontology: Ontology { + domain: Some("Research".to_string()), + ..Ontology::default() + }, + ..Config::default() + }; + let resolved = resolve_config(&config, &no_env); + let domain = resolved.ontology.domain.expect("domain resolved"); + assert_eq!(domain.value, "Research"); + assert_eq!(domain.source, Source::Config); +} + +#[test] +fn missing_config_uses_default() { + let resolved = resolve_config(&Config::default(), &no_env); + let domain = resolved.ontology.domain.expect("domain default"); + assert_eq!(domain.value, "Technology"); + assert_eq!(domain.source, Source::Default); +} + +#[test] +fn env_override_is_reflected_in_env_vars() { + let resolved = resolve_config( + &Config::default(), + &env_from(&[("FORGE_OWNER", "N4M3Z"), ("FORGE_DOMAIN", "Systems")]), + ); + let vars = env_vars(&resolved); + assert!(vars.contains(&("FORGE_OWNER".to_string(), "N4M3Z".to_string()))); + assert!(vars.contains(&("FORGE_DOMAIN".to_string(), "Systems".to_string()))); +} + +#[test] +fn tilde_expansion_resolves_under_home() { + let home = dirs::home_dir().expect("home for test"); + assert_eq!(expand_tilde("~/Agents"), home.join("Agents")); +} + +#[test] +fn project_yaml_fallback_populates_ontology() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project.yaml"); + std::fs::write( + &project, + "workshop: /workshop\ndefaults:\n domain: Security\n", + ) + .expect("write project"); + + let resolved = load_from_dir_with_env(dir.path(), &no_env).expect("load project fallback"); + let workshop = resolved.ontology.workshop.expect("workshop"); + let domain = resolved.ontology.domain.expect("domain"); + assert_eq!(workshop.value, "/workshop"); + assert_eq!(workshop.source, Source::Config); + assert_eq!(domain.value, "Security"); +} + +#[test] +fn unknown_top_level_config_key_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("config.yaml"); + std::fs::write(&config, "surprise: true\n").expect("write config"); + + let error = load_from_dir_with_env(dir.path(), &no_env).expect_err("unknown key"); + assert_eq!(error.kind(), ErrorKind::Config); + assert!(error.message().contains("unknown field"), "got: {error}"); +} diff --git a/tests/cli.rs b/tests/cli.rs index ef2489b..85116f0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,7 @@ use assert_cmd::Command; use predicates::prelude::*; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; fn forge() -> Command { Command::cargo_bin("forge").unwrap() @@ -85,3 +87,79 @@ fn no_args_exits_with_error() { .failure() .stderr(predicate::str::is_empty().not()); } + +#[test] +fn exec_shell_fixture_round_trips_json() { + let root = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("module.yaml"), "name: test\n").unwrap(); + let skill = root.path().join("skills/demo"); + std::fs::create_dir_all(&skill).unwrap(); + std::fs::write( + skill.join("SKILL.md"), + "---\nexec:\n script: run.sh\n---\n# Demo\n", + ) + .unwrap(); + std::fs::write( + skill.join("run.sh"), + "read payload\nif [ \"$payload\" != '{\"name\":\"Ada\"}' ]; then exit 8; fi\nprintf '{\"input\":\"%s\",\"arg\":\"%s\"}\\n' \"$INPUT_NAME\" \"$1\"\n", + ) + .unwrap(); + + forge() + .current_dir(root.path()) + .env("HOME", home.path()) + .args(["exec", "demo", "--json", "{\"name\":\"Ada\"}", "--", "x"]) + .assert() + .success() + .stdout(predicate::str::contains("\"ok\":true")) + .stdout(predicate::str::contains("\"input\":\"Ada\"")); +} + +#[cfg(unix)] +#[test] +fn external_command_from_extension_receives_args_and_exit_code() { + let cwd = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let extension = tempfile::tempdir().unwrap(); + let config_dir = home.path().join(".config/forge"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.yaml"), + format!("extensions:\n - {}\n", extension.path().display()), + ) + .unwrap(); + let script = extension.path().join("forge-hello"); + std::fs::write( + &script, + "#!/usr/bin/env bash\nif [ -z \"$FORGE_ROOT\" ]; then exit 7; fi\nprintf 'hello %s %s\\n' \"$1\" \"$2\"\nexit 5\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).unwrap(); + + forge() + .current_dir(cwd.path()) + .env("HOME", home.path()) + .args(["hello", "--name", "x"]) + .assert() + .code(5) + .stdout(predicate::str::contains("hello --name x")); +} + +#[test] +fn unknown_external_command_exits_two_cleanly() { + let cwd = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + + forge() + .current_dir(cwd.path()) + .env("HOME", home.path()) + .args(["does-not-exist"]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "error: unknown command 'forge does-not-exist'", + )); +}