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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Cargo.lock.bak
.DS_Store
/.idea
.pitboss/
pitboss-contributions.md
427 changes: 427 additions & 0 deletions src/cli/config.rs

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/cli/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,32 @@ pub fn run(workspace: impl AsRef<Path>) -> Result<()> {
Ok(())
}

/// Create the directory structure, state file, and `.gitignore` entry without
/// writing any template content files. Used by `pitboss setup`, which
/// provides its own `plan.md` and `config.toml` via the wizard.
pub(crate) fn scaffold_dirs(workspace: &Path) -> Result<()> {
fs::create_dir_all(workspace)
.with_context(|| format!("init: creating workspace {:?}", workspace))?;

let pitboss_root = workspace.join(".pitboss");
if pitboss_root.exists() && !pitboss_root.is_dir() {
anyhow::bail!(
"init: {:?} exists but is not a directory; refusing to overwrite",
pitboss_root
);
}

let mut report = Vec::new();
ensure_dir(workspace, ".pitboss/play/snapshots", &mut report)?;
ensure_dir(workspace, ".pitboss/play/logs", &mut report)?;
ensure_dir(workspace, ".pitboss/grind/prompts", &mut report)?;
ensure_dir(workspace, ".pitboss/grind/rotations", &mut report)?;
ensure_dir(workspace, ".pitboss/grind/runs", &mut report)?;
init_state_file(workspace, &mut report)?;
update_gitignore(workspace, &mut report)?;
Ok(())
}

