use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use crate::config::StorageTarget;
use crate::manifest::TaskKind;
use crate::output::Format;
#[derive(Debug, Parser)]
#[command(
name = "workspace-mgr",
version,
about = "Fixed-policy repository workspace manager for coding agents",
subcommand_required = true,
arg_required_else_help = true
)]
pub struct Cli {
#[arg(
long,
global = true,
value_enum,
default_value = "human",
env = "WORKSPACE_MGR_FORMAT"
)]
pub format: Format,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(long_about = crate::command_guidance::command("setup"))]
Setup(SetupArgs),
#[command(alias = "init", long_about = crate::command_guidance::command("manage"))]
Manage(ManageArgs),
#[command(long_about = crate::command_guidance::command("instructions"))]
Instructions(InstructionsArgs),
#[command(long_about = crate::command_guidance::command("doctor"))]
Doctor(DoctorArgs),
#[command(long_about = crate::command_guidance::command("config"))]
Config(ConfigArgs),
#[command(long_about = crate::command_guidance::command("task"))]
Task(TaskArgs),
#[command(long_about = crate::command_guidance::command("plan"))]
Plan(PlanArgs),
#[command(long_about = crate::command_guidance::command("publish"))]
Publish(PublishCommandArgs),
#[command(long_about = crate::command_guidance::command("storage"))]
Storage(StorageArgs),
#[command(long_about = crate::command_guidance::command("move"))]
Move(MoveArgs),
#[command(long_about = crate::command_guidance::command("archive"))]
Archive(ArchiveArgs),
#[command(long_about = crate::command_guidance::command("remove"))]
Remove(RemoveArgs),
#[command(long_about = crate::command_guidance::command("untrack"))]
Untrack(UntrackArgs),
#[command(long_about = crate::command_guidance::command("refresh"))]
Refresh(RefreshArgs),
}
#[derive(Debug, Args)]
pub struct SetupArgs {
#[arg(long)]
pub runtime_dir: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct RepoArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct DoctorArgs {
pub task: Option<String>,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct ManageArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub s3_url: Option<String>,
#[arg(long, requires = "s3_url")]
pub s3_endpoint_url: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, conflicts_with_all = ["s3_url", "s3_endpoint_url"])]
pub cancel_migration: bool,
}
#[derive(Debug, Args)]
pub struct InstructionsArgs {
pub topic: Option<String>,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct ConfigArgs {
#[command(subcommand)]
pub command: ConfigCommand,
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
#[command(long_about = crate::command_guidance::command("config show"))]
Show(RepoArgs),
}
#[derive(Debug, Args)]
pub struct TaskArgs {
#[command(subcommand)]
pub command: TaskCommand,
}
#[derive(Debug, Subcommand)]
pub enum TaskCommand {
#[command(long_about = crate::command_guidance::command("task list"))]
List(TaskListArgs),
#[command(long_about = crate::command_guidance::command("task path"))]
Path(TaskPathArgs),
#[command(long_about = crate::command_guidance::command("task show"))]
Show(TaskShowArgs),
#[command(long_about = crate::command_guidance::command("task create"))]
Create(TaskCreateArgs),
#[command(long_about = crate::command_guidance::command("task adopt"))]
Adopt(TaskAdoptArgs),
#[command(long_about = crate::command_guidance::command("task rename"))]
Rename(TaskRenameArgs),
#[command(long_about = crate::command_guidance::command("task upgrade"))]
Upgrade(TaskUpgradeArgs),
#[command(long_about = crate::command_guidance::command("task status"))]
Status(TaskStatusArgs),
#[command(long_about = crate::command_guidance::command("task discard"))]
Discard(TaskDiscardArgs),
#[command(long_about = crate::command_guidance::command("task approve-cloud-usage"))]
ApproveCloudUsage(TaskApproveCloudUsageArgs),
}
#[derive(Debug, Args)]
pub struct TaskListArgs {
pub query: Option<String>,
#[arg(long, value_enum)]
pub kind: Option<TaskKind>,
#[arg(long, value_enum)]
pub placement: Option<crate::task_catalog::Placement>,
#[arg(long)]
pub paths: bool,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct TaskPathArgs {
pub selector: String,
#[arg(long)]
pub relative: bool,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct TaskShowArgs {
pub selector: String,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct TaskUpgradeArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct TaskRenameArgs {
pub new_slug: String,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct ArchiveArgs {
pub paths: Vec<String>,
#[arg(long, default_value = "{year}/{month}")]
pub layout: String,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, conflicts_with = "layout")]
pub cancel: bool,
}
#[derive(Debug, Args)]
pub struct TaskAdoptArgs {
pub path: String,
#[arg(long)]
pub pull_request: Option<u64>,
#[arg(long)]
pub title: String,
#[arg(long)]
pub purpose: String,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct TaskCreateArgs {
pub slug: String,
#[arg(long)]
pub title: String,
#[arg(long)]
pub purpose: String,
#[arg(long, value_enum, default_value = "deliverable")]
pub kind: TaskKind,
#[arg(long = "scope")]
pub scopes: Vec<String>,
#[arg(long, requires = "scopes")]
pub scope_note: Option<String>,
#[arg(long, hide = true)]
pub timestamp: Option<String>,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct TaskStatusArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct TaskDiscardArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long, conflicts_with = "confirm")]
pub dry_run: bool,
#[arg(long, value_name = "TASK_ID", conflicts_with = "dry_run")]
pub confirm: Option<String>,
}
#[derive(Debug, Args)]
pub struct TaskApproveCloudUsageArgs {
#[arg(long, value_parser = crate::cloud_usage::parse_size)]
pub limit: u64,
#[arg(long)]
pub note: String,
#[arg(long)]
pub scope_note: Option<String>,
#[arg(long)]
pub allow_non_shared_head: bool,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Clone, Args)]
pub struct PlanArgs {
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long = "include")]
pub include: Vec<String>,
#[arg(long)]
pub scope_note: Option<String>,
#[arg(long)]
pub allow_non_shared_head: bool,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Clone, Args)]
pub struct PublishCommandArgs {
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(short = 'm', long)]
pub message: String,
#[arg(long = "include")]
pub include: Vec<String>,
#[arg(long)]
pub scope_note: Option<String>,
#[arg(long)]
pub allow_non_shared_head: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct StorageArgs {
#[command(subcommand)]
pub command: StorageCommand,
}
#[derive(Debug, Subcommand)]
pub enum StorageCommand {
#[command(long_about = crate::command_guidance::command("storage status"))]
Status(StorageStatusArgs),
#[command(long_about = crate::command_guidance::command("storage set"))]
Set(StorageSetArgs),
#[command(long_about = crate::command_guidance::command("storage reset"))]
Reset(StorageResetArgs),
#[command(long_about = crate::command_guidance::command("storage hydrate"))]
Hydrate(StorageHydrateArgs),
}
#[derive(Debug, Clone, Args)]
pub struct ScopedArgs {
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(long = "include")]
pub include: Vec<String>,
#[arg(long)]
pub scope_note: Option<String>,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
}
#[derive(Debug, Args)]
pub struct StorageStatusArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
pub paths: Vec<String>,
}
#[derive(Debug, Args)]
pub struct StorageSetArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
#[arg(required = true)]
pub paths: Vec<String>,
#[arg(long, value_enum)]
pub to: StorageTarget,
#[arg(long)]
pub reason: String,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct StorageResetArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
#[arg(required = true)]
pub paths: Vec<String>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct StorageHydrateArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
pub paths: Vec<String>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct MoveArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
pub old_path: String,
pub new_path: String,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct RemoveArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
#[arg(required = true)]
pub paths: Vec<String>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct UntrackArgs {
#[command(flatten)]
pub scoped: ScopedArgs,
#[arg(required = true)]
pub paths: Vec<String>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct RefreshArgs {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub dry_run: bool,
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::{Cli, Command, TaskArgs, TaskCommand};
#[test]
fn documented_command_shapes_are_accepted_by_clap() {
let examples: &[&[&str]] = &[
&["setup", "--dry-run"],
&[
"setup",
"--runtime-dir",
"/tmp/workspace-mgr-runtime",
"--dry-run",
],
&["manage"],
&[
"manage",
"--s3-url",
"s3://example-bucket/workspace",
"--s3-endpoint-url",
"https://s3.example.invalid",
"--dry-run",
],
&["instructions"],
&["instructions", "model"],
&["instructions", "storage", "--repo", "/tmp/repository"],
&["--format", "json", "instructions", "publish"],
&["doctor", "--repo", "/tmp/repository"],
&["doctor", "example-task", "--repo", "/tmp/repository"],
&["config", "show", "--repo", "/tmp/repository"],
&[
"task",
"upgrade",
"--manifest",
"/tmp/task/.workspace-mgr-task.toml",
"--dry-run",
],
&[
"task",
"create",
"training-report",
"--title",
"Training report",
"--purpose",
"Produce the final training report",
"--dry-run",
],
&[
"task",
"create",
"shared-policy",
"--kind",
"infrastructure",
"--title",
"Shared policy",
"--purpose",
"Update repository policy",
"--scope",
"AGENTS.md",
"--scope",
".github/workflows/ci.yml",
"--scope-note",
"The user requested this infrastructure change",
],
&["task", "status", "--manifest", "task/manifest.toml"],
&[
"task",
"rename",
"current-topic",
"--manifest",
"task/manifest.toml",
"--dry-run",
],
&[
"task",
"approve-cloud-usage",
"--limit",
"1.5GB",
"--note",
"The user approved 1.5 GB for the training checkpoints",
],
&[
"task",
"approve-cloud-usage",
"--limit",
"3GiB",
"--note",
"The user approved 3 GiB in this chat",
"--manifest",
"task/manifest.toml",
"--dry-run",
],
&[
"task",
"approve-cloud-usage",
"--limit",
"2000000000",
"--note",
"The user approved 2000000000 bytes",
"--repo",
"/tmp/repository",
],
&[
"task",
"approve-cloud-usage",
"--limit",
"2GiB",
"--note",
"The user approved 2 GiB in this chat",
"--allow-non-shared-head",
"--scope-note",
"The user selected an alternate checkout",
],
&["storage", "status"],
&[
"storage",
"status",
"task/results/model.bin",
"--manifest",
"task/manifest.toml",
],
&[
"storage",
"set",
"task/report.pdf",
"task/summary.txt",
"--to",
"git",
"--reason",
"Review these files directly",
],
&[
"storage",
"set",
"task/data",
"--to",
"s3",
"--reason",
"Retain the dataset",
"--dry-run",
],
&["storage", "reset", "task/data"],
&["storage", "hydrate", "task/data/example.csv"],
&["move", "task/old.bin", "task/new.bin", "--dry-run"],
&["remove", "task/obsolete.bin", "--dry-run"],
&["untrack", "task/local.bin", "--dry-run"],
&[
"plan",
"--include",
"docs/shared.md",
"--scope-note",
"The user requested this shared documentation update",
],
&[
"plan",
"--allow-non-shared-head",
"--scope-note",
"The user selected an alternate checkout",
],
&["publish", "-m", "Publish the training report"],
&[
"publish",
"--message",
"Publish shared documentation",
"--include",
"docs/shared.md",
"--scope-note",
"The user requested this shared documentation update",
"--dry-run",
],
&["refresh", "--dry-run"],
];
for args in examples {
let argv = std::iter::once("workspace-mgr").chain(args.iter().copied());
Cli::try_parse_from(argv).unwrap_or_else(|error| {
panic!("documented command failed to parse: {args:?}\n{error}")
});
}
}
#[test]
fn approval_limits_are_parsed_as_exact_byte_counts() {
let limit = |raw: &str| -> Result<u64, clap::Error> {
let cli = Cli::try_parse_from([
"workspace-mgr",
"task",
"approve-cloud-usage",
"--limit",
raw,
"--note",
"Approved in chat",
])?;
match cli.command {
Command::Task(TaskArgs {
command: TaskCommand::ApproveCloudUsage(args),
}) => Ok(args.limit),
other => panic!("unexpected command {other:?}"),
}
};
assert_eq!(limit("1.5GB").unwrap(), 1_500_000_000);
assert_eq!(limit("1.5 GB").unwrap(), 1_500_000_000);
assert_eq!(limit("3GiB").unwrap(), 3 << 30);
assert_eq!(limit("2000000000").unwrap(), 2_000_000_000);
for invalid in ["1G", "1.5", "-1GB", "20000000 TB"] {
assert!(limit(invalid).is_err(), "{invalid:?} was accepted");
}
assert!(
Cli::try_parse_from([
"workspace-mgr",
"task",
"approve-cloud-usage",
"--limit",
"2GB"
])
.is_err(),
"the user's decision must be recorded"
);
}
#[test]
fn read_only_commands_do_not_expose_meaningless_mutation_flags() {
assert!(Cli::try_parse_from(["workspace-mgr", "plan", "--dry-run"]).is_err());
assert!(Cli::try_parse_from(["workspace-mgr", "plan", "-m", "unused"]).is_err());
assert!(Cli::try_parse_from(["workspace-mgr", "storage", "status", "--dry-run"]).is_err());
}
}