use std::collections::{HashMap, HashSet};
use std::path::Path;
use chrono::Utc;
use owo_colors::OwoColorize;
use owo_colors::Stream::Stdout;
use owo_colors::Style;
use bears::error::{Error, Result};
use bears::graph::{DepNode, DepNodeJson};
use bears::scaffold;
use bears::service;
use bears::store;
use bears::task::{self, Priority, Status, Task, TaskType};
use super::{color_id, color_priority, color_status, color_tags, format_priority, output};
fn parse_yes(line: &str) -> bool {
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
fn resolve_overwrite_policy(any_exists: bool) -> Result<scaffold::WritePolicy> {
use std::io::{IsTerminal, Write};
if !any_exists {
return Ok(scaffold::WritePolicy::Force);
}
if !std::io::stdin().is_terminal() {
return Ok(scaffold::WritePolicy::SkipExisting);
}
eprint!(
"This directory already contains agent instructions and/or skills. \
Overwrite them? (y/N) "
);
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(if parse_yes(&line) {
scaffold::WritePolicy::Force
} else {
scaffold::WritePolicy::SkipExisting
})
}
fn collect_labels(claude: bool, copilot: bool, codex: bool) -> Vec<&'static str> {
let mut labels: Vec<&str> = Vec::new();
if claude {
labels.push("claude");
}
if copilot {
labels.push("copilot");
}
if codex {
labels.push("codex");
}
labels
}
pub fn cmd_init(
base: &Path,
claude: bool,
copilot: bool,
codex: bool,
force: bool,
json: bool,
) -> Result<()> {
let dir = store::init(base)?;
let labels = collect_labels(claude, copilot, codex);
let policy = if force {
scaffold::WritePolicy::Force
} else {
let any_exists = scaffold::category_targets(base, &labels, scaffold::Category::All)
.iter()
.any(|p| p.exists());
resolve_overwrite_policy(any_exists)?
};
let scaffolded = scaffold::scaffold_category(base, &labels, scaffold::Category::All, policy)?;
if json {
let paths: Vec<String> = std::iter::once(dir.display().to_string())
.chain(scaffolded.iter().map(|p| p.display().to_string()))
.collect();
output(&serde_json::json!({
"path": dir.display().to_string(),
"scaffolded": paths[1..],
}))?;
} else {
println!("Initialized bears in {}", dir.display());
for p in &scaffolded {
println!(" wrote {}", p.display());
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_agent_init(
base: &Path,
category: crate::cli::AgentCategory,
claude: bool,
copilot: bool,
codex: bool,
force: bool,
append: bool,
json: bool,
) -> Result<()> {
use crate::cli::AgentCategory;
if append && category == AgentCategory::Skills {
return Err(Error::InvalidUsage(
"--append is only valid for `instructions` or `all` (there is no instruction file to append to for `skills`)"
.to_string(),
));
}
let labels = collect_labels(claude, copilot, codex);
if labels.is_empty() {
return Err(Error::InvalidUsage(
"select at least one harness: --claude, --copilot, or --codex".to_string(),
));
}
let cat: scaffold::Category = category.into();
let policy = if force {
scaffold::WritePolicy::Force
} else if append {
scaffold::WritePolicy::Append
} else {
let any_exists = scaffold::category_targets(base, &labels, cat)
.iter()
.any(|p| p.exists());
resolve_overwrite_policy(any_exists)?
};
let scaffolded = scaffold::scaffold_category(base, &labels, cat, policy)?;
if json {
let paths: Vec<String> = scaffolded.iter().map(|p| p.display().to_string()).collect();
output(&serde_json::json!({ "scaffolded": paths }))?;
} else if scaffolded.is_empty() {
println!("Nothing to do (target files already exist; use --force to overwrite).");
} else {
for p in &scaffolded {
println!(" wrote {}", p.display());
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_create(
base: &Path,
tasks: &HashMap<String, Task>,
title: String,
priority: Priority,
tags: Vec<String>,
depends_on: Vec<String>,
parent: Option<String>,
body: Option<String>,
epic: bool,
json: bool,
) -> Result<()> {
let task_type = if epic { TaskType::Epic } else { TaskType::Task };
let t = service::create_task(
base,
tasks,
title,
priority,
tags,
depends_on,
parent,
body.unwrap_or_default(),
task_type,
)?;
if json {
output(&t.summary(None))?;
} else {
println!("Created task {} — {}", t.id, t.title);
}
Ok(())
}
pub fn cmd_list(
tasks: &HashMap<String, Task>,
status: Option<Status>,
priority: Option<Priority>,
tag: Option<String>,
epic: Option<String>,
all: bool,
json: bool,
) -> Result<()> {
let filtered = service::list_tasks(
tasks,
status,
priority,
tag.as_deref(),
all,
epic.as_deref(),
);
let eff = service::effective_priorities(tasks);
if json {
let summaries: Vec<_> = filtered.iter().map(|t| t.summary(eff.get(&t.id))).collect();
output(&summaries)?;
} else {
if filtered.is_empty() {
println!("No tasks found.");
} else {
for t in &filtered {
if t.task_type.is_epic() {
let p = service::epic_progress(tasks, &t.id);
println!(
"[{}] {} {} {} {} [{}/{}] [{}]",
color_id(&t.id),
format_priority(&t.priority, eff.get(&t.id)),
color_status(&t.status),
"Epic:".if_supports_color(Stdout, |t| t.bright_magenta()),
t.title,
p.done,
p.total,
color_tags(&t.tags),
);
} else {
println!(
"[{}] {} {} — {} [{}]",
color_id(&t.id),
format_priority(&t.priority, eff.get(&t.id)),
color_status(&t.status),
t.title,
color_tags(&t.tags)
);
}
}
}
}
Ok(())
}
pub fn cmd_ready(
tasks: &HashMap<String, Task>,
tag: Option<String>,
epic: Option<String>,
limit: Option<usize>,
json: bool,
) -> Result<()> {
let ready = service::list_ready(tasks, tag.as_deref(), limit, epic.as_deref());
let eff = service::effective_priorities(tasks);
if json {
let summaries: Vec<_> = ready.iter().map(|t| t.summary(eff.get(&t.id))).collect();
output(&summaries)?;
} else {
if ready.is_empty() {
println!("No tasks ready.");
} else {
for t in &ready {
println!(
"[{}] {} — {} [{}]",
color_id(&t.id),
format_priority(&t.priority, eff.get(&t.id)),
t.title,
color_tags(&t.tags)
);
}
}
}
Ok(())
}
pub fn cmd_epics(tasks: &HashMap<String, Task>, json: bool) -> Result<()> {
let mut epics: Vec<&Task> = tasks.values().filter(|t| t.task_type.is_epic()).collect();
epics.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.created.cmp(&b.created)));
if json {
let out: Vec<_> = epics
.iter()
.map(|t| t.epic_summary(service::epic_progress(tasks, &t.id)))
.collect();
output(&out)?;
} else if epics.is_empty() {
println!("No epics found.");
} else {
for t in &epics {
let p = service::epic_progress(tasks, &t.id);
let prefix = "Epic:"
.if_supports_color(Stdout, |t| t.bright_magenta())
.to_string();
println!(
"[{}] {} {} {} {} [{}/{}] [{}]",
color_id(&t.id),
color_priority(&t.priority),
color_status(&t.status),
prefix,
t.title,
p.done,
p.total,
color_tags(&t.tags),
);
}
}
Ok(())
}
pub fn cmd_show_archived(task: &Task, _plan: bool, json: bool) -> Result<()> {
if json {
let detail = task.detail(None);
let mut json_val = serde_json::to_value(&detail)?;
if let Some(obj) = json_val.as_object_mut() {
obj.insert("archived".to_string(), serde_json::Value::Bool(true));
}
output(&json_val)?;
} else {
println!(
"[{}] {} {}",
color_id(&task.id),
task.title.if_supports_color(Stdout, |t| t.bold()),
"(archived)".if_supports_color(Stdout, |t| t.dimmed()),
);
println!(
"{} {}",
"Status: ".if_supports_color(Stdout, |t| t.bold()),
color_status(&task.status)
);
println!(
"{} {}",
"Priority:".if_supports_color(Stdout, |t| t.bold()),
color_priority(&task.priority)
);
println!(
"{} {}",
"Tags: ".if_supports_color(Stdout, |t| t.bold()),
if task.tags.is_empty() {
"—".to_string()
} else {
color_tags(&task.tags)
}
);
if !task.depends_on.is_empty() {
println!(
"{} {}",
"Deps: ".if_supports_color(Stdout, |t| t.bold()),
task.depends_on
.iter()
.map(|d| color_id(d))
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(ref parent) = task.parent {
println!(
"{} {}",
"Parent: ".if_supports_color(Stdout, |t| t.bold()),
color_id(parent)
);
}
if !task.assignee.is_empty() {
println!(
"{} {}",
"Assignee:".if_supports_color(Stdout, |t| t.bold()),
task.assignee
);
}
println!(
"{} {}",
"Created: ".if_supports_color(Stdout, |t| t.bold()),
task.created
);
println!(
"{} {}",
"Updated: ".if_supports_color(Stdout, |t| t.bold()),
task.updated
);
if !task.body.is_empty() {
println!("\n{}", task.body);
}
}
Ok(())
}
pub fn cmd_show(tasks: &HashMap<String, Task>, id: &str, plan: bool, json: bool) -> Result<()> {
if plan {
return cmd_show_plan(tasks, id, json);
}
let t = service::get_task(tasks, id)?;
let eff = service::effective_priorities(tasks);
let ep = eff.get(&t.id);
if json {
output(&t.detail(ep))?;
} else {
if t.task_type.is_epic() {
let p = service::epic_progress(tasks, &t.id);
println!(
"[{}] {} {} [{}/{}]",
color_id(&t.id),
"Epic:".if_supports_color(Stdout, |t| t.bright_magenta()),
t.title.if_supports_color(Stdout, |t| t.bold()),
p.done,
p.total,
);
} else {
println!(
"[{}] {}",
color_id(&t.id),
t.title.if_supports_color(Stdout, |t| t.bold())
);
}
println!(
"{} {}",
"Status: ".if_supports_color(Stdout, |t| t.bold()),
color_status(&t.status)
);
println!(
"{} {}",
"Priority:".if_supports_color(Stdout, |t| t.bold()),
format_priority(&t.priority, ep)
);
println!(
"{} {}",
"Tags: ".if_supports_color(Stdout, |t| t.bold()),
if t.tags.is_empty() {
"—".to_string()
} else {
color_tags(&t.tags)
}
);
if !t.depends_on.is_empty() {
println!(
"{} {}",
"Deps: ".if_supports_color(Stdout, |t| t.bold()),
t.depends_on
.iter()
.map(|d| color_id(d))
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(ref parent) = t.parent {
println!(
"{} {}",
"Parent: ".if_supports_color(Stdout, |t| t.bold()),
color_id(parent)
);
}
if !t.assignee.is_empty() {
println!(
"{} {}",
"Assignee:".if_supports_color(Stdout, |t| t.bold()),
t.assignee
);
}
println!(
"{} {}",
"Created: ".if_supports_color(Stdout, |t| t.bold()),
t.created
);
println!(
"{} {}",
"Updated: ".if_supports_color(Stdout, |t| t.bold()),
t.updated
);
if !t.body.is_empty() {
println!("\n{}", t.body);
}
}
Ok(())
}
fn cmd_show_plan(tasks: &HashMap<String, Task>, id: &str, json: bool) -> Result<()> {
let plan = service::plan_epic(tasks, id)?;
if !plan.cyclic.is_empty() {
let ids: Vec<&str> = plan.cyclic.iter().map(|t| t.id.as_str()).collect();
eprintln!(
"warning: {} task(s) in a dependency cycle, appended unordered: {}",
ids.len(),
ids.join(", ")
);
}
let all: Vec<&Task> = plan
.tasks
.iter()
.chain(plan.cyclic.iter())
.copied()
.collect();
if json {
let eff = service::effective_priorities(tasks);
let details: Vec<_> = all.iter().map(|t| t.detail(eff.get(&t.id))).collect();
output(&details)?;
} else {
for (i, t) in all.iter().enumerate() {
print!("# {}. {}\n\n", i + 1, t.title);
if !t.body.is_empty() {
print!("{}\n\n", t.body);
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_update(
base: &Path,
tasks: &HashMap<String, Task>,
id: &str,
status: Option<Status>,
priority: Option<Priority>,
tags: Option<Vec<String>>,
assignee: Option<String>,
body: Option<String>,
title: Option<String>,
parent: Option<String>,
json: bool,
) -> Result<()> {
let parent_update: Option<Option<String>> =
parent.map(|p| if p.is_empty() { None } else { Some(p) });
let t = service::update_task(
base,
tasks,
id,
status,
priority,
tags,
assignee,
body,
title,
parent_update,
)?;
if json {
output(&t.summary(None))?;
} else {
println!("Updated task {} — {}", t.id, t.title);
}
Ok(())
}
pub fn cmd_status(
base: &Path,
tasks: &HashMap<String, Task>,
id: &str,
status: Status,
json: bool,
) -> Result<()> {
let t = service::set_status(base, tasks, id, status)?;
if json {
output(&t.summary(None))?;
} else {
println!(
"[{}] {} → {}",
color_id(&t.id),
t.title,
color_status(&t.status)
);
}
Ok(())
}
pub fn cmd_dep_add(
base: &Path,
tasks: &HashMap<String, Task>,
id: &str,
depends_on: &str,
json: bool,
) -> Result<()> {
let resolved_dep = store::resolve_prefix(tasks, depends_on)?;
let t = service::add_dependency(base, tasks, id, depends_on)?;
if json {
output(&t.summary(None))?;
} else {
println!("[{}] now depends on [{}]", t.id, resolved_dep);
}
Ok(())
}
pub fn cmd_dep_remove(
base: &Path,
tasks: &HashMap<String, Task>,
id: &str,
depends_on: &str,
json: bool,
) -> Result<()> {
let resolved_dep = store::resolve_prefix(tasks, depends_on)?;
let t = service::remove_dependency(base, tasks, id, depends_on)?;
if json {
output(&t.summary(None))?;
} else {
println!("[{}] no longer depends on [{}]", t.id, resolved_dep);
}
Ok(())
}
pub fn cmd_dep_tree(tasks: &HashMap<String, Task>, id: &str, json: bool) -> Result<()> {
let graph = service::build_graph(tasks);
let tree = graph
.dep_tree(tasks, id)
.ok_or_else(|| Error::TaskNotFound(id.into()))?;
if json {
let json_tree = DepNodeJson::from_dep_node(&tree);
output(&json_tree)?;
} else {
let eff = graph.effective_priorities_all(tasks);
println!(
"Dependency tree for {}:\n",
tree.task.title.if_supports_color(Stdout, |t| t.bold())
);
print_tree(&tree, "", true, tasks, &eff, true);
}
Ok(())
}
fn print_tree(
node: &DepNode<'_>,
prefix: &str,
is_last: bool,
tasks: &HashMap<String, Task>,
eff: &HashMap<String, Priority>,
all: bool,
) {
let connector = if prefix.is_empty() {
""
} else if is_last {
"└── "
} else {
"├── "
};
let t = node.task;
let blocked = matches!(t.status, Status::Open | Status::InProgress)
&& t.depends_on.iter().any(|dep_id| {
tasks
.get(dep_id)
.is_some_and(|dep| dep.status != Status::Done)
});
let cycle_suffix = if node.cycle {
let style = Style::new().bold().yellow();
format!(
" {}",
"[CYCLE]".if_supports_color(Stdout, |s| s.style(style))
)
} else if node.seen {
let style = Style::new().dimmed();
format!(
" {}",
"(see above)".if_supports_color(Stdout, |s| s.style(style))
)
} else {
String::new()
};
let title = if blocked {
t.title
.if_supports_color(Stdout, |s| s.dimmed())
.to_string()
} else {
t.title.clone()
};
let epic_prefix = if t.task_type.is_epic() {
format!(
"{} ",
"Epic:".if_supports_color(Stdout, |t| t.bright_magenta())
)
} else {
String::new()
};
println!(
"{prefix}{connector}[{}] {epic_prefix}{title} [{}] ({}){cycle_suffix}",
color_id(&t.id),
format_priority(&t.priority, eff.get(&t.id)),
color_status(&t.status),
);
let child_prefix = if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
let visible_children: Vec<_> = node
.children
.iter()
.filter(|c| all || !matches!(c.task.status, Status::Done | Status::Cancelled))
.collect();
for (i, child) in visible_children.iter().enumerate() {
print_tree(
child,
&child_prefix,
i == visible_children.len() - 1,
tasks,
eff,
all,
);
}
}
pub fn cmd_graph(tasks: &HashMap<String, Task>, all: bool, json: bool) -> Result<()> {
let graph = service::build_graph(tasks);
if json {
let adj = graph.bounded_adjacency_list(tasks, all, None, None);
output(&adj)?;
return Ok(());
}
if tasks.is_empty() {
println!("No tasks.");
return Ok(());
}
let eff = graph.effective_priorities_all(tasks);
let all_deps: HashSet<&str> = tasks
.values()
.flat_map(|t| t.depends_on.iter().map(String::as_str))
.collect();
let mut roots: Vec<&Task> = tasks
.values()
.filter(|t| !all_deps.contains(t.id.as_str()))
.filter(|t| all || !matches!(t.status, Status::Done | Status::Cancelled))
.collect();
roots.sort_by(|a, b| {
eff.get(&a.id)
.cmp(&eff.get(&b.id))
.then(a.created.cmp(&b.created))
});
if roots.is_empty() {
println!("No active tasks.");
return Ok(());
}
println!("Dependency graph\n");
for root in &roots {
if let Some(tree) = graph.dep_tree(tasks, &root.id) {
print_tree(&tree, "", true, tasks, &eff, all);
}
}
Ok(())
}
pub fn cmd_prune(
base: &Path,
tasks: &HashMap<String, Task>,
include_done: bool,
json: bool,
) -> Result<()> {
let deleted = service::prune_tasks(base, tasks, include_done)?;
let summaries: Vec<_> = deleted.iter().map(|t| t.summary(None)).collect();
if json {
output(&summaries)?;
} else {
if deleted.is_empty() {
println!("No tasks to prune.");
} else {
for s in &summaries {
println!("Pruned {} — {}", s.id, s.title);
}
}
}
Ok(())
}
pub fn cmd_delete(base: &Path, tasks: &HashMap<String, Task>, id: &str, json: bool) -> Result<()> {
let t = service::delete_task(base, tasks, id)?;
if json {
output(&t.summary(None))?;
} else {
println!("Deleted task {} — {}", t.id, t.title);
}
Ok(())
}
pub fn cmd_search(tasks: &HashMap<String, Task>, query: &str, all: bool, json: bool) -> Result<()> {
let results = service::search_tasks(tasks, query, all);
if json {
let summaries: Vec<_> = results.iter().map(|t| t.summary(None)).collect();
output(&summaries)?;
} else {
if results.is_empty() {
println!("No tasks matching \"{query}\".");
} else {
for t in &results {
println!(
"[{}] {} {} — {} [{}]",
color_id(&t.id),
color_priority(&t.priority),
color_status(&t.status),
t.title,
color_tags(&t.tags)
);
}
}
}
Ok(())
}
pub async fn cmd_archive(
base: &Path,
tasks: &HashMap<String, Task>,
id: Option<&str>,
json: bool,
) -> Result<()> {
let archived_ids: Vec<String> = match id {
Some(id_or_prefix) => service::archive_task(base, tasks, id_or_prefix)?,
None => service::archive_all(base, tasks)?,
};
if json {
output(&serde_json::json!({ "archived": archived_ids }))?;
} else if archived_ids.is_empty() {
println!("Nothing to archive.");
} else {
for id in &archived_ids {
println!("Archived {id}");
}
}
Ok(())
}
pub async fn cmd_restore(base: &Path, id: &str, json: bool) -> Result<()> {
let restored_ids = service::restore_task(base, id).await?;
if json {
output(&serde_json::json!({ "restored": restored_ids }))?;
} else {
for id in &restored_ids {
println!("Restored {id}");
}
}
Ok(())
}
pub async fn cmd_list_archived(base: &Path, json: bool) -> Result<()> {
let archived = service::list_archive(base, None).await?;
if json {
let summaries: Vec<_> = archived.iter().map(|t| t.summary(None)).collect();
output(&summaries)?;
} else if archived.is_empty() {
println!("No archived tasks.");
} else {
for t in &archived {
println!(
"[{}] {} {} — {} [{}]",
color_id(&t.id),
color_priority(&t.priority),
color_status(&t.status),
t.title,
color_tags(&t.tags)
);
}
}
Ok(())
}
pub async fn cmd_log(base: &Path, limit: Option<usize>, json: bool) -> Result<()> {
let archived = service::list_archive(base, limit).await?;
if json {
let summaries: Vec<_> = archived.iter().map(|t| t.summary(None)).collect();
output(&summaries)?;
} else if archived.is_empty() {
println!("No archived tasks.");
} else {
for t in &archived {
println!(
"[{}] {} {} — {} [{}]",
color_id(&t.id),
color_priority(&t.priority),
color_status(&t.status),
t.title,
color_tags(&t.tags)
);
}
}
Ok(())
}
pub fn cmd_edit(base: &Path, tasks: &HashMap<String, Task>, id: &str, json: bool) -> Result<()> {
let original = service::get_task(tasks, id)?;
let original_id = original.id.clone();
let path = store::find_task_path(base, &original_id)?;
crate::editor::open_in_editor(&path)?;
let content = std::fs::read_to_string(&path).map_err(Error::Io)?;
let mut edited = match task::parse_task(&content) {
Ok(t) => t,
Err(e) => {
let reason = match e {
Error::InvalidFrontmatter { reason, .. } => reason,
other => other.to_string(),
};
return Err(Error::InvalidFrontmatter {
path: path.clone(),
reason: format!("{reason} (file left on disk — fix and retry)"),
});
}
};
let id_changed = edited.id != original_id;
if id_changed {
eprintln!("warning: task ID cannot be changed via edit; keeping {original_id}");
edited.id = original_id.clone();
}
if !id_changed && task::render_task(&original) == task::render_task(&edited) {
if !json {
println!("No changes.");
}
return Ok(());
}
edited.updated = Utc::now();
store::save(base, &edited)?;
if json {
output(&edited.summary(None))?;
} else {
println!("Edited task {} — {}", edited.id, edited.title);
}
Ok(())
}