diff --git a/docs/cli.md b/docs/cli.md index 77e3bb6..ca92176 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,6 +23,20 @@ vulp [--json] # non-interactive (scripts/AI) - Mutating commands write to the same `~/.vulp/activity.log` as the TUI, so CLI actions appear in the TUI's Overview feed. +## Guardrails (for AI agents especially) + +- **`--dry-run`** (global flag): mutating commands (`sync`, `release`, + `init`, `repo collab`, `repo license`) print `DRY RUN: would ...` lines + and exit 0 without changing anything. Read commands ignore it. +- **`protected_repos`** (config, comma-separated in `vulp config`): mutating + commands refuse to touch listed repos (exit 1) unless **`--force`** is + passed. Matching is by display label or dir basename, case-insensitive. + The protection check runs before `--dry-run`, so a dry run also reveals it. +- **`VULP_AGENT`** (env var, any non-empty value): every activity-log entry + written while it's set gets a `· via agent` marker in its detail, so human + and agent actions are distinguishable in `vulp activity` and the TUI feed. + AI agents driving vulp should always set it. + ## Workspace (local, no network) ``` diff --git a/src/activity/mod.rs b/src/activity/mod.rs index 5ccbf13..03dc993 100644 --- a/src/activity/mod.rs +++ b/src/activity/mod.rs @@ -55,7 +55,17 @@ fn log_path() -> PathBuf { /// the Activity tab's commit feed (sourced straight from git log) covers /// separately and accurately regardless of how it happened. pub fn log(entry: &ActivityEntry) { - let Ok(line) = serde_json::to_string(entry) else { return }; + // Executor marker: AI agents set VULP_AGENT so their actions are + // distinguishable from the human's in the shared log. + let mut entry = entry.clone(); + if std::env::var("VULP_AGENT").map(|v| !v.is_empty()).unwrap_or(false) { + entry.detail = if entry.detail.is_empty() { + "via agent".into() + } else { + format!("{} · via agent", entry.detail) + }; + } + let Ok(line) = serde_json::to_string(&entry) else { return }; let path = log_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); diff --git a/src/app.rs b/src/app.rs index 0587067..330ca74 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,6 +22,9 @@ pub async fn run(cli: Cli) -> Result<()> { Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await, // ── everything else: non-interactive CLI ── - Some(cmd) => crate::cli_cmds::run(cmd, cli.json, &cfg).await, + Some(cmd) => { + let guard = crate::cli_cmds::Guard { dry_run: cli.dry_run, force: cli.force }; + crate::cli_cmds::run(cmd, cli.json, guard, &cfg).await + } } } diff --git a/src/cli.rs b/src/cli.rs index 85d5f1e..fa4a94c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,6 +19,13 @@ pub struct Cli { /// Machine-readable JSON output on stdout (list/status/stats/... commands) #[arg(long, global = true)] pub json: bool, + /// Print what a mutating command (sync/release/init/collab/license) + /// would do without doing it + #[arg(long, global = true)] + pub dry_run: bool, + /// Proceed on repos listed in config `protected_repos` + #[arg(long, global = true)] + pub force: bool, } #[derive(Subcommand)] diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index 724b8da..f9d37a6 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -14,18 +14,26 @@ use crate::cli::Commands; use crate::config::AppConfig; use anyhow::Result; +/// Global guardrail flags (`--dry-run`, `--force`) threaded into the +/// mutating commands; read commands ignore them. +#[derive(Clone, Copy)] +pub(crate) struct Guard { + pub dry_run: bool, + pub force: bool, +} + /// Dispatches one parsed subcommand. Only called for commands that aren't /// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`). -pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> { +pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> { match cmd { Commands::List { dirty, behind, ahead, lang, project } => workspace::list(cfg, json, dirty, behind, ahead, lang, project), Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Scan => workspace::scan_cmd(cfg, json), Commands::Sync { repos, all, project, action, message } => - workspace::sync(cfg, repos, all, project, &action, message), + workspace::sync(cfg, guard, repos, all, project, &action, message), Commands::Release { repo, tag, notes } => - remote::release(cfg, &repo, &tag, ¬es).await, + remote::release(cfg, guard, &repo, &tag, ¬es).await, Commands::Stats { period } => workspace::stats(cfg, json, &period), Commands::Activity { limit, kind } => workspace::activity(json, limit, kind), @@ -34,9 +42,9 @@ pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> { Commands::Pr { action } => remote::pr(cfg, json, action).await, Commands::Issue { action } => remote::issue(cfg, json, action).await, - Commands::Repo { action } => remote::repo_cmd(cfg, json, action).await, + Commands::Repo { action } => remote::repo_cmd(cfg, json, guard, action).await, Commands::Init { name, private, description } => - remote::init(cfg, &name, private, &description).await, + remote::init(cfg, guard, &name, private, &description).await, Commands::Accounts => remote::accounts(cfg, json), Commands::Refresh => remote::refresh(cfg, json).await, @@ -96,6 +104,29 @@ pub(crate) fn default_provider(cfg: &AppConfig) -> (crate::auth::Account, Box bool { + let name = r.name.to_lowercase(); + let base = r.path.file_name().map(|b| b.to_string_lossy().to_lowercase()).unwrap_or_default(); + cfg.protected_repos.iter().any(|p| { + let p = p.trim().to_lowercase(); + p == name || p == base + }) +} + +/// Exits 1 when any target is protected and `--force` wasn't passed. +pub(crate) fn guard_protected(cfg: &AppConfig, force: bool, targets: &[&crate::status::RepoSummary]) { + if force { return; } + let blocked: Vec<&str> = targets.iter() + .filter(|r| is_protected(cfg, r)) + .map(|r| r.name.as_str()) + .collect(); + if !blocked.is_empty() { + die(1, &format!("protected repos: {} — pass --force to proceed", blocked.join(", "))); + } +} + /// Logs one vulp-performed action to `~/.vulp/activity.log` (same log the /// TUI writes, so CLI actions show up in the Overview feed too). pub(crate) fn log_activity(kind: crate::activity::ActivityKind, repo: &str, detail: String) { diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index 80541d0..f7ca175 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -78,8 +78,13 @@ pub async fn issue(cfg: &AppConfig, json: bool, cmd: IssueCmd) -> Result<()> { } } -pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Result<()> { +pub async fn release(cfg: &AppConfig, guard: super::Guard, repo: &str, tag: &str, notes: &str) -> Result<()> { let r = find_repo(cfg, repo); + super::guard_protected(cfg, guard.force, &[&r]); + if guard.dry_run { + println!("DRY RUN: would tag {} as {tag}, push the tag, and publish a provider release", r.name); + return Ok(()); + } let msg = if notes.is_empty() { tag } else { notes }; git_run(&r.path, &["tag", "-a", tag, "-m", msg]); if !git_run_ok(&r.path, &["push", "origin", tag]) { @@ -103,7 +108,7 @@ pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Res Ok(()) } -pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> { +pub async fn repo_cmd(cfg: &AppConfig, json: bool, guard: super::Guard, cmd: RepoCmd) -> Result<()> { match cmd { RepoCmd::Url { repo } => { let r = find_repo(cfg, &repo); @@ -162,16 +167,21 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> { die(2, "role must be pull | push | admin"); } let r = find_repo(cfg, &repo); - let (_, provider) = default_provider(cfg); + super::guard_protected(cfg, guard.force, &[&r]); let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) .unwrap_or_else(|| r.name.clone()); + if guard.dry_run { + println!("DRY RUN: would invite {user} to {repo_basename} ({role})"); + return Ok(()); + } + let (_, provider) = default_provider(cfg); match provider.add_collaborator(&repo_basename, &user, &role).await { Ok(()) => { println!("invited {user} to {repo_basename} ({role})"); Ok(()) } Err(e) => die(1, &format!("invite failed: {e:#}")), } } RepoCmd::License { repos, all, kind, holder } => { - license(cfg, json, repos, all, kind, holder) + license(cfg, json, guard, repos, all, kind, holder) } } } @@ -179,7 +189,7 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> { /// `vulp repo license` — writes a LICENSE into each target repo that lacks /// one. Explicit names that already have a license are reported as skipped, /// not errors; `--all` targets every unlicensed workspace repo. -fn license(cfg: &AppConfig, json: bool, repos: Vec, all: bool, +fn license(cfg: &AppConfig, json: bool, guard: super::Guard, repos: Vec, all: bool, kind: Option, holder: Option) -> Result<()> { use crate::license::{LicenseKind, WriteOutcome}; @@ -211,6 +221,18 @@ fn license(cfg: &AppConfig, json: bool, repos: Vec, all: bool, }).collect() }; + super::guard_protected(cfg, guard.force, &targets); + if guard.dry_run { + for r in &targets { + if r.has_license { + println!("DRY RUN: would skip {} (already has a license)", r.name); + } else { + println!("DRY RUN: would write {} LICENSE to {} (holder: {holder})", kind.label(), r.name); + } + } + return Ok(()); + } + let year = chrono::Datelike::year(&chrono::Utc::now()); let (mut written, mut skipped, mut failed) = (Vec::new(), Vec::new(), Vec::new()); for r in targets { @@ -240,11 +262,16 @@ fn license(cfg: &AppConfig, json: bool, repos: Vec, all: bool, Ok(()) } -pub async fn init(cfg: &AppConfig, name: &str, private: bool, description: &str) -> Result<()> { +pub async fn init(cfg: &AppConfig, guard: super::Guard, name: &str, private: bool, description: &str) -> Result<()> { let path = cfg.workspace_root.join(name); if path.exists() { die(1, &format!("{} already exists", path.display())); } + if guard.dry_run { + println!("DRY RUN: would init {}, create a {} repo on the provider, and push", + path.display(), if private { "private" } else { "public" }); + return Ok(()); + } let (_, provider) = default_provider(cfg); std::fs::create_dir_all(&path)?; diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs index 71ba411..718d6a6 100644 --- a/src/cli_cmds/workspace.rs +++ b/src/cli_cmds/workspace.rs @@ -89,7 +89,7 @@ pub fn scan_cmd(cfg: &AppConfig, json: bool) -> Result<()> { } pub fn sync( - cfg: &AppConfig, + cfg: &AppConfig, guard: super::Guard, repos: Vec, all: bool, project: Option, action: &str, message: Option, ) -> Result<()> { @@ -120,11 +120,25 @@ pub fn sync( }; let action = action.replace('-', "_"); // commit-push → commit_push + if !["pull", "push", "commit_push"].contains(&action.as_str()) { + die(2, &format!("unknown action '{action}' (pull | push | commit-push)")); + } if action == "commit_push" && message.is_none() { die(2, "--message is required for commit-push"); } let msg = message.unwrap_or_default(); + super::guard_protected(cfg, guard.force, &targets); + if guard.dry_run { + for r in &targets { + match action.as_str() { + "commit_push" => println!("DRY RUN: would commit+push {} (message: {msg})", r.name), + a => println!("DRY RUN: would {a} {}", r.name), + } + } + return Ok(()); + } + let mut failed = 0usize; for r in &targets { eprintln!("── {} [{}]", r.name, action); diff --git a/src/config/mod.rs b/src/config/mod.rs index c0772ac..881b0b2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -44,6 +44,10 @@ pub struct AppConfig { /// Default template for the add-LICENSE action: "mit" or "apache-2.0". #[serde(default = "default_license_kind")] pub default_license: String, + /// Repos (display name or dir basename, case-insensitive) that mutating + /// CLI commands refuse to touch unless `--force` is passed. + #[serde(default)] + pub protected_repos: Vec, } fn default_license_kind() -> String { "mit".into() } @@ -82,6 +86,7 @@ impl Default for AppConfig { show_locs: false, license_holder: String::new(), default_license: default_license_kind(), + protected_repos: vec![], } } } @@ -107,6 +112,7 @@ impl AppConfig { ("workspace_groups", self.workspace_groups.join(", ")), ("license_holder", self.license_holder.clone()), ("default_license", self.default_license.clone()), + ("protected_repos", self.protected_repos.join(", ")), ] } @@ -130,6 +136,13 @@ impl AppConfig { "vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value), "license_holder" => self.license_holder = value, "default_license" => self.default_license = value, + "protected_repos" => { + self.protected_repos = value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } "workspace_groups" => { self.workspace_groups = value .split(',')