fn write_if_missing(
workspace: &Path,
rel: &str,
Expand Down
45 changes: 44 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
use anyhow::Result;
use clap::{Parser, Subcommand};

pub mod config;
pub mod exit_code;
pub mod fold;
pub mod grind;
pub mod init;
pub mod interview;
pub mod nuke;
pub mod plan;
pub mod play;
pub mod prompts;
pub mod rebuy;
pub mod start;
pub mod status;
pub mod sweep;

Expand Down Expand Up @@ -66,12 +69,18 @@ impl Cli {
match &self.command {
Command::Play { tui, .. } | Command::Rebuy { tui, .. } => *tui,
Command::Grind(args) => args.tui,
// Config and Start both run a full-screen wizard in alternate
// mode — suppress the fmt layer so tracing output doesn't corrupt
// the terminal buffer. (Start may also chain into `play --tui`,
// which keeps the same alternate-screen mode active.)
Command::Config { .. } | Command::Start => true,
Command::Init
| Command::Plan { .. }
| Command::Status
| Command::Fold { .. }
| Command::Sweep(_)
| Command::Prompts(_) => false,
| Command::Prompts(_)
| Command::Nuke => false,
}
}
}
Expand All @@ -80,6 +89,28 @@ impl Cli {
pub enum Command {
/// Scaffold a new pitboss workspace in the current directory.
Init,
/// Interactive TUI wizard for `.pitboss/config.toml`. On a fresh
/// workspace, walks the user through every config knob (models, budget,
/// sweeps, auditor, tests). On an existing workspace, opens straight to
/// a summary of current settings with an option to edit. Aliased as
/// `setup` for backwards compatibility.
#[command(alias = "setup")]
Config {
/// Accepted for backwards compatibility with `pitboss setup --force`.
/// No-op: `pitboss config` always allows re-editing an existing
/// workspace.
#[arg(long, hide = true)]
force: bool,
},
/// Universal entry point. Auto-detects whether `.pitboss/` exists:
/// without it, runs the setup wizard and prints next steps; with it,
/// opens the iteration wizard (current budget, deferred items, and
/// completed phases) and offers continue / sweep / new-plan paths.
Start,
/// Completely remove pitboss from the workspace. Deletes the
/// `.pitboss/` directory (config, plan, deferred items, state, all
/// logs) after a `y/N` confirmation. Cannot be undone.
Nuke,
/// Generate a `plan.md` for a goal using the planner agent.
Plan {
/// Free-form description of what to build.
Expand Down Expand Up @@ -188,6 +219,18 @@ pub async fn dispatch(cli: Cli) -> Result<ExitCode> {
init::run(std::env::current_dir()?)?;
Ok(ExitCode::Success)
}
Command::Config { force: _ } => {
config::run(std::env::current_dir()?).await?;
Ok(ExitCode::Success)
}
Command::Start => {
start::run(std::env::current_dir()?).await?;
Ok(ExitCode::Success)
}
Command::Nuke => {
nuke::run(std::env::current_dir()?).await?;
Ok(ExitCode::Success)
}
Command::Plan {
goal,
force,
Expand Down
141 changes: 141 additions & 0 deletions src/cli/nuke.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//! `pitboss nuke` — completely remove pitboss from the workspace.
//!
//! Deletes the `.pitboss/` directory (config, plan, deferred items, state,
//! per-run logs, grind state — everything) after an explicit `y/N` prompt.
//! Refuses to run when there's nothing to delete. Leaves the `.pitboss/`
//! entry in `.gitignore` alone — adding it again on a future `pitboss
//! config` would be a no-op anyway, and the entry shouldn't matter to
//! anything else.

use std::fs;
use std::io::Write as _;
use std::path::PathBuf;

use anyhow::{Context, Result};

pub async fn run(workspace: PathBuf) -> Result<()> {
let pitboss_dir = workspace.join(".pitboss");

if !pitboss_dir.is_dir() {
anyhow::bail!(
"nothing to nuke: {:?} has no `.pitboss/` directory.",
workspace
);
}

// Show what's about to die so the user can sanity-check before
// confirming. Lists the workspace path + the top-level contents that
// are about to be removed.
println!();
println!(" About to delete:");
println!(" {}", pitboss_dir.display());
if let Ok(entries) = fs::read_dir(&pitboss_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let suffix = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
"/"
} else {
""
};
println!(" .pitboss/{}{}", name, suffix);
}
}
println!();
println!(" This removes all config, plan content, deferred items, run state,");
println!(" agent logs, and grind state. The action cannot be undone.");
println!();

if !confirm()? {
eprintln!("nuke cancelled — nothing removed");
return Ok(());
}

fs::remove_dir_all(&pitboss_dir)
.with_context(|| format!("nuke: removing {:?}", pitboss_dir))?;

println!();
println!(" Pitboss deleted from project.");
println!(" Run `pitboss config` to add it again.");
println!();
Ok(())
}

/// Prompt the user with `Are you sure you want to delete? (y/N): ` and
/// return `true` only when the answer starts with `y`/`Y`. Empty answer or
/// any other input is treated as no. Non-interactive stdin (piped input)
/// bails — there's no safe default for a destructive action without an
/// explicit human.
fn confirm() -> Result<bool> {
if !is_interactive() {
anyhow::bail!("pitboss nuke requires an interactive terminal to confirm the deletion.");
}

print!(" Are you sure you want to delete? (y/N): ");
std::io::stdout().flush().ok();

let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.context("nuke: reading confirmation")?;
Ok(is_yes_answer(&line))
}

fn is_yes_answer(answer: &str) -> bool {
answer.trim().to_ascii_lowercase().starts_with('y')
}

#[cfg(not(test))]
fn is_interactive() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}

// In `cargo test`, stdin is still wired to the real TTY, so the production
// `is_terminal()` check returns true and `read_line` blocks forever. Force
// non-interactive under cfg(test) so the bail path is exercisable.
#[cfg(test)]
fn is_interactive() -> bool {
false
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[tokio::test]
async fn bails_when_no_pitboss_dir() {
let dir = tempdir().unwrap();
let err = run(dir.path().to_path_buf()).await.unwrap_err();
assert!(
err.to_string().contains("nothing to nuke"),
"expected 'nothing to nuke' message, got: {err}"
);
}

#[tokio::test]
async fn refuses_non_interactive_when_pitboss_exists() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".pitboss/play")).unwrap();
// No TTY in tests — the confirm step must refuse rather than nuke
// by accident.
let err = run(dir.path().to_path_buf()).await.unwrap_err();
assert!(
err.to_string().contains("interactive terminal"),
"expected interactive-terminal bail, got: {err}"
);
// Critical: .pitboss/ must still exist after the bail.
assert!(dir.path().join(".pitboss").is_dir());
}

#[test]
fn accepts_any_yes_prefix() {
assert!(is_yes_answer("y"));
assert!(is_yes_answer("yes"));
assert!(is_yes_answer("YEP"));
assert!(is_yes_answer(" yolo "));
assert!(!is_yes_answer(""));
assert!(!is_yes_answer("n"));
assert!(!is_yes_answer("nope"));
}
}
2 changes: 1 addition & 1 deletion src/cli/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ const MANIFEST_FILES: &[&str] = &[
/// READMEs we surface verbatim (truncated). Lowercase variants are tried too.
const README_FILES: &[&str] = &["README.md", "README", "README.txt", "README.rst"];

fn collect_repo_summary(workspace: &Path) -> Result<String> {
pub(crate) fn collect_repo_summary(workspace: &Path) -> Result<String> {
let mut sections: Vec<String> = Vec::new();
sections.push(format!(
"Top-level entries:\n{}",
Expand Down
Loading