Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4dc10a8
feat: TUI snapshot mode, vcs visibility, aligned glow preview
N4M3Z Jul 9, 2026
b7dec34
feat: TUI comment reachable from any detail tab, context hints
N4M3Z Jul 10, 2026
94a20d5
fix: harden vcs scan per adversarial review
N4M3Z Jul 10, 2026
1b23a8b
feat: TUI mouse support, windowed code view, highlighted preview fences
N4M3Z Jul 10, 2026
06a5ae3
fix: reference links resolve across preview fence segments
N4M3Z Jul 10, 2026
3e74530
fix: harden mouse hit-testing, code cursor reach, fence parsing
N4M3Z Jul 10, 2026
9c84f89
feat: rich zoom, full ADR body, real diff, repo detail, cached tabs
N4M3Z Jul 10, 2026
280ef18
fix: module-qualify detail and code cache keys
N4M3Z Jul 10, 2026
6dd25ad
fix: honest diff states, fresh zoom after rescan, smooth list browsing
N4M3Z Jul 10, 2026
c049e1a
fix: harness-panel review round — list viewport, comment identity, ho…
N4M3Z Jul 12, 2026
6e7b9c9
fix: Fable panel round — safe escape, honest search mode, live scroll…
N4M3Z Jul 12, 2026
7b47811
feat: council round one — rekeyed input, gitui letters, tuicr wrap, d…
N4M3Z Jul 12, 2026
b1f9a94
feat: council round two — one artifact view, structured provenance
N4M3Z Jul 12, 2026
e72a64f
feat: council round three — code cursor, exact scroll everywhere
N4M3Z Jul 12, 2026
73ad792
feat: diff line-number gutter and per-line diff commenting
N4M3Z Jul 12, 2026
816e75c
feat: numbered tabs restored, glow color unlocked, richer style, repo…
N4M3Z Jul 12, 2026
697b89a
feat: deploy-to-target picker and harness launch in the TUI
N4M3Z Jul 12, 2026
7bc90c8
feat: single-artifact deploy, add-target input, harness launch picker
N4M3Z Jul 12, 2026
fc6436c
fix: deploy/launch council round — slug-safe --only, contained writes
N4M3Z Jul 12, 2026
4f5d2b0
feat: digits address tabs only, jj view, clickable commits, one compa…
N4M3Z Jul 12, 2026
ffe5d24
feat: make it actionable — in-panel filter, enterable Overview, colum…
N4M3Z Jul 12, 2026
2d4fd65
fix: actionable-round adversarial pass — exact module jumps, honest help
N4M3Z Jul 12, 2026
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: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ validate = ["dep:rust-embed"]
deploy = []
embed = ["dep:rust-embed"]
dashboard = ["full", "dep:axum", "dep:tokio", "dep:askama", "dep:tower-http", "dep:open"]
tui = ["dep:ratatui", "dep:crossterm", "dep:syntect", "dep:ansi-to-tui"]
tui = ["dep:ratatui", "dep:crossterm", "dep:syntect", "dep:ansi-to-tui", "dep:unicode-width"]

[dependencies]
# cli
Expand Down Expand Up @@ -65,6 +65,7 @@ ratatui = { version = "0.30", optional = true }
crossterm = { version = "0.29", optional = true }
syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"], optional = true }
ansi-to-tui = { version = "8.0.1", optional = true }
unicode-width = { version = "0.2", optional = true }

[build-dependencies]
sha2 = "0.10"
Expand Down
7 changes: 7 additions & 0 deletions src/cli/dashboard/routes/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ pub(super) async fn companion_detail(
kind: "skills".to_string(),
module: module_name,
relative_path: companion.relative_path.clone(),
source_path: companion.relative_path.clone(),
description: companion.description.clone(),
content_preview: String::new(),
content_body: companion.content_body.clone(),
Expand All @@ -234,6 +235,7 @@ pub(super) async fn companion_detail(
module_tint: 0,
companions: Vec::new(),
variants: Vec::new(),
vcs: None,
};
let companion_label = format!("{parent} / {name}");
let provenance_raw = scan::read_source_sidecar(
Expand Down Expand Up @@ -654,6 +656,8 @@ mod tests {
module_tint: 0,
companions: Vec::new(),
variants: Vec::new(),
source_path: String::new(),
vcs: None,
}
}

Expand All @@ -665,6 +669,9 @@ mod tests {
source_uri: format!("https://example.com/{name}"),
is_target: false,
artifacts,
local_path: None,
vcs: None,
git_log: Vec::new(),
}
}

