diff --git a/CHANGELOG.md b/CHANGELOG.md index 445480d..b0877a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - `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. +- `forge tui` now opens an instant Miller-column dashboard at full web-dashboard parity: sections, artifact lists, tabbed artifact detail, provenance chain/deploy groups, ADRs, variants, integrity attention list, search filters/sorts, git history, Settings/Hooks/Config/Schemas file-browser sections, and a `?` help overlay driven by the same keybinding table as the footer hints. Scans run on a background thread so the shell renders immediately while module discovery continues. - 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) - `forge validate` sanity-checks Claude Code plugin scaffolding when `.claude-plugin/plugin.json` is present: each manifest (`plugin.json`, `.claude-plugin/marketplace.json`, `hooks/hooks.json`) must be valid JSON, and every hook script referenced via `${CLAUDE_PLUGIN_ROOT}` must exist and be executable (the most common cause of hooks silently not firing). It deliberately makes no assertions about the plugin or marketplace field schema, so it does not break when Claude Code's plugin format changes. Non-plugin modules are unaffected: the check runs only when `plugin.json` exists. (#59) @@ -19,9 +20,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- Pure dashboard view builders for nested overview, matrix, variant coverage, deployment grouping, dependency links, and search sorting/filtering moved into `commands::services::builders` so the axum dashboard and TUI share the same rendering inputs. - Dashboard scan logic moved from the dashboard binary into `commands::services`, shared by the axum dashboard and reusable by a future TUI with no behavioral change. - `forge install` and `forge deploy` default `--target` to `--source` when a `.forge` consumer manifest is present and `--target` is omitted. The consumer dir IS the place the user wants provider trees written; the previous behavior forced redundant `--target .` on every consumer-mode invocation. Module-root flows (no `.forge`) are unchanged: an omitted `--target` still resolves provider directories relative to the current working directory. (#52) +### Deferred + +- Line-level annotation export from the TUI Code tab remains follow-up work. + ### Fixed - `forge provenance` verifies source-side `.provenance/` sidecars, not just deployed targets. Pointed at a source repository or an artifact subdirectory (detected by a `module.yaml` at or above the path), it walks `.provenance/*.yaml`, resolves each `subject.name` to a repo-relative file, recomputes its SHA-256 against the recorded digest, and verifies the digests of in-repo `resolvedDependencies` (the remote `upstream` dependency is left to its recorded pin). Previously the strict typed sidecar model rejected `adopt/v1` sidecars (which carry `upstream_url` rather than `source`) and the directory walk only iterated provider kind roots, so `forge provenance skills/Foo` reported "No provenance found" and source-side drift went undetected. The model now tolerates both `assemble/v1` and `adopt/v1` schemas without changing generated sidecar output, and `--json` emits a machine-readable per-sidecar report. (#44) diff --git a/src/cli/dashboard/routes/artifact.rs b/src/cli/dashboard/routes/artifact.rs index fea1499..5a76c03 100644 --- a/src/cli/dashboard/routes/artifact.rs +++ b/src/cli/dashboard/routes/artifact.rs @@ -8,9 +8,8 @@ use super::{AppState, DashboardState}; use crate::cli::dashboard::scan; use crate::cli::dashboard::server; use crate::cli::dashboard::templates; -use commands::view::{ - ArtifactView, DashboardView, DeployGroup, DeployHarness, ModuleView, ProvenanceArtifact, -}; +use commands::services::builders::{group_deployments, resolve_dep_links}; +use commands::view::{ArtifactView, DashboardView, ModuleView, ProvenanceArtifact}; /// Artifact detail: `/artifact/{module}/{kind}/{name}`. The module qualifier /// disambiguates the same artifact present in more than one module (e.g. an @@ -109,39 +108,6 @@ fn render_artifact( Html(template.to_string()).into_response() } -/// Resolves each adoption dependency to the module containing a skill of that -/// name (first match), so the dep chip links to the correct copy. An unresolved -/// dependency gets an empty module and renders as plain text (no link, no 404). -pub(super) fn resolve_dep_links( - view: &DashboardView, - adoption: Option<&commands::view::Adoption>, -) -> Vec { - let Some(adoption) = adoption else { - return Vec::new(); - }; - adoption - .dependencies - .iter() - .map(|dependency| { - let module = view - .modules - .iter() - .find(|candidate| { - candidate - .artifacts - .iter() - .any(|art| art.kind == "skills" && art.name == dependency.name) - }) - .map_or_else(String::new, |candidate| candidate.name.clone()); - templates::DepLink { - name: dependency.name.clone(), - uri: dependency.uri.clone(), - module, - } - }) - .collect() -} - /// Falls back to the deployed `assemble/v1` sidecar (at the target's /// `.provenance/` directory) when an artifact has no source-side adoption /// sidecar, so the Provenance "Sidecar" view is available for authored @@ -293,46 +259,6 @@ pub(super) async fn companion_detail( Html(template.to_string()).into_response() } -/// Groups deployment provenance entries by target location, so the graph -/// shows one node per directory (expandable) rather than a flat harness list. -fn group_deployments(entries: &[&ProvenanceArtifact]) -> Vec { - let mut order: Vec = Vec::new(); - let mut groups: std::collections::HashMap = - std::collections::HashMap::new(); - for entry in entries { - let group = groups.entry(entry.target.clone()).or_insert_with(|| { - order.push(entry.target.clone()); - DeployGroup { - target: entry.target.clone(), - verified: 0, - total: 0, - harnesses: Vec::new(), - } - }); - group.total += 1; - if entry.verified { - group.verified += 1; - } - let deployed_dir = entry - .deployed_path - .rsplit_once('/') - .map_or_else(String::new, |(dir, _)| dir.to_string()); - group.harnesses.push(DeployHarness { - harness: entry.harness.clone(), - deployed_path: entry.deployed_path.clone(), - deployed_dir, - verified: entry.verified, - }); - } - for group in groups.values_mut() { - group.harnesses.sort_by(|a, b| a.harness.cmp(&b.harness)); - } - order - .into_iter() - .filter_map(|target| groups.remove(&target)) - .collect() -} - pub(super) async fn refresh(State(app): State) -> impl IntoResponse { match server::build_state(&app.root) { Ok(new_state) => { diff --git a/src/cli/dashboard/routes/browse.rs b/src/cli/dashboard/routes/browse.rs index 0bc378d..daa8829 100644 --- a/src/cli/dashboard/routes/browse.rs +++ b/src/cli/dashboard/routes/browse.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use super::AppState; use super::PAGE_SIZE; use crate::cli::dashboard::templates; +use commands::services::builders::{SearchFilters, search_results}; use commands::view::ArtifactView; #[derive(Deserialize)] @@ -58,11 +59,12 @@ pub(super) async fn overview( "comfortable" }; let nested = if layout == "nested" { - templates::build_nested(&state.view, primary) + commands::services::builders::build_nested(&state.view, primary) } else { Vec::new() }; - let matrix = (layout == "matrix").then(|| templates::build_matrix(&state.view)); + let matrix = + (layout == "matrix").then(|| commands::services::builders::build_matrix(&state.view)); let template = templates::OverviewTemplate { tab: "overview", version: &state.version, @@ -131,33 +133,14 @@ pub(super) async fn search( headers: axum::http::HeaderMap, ) -> impl IntoResponse { let state = app.shared.read().await; - let query_lower = params.query.to_lowercase(); - let mut matched: Vec<(&ArtifactView, &str)> = state - .view - .modules - .iter() - .filter(|module| params.module.is_empty() || module.name == params.module) - .flat_map(|module| { - module - .artifacts - .iter() - .map(move |artifact| (artifact, module.name.as_str())) - }) - .filter(|(artifact, _)| { - if !params.kind.is_empty() && artifact.kind != params.kind { - return false; - } - if !params.status.is_empty() && !matches_status(artifact, ¶ms.status) { - return false; - } - if query_lower.is_empty() { - return true; - } - artifact.matches_query(&query_lower) - }) - .collect(); - - sort_results(&mut matched, ¶ms.sort); + let filters = SearchFilters { + query: params.query.clone(), + kind: params.kind.clone(), + module: params.module.clone(), + status: params.status.clone(), + sort: params.sort.clone(), + }; + let matched = search_results(&state.view, &filters); let total = matched.len(); let total_pages = total.div_ceil(PAGE_SIZE); @@ -203,55 +186,3 @@ pub(super) async fn search( }; Html(template.to_string()) } - -/// Sorts matched artifacts in place. `recent` (default) orders by latest commit -/// date descending; `name` / `module` lexically; `size` by total lines -/// descending; `age` by oldest commit first; `staleness` by worst signal first -/// (broken references, then modified, then stale). -fn sort_results(matched: &mut [(&ArtifactView, &str)], sort: &str) { - match sort { - "name" => matched.sort_by(|a, b| a.0.name.cmp(&b.0.name)), - "module" => matched.sort_by(|a, b| a.1.cmp(b.1).then_with(|| a.0.name.cmp(&b.0.name))), - "size" => matched.sort_by(|a, b| { - b.0.total_lines() - .cmp(&a.0.total_lines()) - .then_with(|| a.0.name.cmp(&b.0.name)) - }), - "age" => matched.sort_by(|a, b| { - // Oldest first; artifacts with no git history sort last. - match (a.0.age_days, b.0.age_days) { - (Some(left), Some(right)) => right.cmp(&left).then_with(|| a.0.name.cmp(&b.0.name)), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.0.name.cmp(&b.0.name), - } - }), - "staleness" => matched.sort_by(|a, b| { - a.0.staleness_rank() - .cmp(&b.0.staleness_rank()) - .reverse() - .then_with(|| a.0.name.cmp(&b.0.name)) - }), - _ => matched.sort_by(|a, b| { - let a_date = a.0.latest_commit_date(); - let b_date = b.0.latest_commit_date(); - match (a_date.is_empty(), b_date.is_empty()) { - (true, true) => a.0.name.cmp(&b.0.name), - (true, false) => std::cmp::Ordering::Greater, - (false, true) => std::cmp::Ordering::Less, - (false, false) => b_date.cmp(a_date).then_with(|| a.0.name.cmp(&b.0.name)), - } - }), - } -} - -/// Whether an artifact matches a status filter value. `attention` is a composite -/// matching anything needing review (broken references, modified, or stale); -/// other values match `overall_status` exactly. -fn matches_status(artifact: &ArtifactView, status: &str) -> bool { - if status == "attention" { - return artifact.has_broken_refs() - || matches!(artifact.overall_status(), "modified" | "stale"); - } - artifact.overall_status() == status -} diff --git a/src/cli/dashboard/routes/files.rs b/src/cli/dashboard/routes/files.rs index 5392b41..20de556 100644 --- a/src/cli/dashboard/routes/files.rs +++ b/src/cli/dashboard/routes/files.rs @@ -3,24 +3,10 @@ use axum::response::{Html, IntoResponse}; use std::path::PathBuf; use super::AppState; -use super::shared::{display_path, not_found}; +use super::shared::not_found; use crate::cli::dashboard::scan; use crate::cli::dashboard::templates; - -/// Reads a file into a `ConfigFile` if it exists, abbreviating the path with `~`. -fn read_config_file( - label: &str, - path: &std::path::Path, - language: &str, -) -> Option { - let content = std::fs::read_to_string(path).ok()?; - Some(templates::ConfigFile { - label: label.to_string(), - path: display_path(path, dirs::home_dir().as_deref()), - language: language.to_string(), - content, - }) -} +use commands::services::files as file_services; /// Builds a grid card for a config file, pointing at its detail route. fn file_card(file: &templates::ConfigFile, href: String) -> templates::FileCard { @@ -51,65 +37,9 @@ fn render_single_file( Html(template.to_string()).into_response() } -/// Forge-cli's own config files surfaced from `~/.config/forge`. Per-artifact -/// config there (forensic.yaml, avatar.yaml, ...) holds private identifiers and -/// is deliberately excluded; only this allowlist is rendered. -const FORGE_CONFIG_FILES: &[&str] = &[ - "config.yaml", - "config.yml", - "config.toml", - "config.json", - "watchlist.yaml", -]; - -/// Forge config files at the scanned root plus the allowlisted forge-cli config -/// files in `~/.config/forge`, in a stable order so index-based detail routing -/// stays valid across requests. -fn collect_dashboard_config_files(root: &std::path::Path) -> Vec { - let mut files = Vec::new(); - for (label, name, lang) in [ - ("Module manifest", "module.yaml", "yaml"), - ("Defaults", "defaults.yaml", "yaml"), - ("Config override", "config.yaml", "yaml"), - ("Consumer manifest", ".forge", "yaml"), - ] { - if let Some(file) = read_config_file(label, &root.join(name), lang) { - files.push(file); - } - } - if let Some(home) = dirs::home_dir() { - let forge_dir = home.join(".config/forge"); - if let Ok(entries) = std::fs::read_dir(&forge_dir) { - let mut names: Vec<_> = entries - .flatten() - .filter(|entry| entry.path().is_file()) - .map(|entry| entry.file_name().to_string_lossy().to_string()) - .filter(|name| { - FORGE_CONFIG_FILES - .iter() - .any(|allowed| name.eq_ignore_ascii_case(allowed)) - }) - .collect(); - names.sort(); - for name in names { - let is_toml = std::path::Path::new(&name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("toml")); - let lang = if is_toml { "toml" } else { "yaml" }; - if let Some(file) = - read_config_file("~/.config/forge", &forge_dir.join(&name), lang) - { - files.push(file); - } - } - } - } - files -} - pub(super) async fn settings_page(State(app): State) -> impl IntoResponse { let state = app.shared.read().await; - let groups = settings_by_harness( + let groups = file_services::settings_by_harness( &app.root, &state.provider_targets, &state.settings_filenames, @@ -140,7 +70,7 @@ pub(super) async fn settings_detail( Path((harness, index)): Path<(String, usize)>, ) -> impl IntoResponse { let state = app.shared.read().await; - let groups = settings_by_harness( + let groups = file_services::settings_by_harness( &app.root, &state.provider_targets, &state.settings_filenames, @@ -157,7 +87,7 @@ pub(super) async fn settings_detail( pub(super) async fn hooks_page(State(app): State) -> impl IntoResponse { let state = app.shared.read().await; - let groups = hooks_by_harness( + let groups = file_services::hooks_by_harness( &app.root, &state.provider_targets, &state.settings_filenames, @@ -175,7 +105,7 @@ pub(super) async fn hook_detail( Path((harness, index)): Path<(String, usize)>, ) -> impl IntoResponse { let state = app.shared.read().await; - let groups = hooks_by_harness( + let groups = file_services::hooks_by_harness( &app.root, &state.provider_targets, &state.settings_filenames, @@ -188,7 +118,7 @@ pub(super) async fn hook_detail( return not_found("Unknown hook."); } let hook = hooks.remove(index); - let (wrapper, command) = unwrap_shell(&hook.command); + let (wrapper, command) = file_services::unwrap_shell(&hook.command); let template = templates::HookDetailTemplate { tab: "hooks", version: &state.version, @@ -202,24 +132,6 @@ pub(super) async fn hook_detail( Html(template.to_string()).into_response() } -/// Strips a `sh -c '