Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <skill>` runs skill-bundled scripts through a small synchronous runtime table (`uv run`, `bash`, `deno run`, `node`), injects `FORGE_*`/`INPUT_*` environment, supports JSON stdin/stdout wrapping, dry-runs, and output schema validation.
- Unknown `forge <verb>` commands now dispatch to external `forge-<verb>` scripts from the module `commands/` directory, configured extension directories, or `PATH`, keeping new capabilities out of the Rust kernel.
- `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/<model>/` > `provider/` > base precedence: a file at `rules/<provider>/<model-id>/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 <ID>` and `forge install --model <ID>` 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/<provider>/<model-id>/` 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 <BASE>` 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/<provider>` is compared to `<BASE>/<provider-target>`, 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)
Expand Down
65 changes: 65 additions & 0 deletions docs/decisions/CLI-0013 Unified Config and Ontology.md
Original file line number Diff line number Diff line change
@@ -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)
68 changes: 68 additions & 0 deletions docs/decisions/CLI-0014 Exec Runtime Contract.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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 <skill>` locates a skill under `FORGE_ROOT/skills/<skill>` or configured extension skill roots. The script comes from `--script <name>` 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)
66 changes: 66 additions & 0 deletions docs/decisions/CLI-0015 Git-Style External Command Dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: "Git-Style External Command Dispatch"
description: "Unknown forge verbs dispatch to forge-<verb> 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-<verb>` 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-<verb>`.** Known commands still parse normally, while unknown commands become script dispatch.
3. **Make an explicit `forge run <verb>` 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 <verb>` invocations resolve to `forge-<verb>` 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 <verb>' (no forge-<verb> 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)
115 changes: 115 additions & 0 deletions src/cli/dispatch.rs
Original file line number Diff line number Diff line change
@@ -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<i32, String> {
external_with_context(
args,
&env::current_dir().map_err(|error| error.to_string())?,
)
}

fn external_with_context(args: &[OsString], cwd: &Path) -> Result<i32, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf> {
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<PathBuf> {
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;
30 changes: 30 additions & 0 deletions src/cli/dispatch/tests.rs
Original file line number Diff line number Diff line change
@@ -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)
);
}
Loading