Expand Down
134 changes: 116 additions & 18 deletions src/cli/deploy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::cli::config;
/// Modified → skip (unless --force)
/// ```
#[allow(clippy::fn_params_excessive_bools, clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
pub fn execute(
path: &str,
target: Option<&str>,
Expand All @@ -29,7 +30,11 @@ pub fn execute(
prune: bool,
_interactive: bool,
dry_run: bool,
only: Option<&str>,
) -> Result<ActionResult, Error> {
// A scoped deploy leaves everything else at the target alone: pruning
// against a filtered key set would quarantine the rest of the module.
let prune = prune && only.is_none();
let module_root = Path::new(path);
require_module_root(module_root)?;
let mut result = ActionResult::new();
Expand Down Expand Up @@ -71,7 +76,22 @@ pub fn execute(
validate_target_boundary(&target_base, Path::new(dir))?;
}

let mut existing_manifest = load_deployed_manifest(&target_base);
let mut existing_manifest = match load_deployed_manifest(&target_base) {
Ok(entries) => entries,
Err(error) if only.is_some() => {
return Err(Error::new(
ErrorKind::Config,
format!(
"refusing filtered deploy over a corrupt manifest ({error}); \
run a full install to rebuild it"
),
));
}
Err(error) => {
eprintln!("warning: {error}; rebuilding manifest from this deploy");
HashMap::new()
}
};
let mut deployed_keys: HashSet<String> = HashSet::new();

deploy_provider_files(
Expand All @@ -82,6 +102,7 @@ pub fn execute(
&mut result,
provider_name,
force,
only,
)?;

// Stale detection only when prune enabled. Pruned files are
Expand Down Expand Up @@ -214,6 +235,33 @@ pub fn execute(
}

/// Deploy all content kinds (agents, skills, rules) for a single provider.
/// Whether a manifest key falls under an `--only` prefix. A prefix ending in
/// `/` or `.` matches literally; a bare prefix (`skills/Alpha`) matches only
/// at a path or extension boundary, so `skills/AlphaOther/` stays untouched.
fn only_matches(manifest_key: &str, prefix: &str) -> bool {
// Providers rename artifacts during assembly (gemini slugifies
// SecurityArchitect to security-architect); compare shapes that survive
// the rename: lowercase with separators dropped.
let key = normalize_only(manifest_key);
let prefix = normalize_only(prefix);
if prefix.ends_with('/') || prefix.ends_with('.') {
return key.starts_with(&prefix);
}
key == prefix
|| key
.strip_prefix(&prefix)
.is_some_and(|rest| rest.starts_with('/') || rest.starts_with('.'))
}

fn normalize_only(value: &str) -> String {
value
.chars()
.filter(|character| *character != '-' && *character != '_')
.flat_map(char::to_lowercase)
.collect()
}

#[allow(clippy::too_many_arguments)]
fn deploy_provider_files(
build_provider_dir: &Path,
target_base: &Path,
Expand All @@ -222,6 +270,7 @@ fn deploy_provider_files(
result: &mut ActionResult,
provider_name: &str,
force: bool,
only: Option<&str>,
) -> Result<(), Error> {
for kind in commands::provider::ContentKind::ALL {
let kind_dir = build_provider_dir.join(kind.as_str());
Expand All @@ -242,6 +291,9 @@ fn deploy_provider_files(
.to_string_lossy()
.to_string();
let manifest_key = format!("{kind}/{relative}");
if only.is_some_and(|prefix| !only_matches(&manifest_key, prefix)) {
continue;
}
deployed_keys.insert(manifest_key.clone());
let target_path = target_base.join(kind.as_str()).join(&relative);

Expand All @@ -250,11 +302,6 @@ fn deploy_provider_files(
let provenance_relative = manifest::provenance_path(&manifest_key);
let sidecar_source = manifest::sidecar_path(&build_path);

if sidecar_source.is_file() {
let provenance_target = target_base.join(&provenance_relative);
let _ = copy_file(&sidecar_source, &provenance_target);
}

let target_content = fs::read_to_string(&target_path).ok();
let status = manifest::status(
target_content.as_deref(),
Expand All @@ -264,7 +311,19 @@ fn deploy_provider_files(

match status {
manifest::FileStatus::New | manifest::FileStatus::Stale => {
ensure_destination_within(&target_path, target_base)?;
copy_file(&build_path, &target_path)?;
// Provenance travels only with content that actually
// installed; a skipped modified file keeps its old sidecar.
if sidecar_source.is_file() {
let provenance_target = target_base.join(&provenance_relative);
if let Err(error) = copy_file(&sidecar_source, &provenance_target) {
eprintln!(
"warning: sidecar copy failed for {}: {error}",
provenance_target.display()
);
}
}
new_manifest.insert(
manifest_key,
manifest::ManifestEntry {
Expand Down Expand Up @@ -389,7 +448,10 @@ fn require_module_root(module_root: &Path) -> Result<(), Error> {
}

/// Verify the resolved target path stays within the specified base directory.
/// Containment is checked against the deepest existing ancestor BEFORE any
/// directory is created, so an escaping path never mutates the filesystem.
fn validate_target_boundary(target_path: &Path, base_directory: &Path) -> Result<(), Error> {
ensure_destination_within(&target_path.join("probe"), base_directory)?;
fs::create_dir_all(target_path).map_err(|error| {
Error::new(
ErrorKind::Io,
Expand Down Expand Up @@ -426,21 +488,17 @@ fn validate_target_boundary(target_path: &Path, base_directory: &Path) -> Result
/// Load the previously deployed `.manifest` from a provider's target directory.
pub(crate) fn load_deployed_manifest(
target_base: &Path,
) -> HashMap<String, manifest::ManifestEntry> {
) -> Result<HashMap<String, manifest::ManifestEntry>, Error> {
let manifest_path = target_base.join(".manifest");
let Ok(content) = fs::read_to_string(&manifest_path) else {
return HashMap::new();
return Ok(HashMap::new());
};
match manifest::read(&content) {
Ok(entries) => entries,
Err(error) => {
eprintln!(
"warning: corrupt .manifest at {}: {error}",
manifest_path.display()
);
HashMap::new()
}
}
manifest::read(&content).map_err(|error| {
Error::new(
ErrorKind::Config,
format!("corrupt .manifest at {}: {error}", manifest_path.display()),
)
})
}

/// Write `.manifest` to the provider's target directory after deployment.
Expand All @@ -463,6 +521,46 @@ fn write_manifest(
.map_err(|e| Error::new(ErrorKind::Io, format!("cannot write .manifest: {e}")))
}

/// Verify that writing `target_path` cannot escape `base`: the deepest
/// existing ancestor (which any symlinked component resolves through) must
/// canonicalize inside the base directory.
fn ensure_destination_within(target_path: &Path, base: &Path) -> Result<(), Error> {
fs::create_dir_all(base).map_err(|error| {
Error::new(
ErrorKind::Io,
format!("cannot create {}: {error}", base.display()),
)
})?;
let resolved_base = base.canonicalize().map_err(|error| {
Error::new(
ErrorKind::Io,
format!("cannot resolve {}: {error}", base.display()),
)
})?;
let existing = target_path
.ancestors()
.find(|ancestor| ancestor.exists())
.unwrap_or(base);
let resolved = existing.canonicalize().map_err(|error| {
Error::new(
ErrorKind::Io,
format!("cannot resolve {}: {error}", existing.display()),
)
})?;
if resolved.starts_with(&resolved_base) {
Ok(())
} else {
Err(Error::new(
ErrorKind::Config,
format!(
"destination escapes target: {} resolves to {}",
target_path.display(),
resolved.display()
),
))
}
}

/// Copy a file, creating parent directories as needed.
fn copy_file(source: &Path, target: &Path) -> Result<(), Error> {
if let Some(parent) = target.parent() {
Expand Down
74 changes: 72 additions & 2 deletions src/cli/deploy/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ fn collect_files_recursive_errors_on_missing_directory() {
#[test]
fn load_deployed_manifest_returns_empty_for_missing_file() {
let temp_directory = TempDir::new().unwrap();
let manifest = load_deployed_manifest(temp_directory.path());
let manifest = load_deployed_manifest(temp_directory.path()).unwrap();
assert!(manifest.is_empty());
}

Expand Down Expand Up @@ -80,7 +80,7 @@ fn write_then_load_manifest_roundtrips() {
);

write_manifest(temp_directory.path(), &entries).unwrap();
let loaded = load_deployed_manifest(temp_directory.path());
let loaded = load_deployed_manifest(temp_directory.path()).unwrap();
assert_eq!(loaded["rules/UseRTK.md"].fingerprint, "abc123");
}

Expand Down Expand Up @@ -201,3 +201,73 @@ fn prune_empty_parents_never_removes_stop() {
assert!(!nested.exists());
assert!(stop.exists(), "stop directory must never be removed");
}

#[test]
fn deploy_provider_files_only_prefix_filters_deployment() {
let temp_directory = TempDir::new().unwrap();
let build_dir = temp_directory.path().join("build/claude");
std::fs::create_dir_all(build_dir.join("skills/Alpha")).unwrap();
std::fs::create_dir_all(build_dir.join("skills/Beta")).unwrap();
std::fs::write(build_dir.join("skills/Alpha/SKILL.md"), "alpha body").unwrap();
std::fs::write(build_dir.join("skills/Beta/SKILL.md"), "beta body").unwrap();
let target = temp_directory.path().join("target");

let mut manifest_entries = HashMap::new();
let mut deployed_keys = HashSet::new();
let mut result = ActionResult::new();
deploy_provider_files(
&build_dir,
&target,
&mut manifest_entries,
&mut deployed_keys,
&mut result,
"claude",
false,
Some("skills/Alpha/"),
)
.unwrap();

assert!(target.join("skills/Alpha/SKILL.md").is_file());
assert!(!target.join("skills/Beta/SKILL.md").exists());
assert!(deployed_keys.contains("skills/Alpha/SKILL.md"));
assert!(!deployed_keys.contains("skills/Beta/SKILL.md"));
assert_eq!(
std::fs::read_to_string(target.join("skills/Alpha/SKILL.md")).unwrap(),
"alpha body"
);
}

#[test]
fn only_matches_respects_boundaries() {
assert!(only_matches("skills/Alpha/SKILL.md", "skills/Alpha/"));
assert!(only_matches("skills/Alpha/SKILL.md", "skills/Alpha"));
assert!(!only_matches("skills/AlphaOther/SKILL.md", "skills/Alpha"));
assert!(only_matches("agents/Name.md", "agents/Name."));
assert!(only_matches("agents/Name.toml", "agents/Name"));
assert!(!only_matches("agents/NameOther.md", "agents/Name"));
}

#[test]
fn only_matches_survives_provider_slugging() {
assert!(only_matches(
"agents/security-architect.md",
"agents/SecurityArchitect"
));
assert!(!only_matches(
"agents/security-architect-two.md",
"agents/SecurityArchitect"
));
}

#[test]
fn ensure_destination_within_rejects_symlink_escape() {
let temp_directory = TempDir::new().unwrap();
let base = temp_directory.path().join("base");
let outside = temp_directory.path().join("outside");
std::fs::create_dir_all(base.join("skills")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, base.join("skills/Escape")).unwrap();

assert!(ensure_destination_within(&base.join("skills/Inside/SKILL.md"), &base).is_ok());
assert!(ensure_destination_within(&base.join("skills/Escape/SKILL.md"), &base).is_err());
}
5 changes: 4 additions & 1 deletion src/cli/drift/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ fn compare_provider(

// This module's deployed files (per the target manifest + provenance) that
// are no longer built — stale deployments that should be pruned.
for (key, entry) in load_deployed_manifest(deployed_base) {
for (key, entry) in load_deployed_manifest(deployed_base).unwrap_or_else(|error| {
eprintln!("warning: {error}; treating manifest as empty for drift");
std::collections::HashMap::new()
}) {
if build_files.contains_key(&key) || !is_content_key(&key) {
continue;
}
Expand Down
2 changes: 2 additions & 0 deletions src/cli/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn execute(
prune: bool,
interactive: bool,
dry_run: bool,
only: Option<&str>,
model: Option<&str>,
allow_stale: bool,
) -> Result<ActionResult, Error> {
Expand All @@ -42,6 +43,7 @@ pub fn execute(
prune,
interactive,
dry_run,
only,
)
}

Expand Down
Loading