mod claude;
mod completions;
mod entries;
mod hook;
mod init;
mod update;
pub use entries::{AddArgs, EntryArgs, EntryCommand, ProjectPart, Text};
pub use init::init;
use crate::api::json::Summary;
use crate::brain::{Brain, Group, ReportView};
use crate::model::{Asked, Author, Kind, Status, Task, fmt_clock, now, parse_tags};
use crate::period::{Period, Unit};
use crate::store::{self, Store};
use crate::{export, platform, projects};
use anyhow::{Context, Result, anyhow, bail};
use std::fmt::Write;
use std::io::{IsTerminal, Read};
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct Program {
pub name: &'static str,
}
impl Program {
pub fn main(self, args: impl IntoIterator<Item = String>) -> Result<()> {
let parsed =
take_project(args.into_iter().collect()).and_then(|(project, args)| Ok((project, Command::parse(args)?)));
let (project, command) = parsed.map_err(|e| anyhow!("{e:#}\n\n{}", usage(self)))?;
command.run(self, project.as_deref())
}
}
fn take_project(mut args: Vec<String>) -> Result<(Option<String>, Vec<String>)> {
let Some(at) = args.iter().position(|a| a == "--project") else { return Ok((None, args)) };
if at + 1 >= args.len() {
bail!("--project needs a project name (or `{}`)", projects::CENTRAL);
}
let name = args.remove(at + 1);
args.remove(at);
Ok((Some(name), args))
}
pub fn usage(program: Program) -> String {
let name = program.name;
let pad = " ".repeat(name.len());
format!(
"{name} — project memory for you and your coding agents: tasks, notes and decisions in markdown files
usage:
{name} list [{statuses}|all] [notes] [decisions] [--tag TAG] [--json]
{pad} print tasks as markdown, one section per status, then notes and decisions
{pad} (default: {default}; several can be given; notes or decisions alone
{pad} list only those)
{name} init [NAME]
{pad} make this folder a project: its own .brain/, registered as NAME (default: the
{pad} folder's name) with a card for the application, and a section telling its agents
{pad} about it in CLAUDE.md / AGENTS.md (run again to update them)
{name} init --all
{pad} update the agents' section and hooks in every registered project to this version's
{pad} (the installer and update run it)
{name} deinit stop this folder (or --project NAME) being a project: unregister it, remove its
{pad} section from CLAUDE.md / AGENTS.md and, with its last repository, the project's
{pad} card; its .brain/ is kept
{name} hook prompt|stop
{pad} what the Claude Code hooks `init` adds run: they hold an agent to describing an
{pad} undescribed project first, and to commenting on the tasks it started (they read the
{pad} hook's JSON on stdin)
{name} status print the running tasks, in whichever project, and how many wait for an answer
{pad} (for status bars)
{name} report [{units}] [--group {groups}] [--tag TAG] [--save]
{pad} print the current period's tracked time as markdown
{pad} (default: {unit}, grouped by {group}); --save also writes it to reports/
{entries}
{name} path print the data directory in use (a project's .brain/ inside it)
{name} update install the latest release over this one (and the other app, if it's beside it)
{name} completions bash|zsh|fish
{pad} print a tab completion script for the shell (commands, ids, projects, tags)
{name} --version print the version
--project NAME, anywhere on the line, runs a command on a registered project's brain (or `central`).
Data lives in the nearest .brain/ folder here or above (see init); else in the central brain:
$KANBRAIN_DIR, else the platform data dir (e.g. ~/.local/share/kanbrain).
The kanban board, `kanbrain` without arguments, shows the same data.
kanbrain by singudotdev (https://github.com/singudotdev/kanbrain). AGPL-3.0-only with the additional terms in
NOTICE; no warranty.",
statuses = Status::names("|"),
default = DEFAULT_LIST.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
units = Unit::names("|"),
groups = Group::names("|"),
unit = DEFAULT_REPORT_UNIT.as_str(),
group = Group::default().as_str(),
entries = entries::USAGE
.lines()
.map(|line| {
let rest = line.strip_prefix(" ").unwrap_or(line);
format!(" {} {rest}", if rest.starts_with(' ') { &pad } else { name })
})
.collect::<Vec<_>>()
.join("\n"),
)
}
const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
List(ListArgs),
Init(Option<String>),
InitAll,
Deinit,
Hook(hook::HookEvent),
Status,
Report(ReportArgs),
Entries(EntryArgs),
Path,
Update,
Completions(completions::Shell),
Complete(completions::Candidates),
Version,
Help,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ListArgs {
pub statuses: Vec<Status>,
pub kinds: Vec<Kind>,
pub tag: Option<String>,
pub json: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ReportArgs {
pub unit: Unit,
pub group: Group,
pub tag: Option<String>,
pub save: bool,
}
impl Command {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
let mut args = args.into_iter();
let command = match args.next().as_deref() {
Some("list") => Command::List(ListArgs::parse(args.by_ref())?),
Some("init") => match args.next() {
Some(all) if all == "--all" => Command::InitAll,
name => Command::Init(name),
},
Some("deinit") => Command::Deinit,
Some("hook") => {
let name = args.next().context("hook needs prompt or stop")?;
Command::Hook(
hook::HookEvent::parse(&name).with_context(|| format!("unknown hook `{name}`: prompt or stop"))?,
)
}
Some("status") => Command::Status,
Some("report") => Command::Report(ReportArgs::parse(args.by_ref())?),
Some("path") => Command::Path,
Some("update") => Command::Update,
Some("completions") => {
let name = args.next().context("completions needs bash, zsh or fish")?;
Command::Completions(
completions::Shell::parse(&name)
.with_context(|| format!("unknown shell `{name}`: bash, zsh or fish"))?,
)
}
Some("complete") => {
let name = args.next().context("complete needs ids, projects or tags")?;
let what = completions::Candidates::parse(&name)
.with_context(|| format!("unknown candidates `{name}`: ids, projects or tags"))?;
args.by_ref().for_each(drop);
Command::Complete(what)
}
Some("-V" | "--version") => Command::Version,
None | Some("-h" | "--help") => Command::Help,
Some(other) => match EntryArgs::parse(other, args.by_ref())? {
Some(entries) => Command::Entries(entries),
None => bail!("unknown command `{other}`"),
},
};
if let Some(extra) = args.next() {
bail!("unexpected argument `{extra}`");
}
Ok(command)
}
pub fn run(self, program: Program, project: Option<&str>) -> Result<()> {
match self {
Command::Version => {
println!("{} {}", program.name, env!("CARGO_PKG_VERSION"));
return Ok(());
}
Command::Help => {
println!("{}", usage(program));
return Ok(());
}
Command::Completions(shell) => {
print!("{}", completions::script(shell, program.name));
return Ok(());
}
Command::Update => {
platform::ensure_not_root()?;
return update::update();
}
Command::Init(name) => {
platform::ensure_not_root()?;
if project.is_some() {
bail!("init works on the current directory; leave out --project");
}
let root = std::env::current_dir().context("reading the current directory")?;
init::init(&root, name.as_deref(), &projects::central_dir()?)?
.iter()
.for_each(|line| println!("{line}"));
return Ok(());
}
Command::InitAll => {
platform::ensure_not_root()?;
init::init_all(&projects::central_dir()?)?.iter().for_each(|line| println!("{line}"));
return Ok(());
}
Command::Hook(event) => {
let mut input = String::new();
if !std::io::stdin().is_terminal() {
std::io::stdin().read_to_string(&mut input).context("reading the hook's input")?;
}
if let Some(out) = hook::respond(event, &input, &projects::central_dir()?) {
println!("{out}");
}
return Ok(());
}
Command::Deinit => {
platform::ensure_not_root()?;
let central = projects::central_dir()?;
let dir = match project {
Some(projects::CENTRAL) => bail!("the central brain isn't a project: there's nothing to deinit"),
Some(label) => projects::find(¢ral, label)?.dir,
None => {
let here = std::env::current_dir().context("reading the current directory")?;
store::find_project(&here).context(
"no .brain/ here or above: run deinit inside a project, or name one with --project",
)?
}
};
init::deinit(&dir, ¢ral)?.iter().for_each(|line| println!("{line}"));
return Ok(());
}
_ => platform::ensure_not_root()?,
}
let store = Store::open_named(project)?;
let central = projects::central_dir()?;
match self {
Command::List(args) => {
let (tasks, _) = store.load()?;
if args.json {
println!("{}", list_json(&tasks, &args)?);
} else {
print!("{}", list_markdown(&tasks, &args));
}
Ok(())
}
Command::Status => print_status(¢ral),
Command::Entries(args) => {
let author = author()?;
let _lock = (!matches!(args.command, EntryCommand::Wait(..))).then(|| store.lock()).transpose()?;
args.run(Brain { author, ..Brain::open_in(store, ¢ral)? })
}
Command::Report(args) => print_report(store, &args),
Command::Path => {
println!("{}", store.dir().display());
Ok(())
}
Command::Complete(what) => {
completions::candidates(what, store, ¢ral)?.iter().for_each(|line| println!("{line}"));
Ok(())
}
Command::Version
| Command::Help
| Command::Completions(_)
| Command::Update
| Command::Init(_)
| Command::InitAll
| Command::Deinit
| Command::Hook(_) => Ok(()),
}
}
}
impl ListArgs {
fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
let mut statuses = Vec::new();
let mut kinds = Vec::new();
let mut tag = None;
let mut json = false;
while let Some(arg) = args.next() {
match arg.as_str() {
"--json" => json = true,
"notes" | "note" => kinds.push(Kind::Note),
"decisions" | "decision" => kinds.push(Kind::Decision),
"--tag" => {
let value = args.next().context("--tag needs a value")?;
tag = parse_tags(&value).into_iter().next();
}
"all" => statuses.extend(Status::ALL),
other => match Status::parse(other) {
Some(status) => statuses.push(status),
None => bail!("unknown list option `{other}`"),
},
}
}
if statuses.is_empty() && kinds.is_empty() {
statuses.extend(DEFAULT_LIST);
}
statuses.sort_by_key(|s| s.position());
statuses.dedup();
kinds.sort_by_key(|k| k.position());
kinds.dedup();
Ok(Self { statuses, kinds, tag, json })
}
}
impl ReportArgs {
fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
while let Some(arg) = args.next() {
match arg.as_str() {
"--group" => {
let groups = Group::names(" or ");
let value = args.next().with_context(|| format!("--group needs {groups}"))?;
report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
}
"--tag" => {
let value = args.next().context("--tag needs a value")?;
report.tag = parse_tags(&value).into_iter().next();
}
"--save" => report.save = true,
other => match Unit::parse(other) {
Some(unit) => report.unit = unit,
None => bail!("unknown report option `{other}`"),
},
}
}
Ok(report)
}
}
const AGENT_VARIABLES: &[&str] =
&["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
fn author() -> Result<Author> {
if let Some(value) = std::env::var_os("KANBRAIN_AUTHOR") {
let value = value.to_string_lossy();
return Author::parse(&value)
.with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
}
let agent = AGENT_VARIABLES.iter().any(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty()));
Ok(if agent { Author::Agent } else { Author::Human })
}
fn print_status(central: &Path) -> Result<()> {
let now = now();
let mut brains = vec![(None, central.to_path_buf())];
let repos = projects::load(central)?;
brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
let mut running = Vec::new();
let mut waiting = 0;
for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
let (tasks, _) = Store::at(&dir)?.load()?;
let project = name.map(|n| format!("{n} ")).unwrap_or_default();
for (task, clock) in tasks.iter().filter_map(|t| Some((t, t.running_for(now)?))) {
running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
}
waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
}
if running.is_empty() {
running.push("idle".to_string());
}
if waiting > 0 {
running.push(format!("{waiting} waiting"));
}
println!("{}", running.join(" · "));
Ok(())
}
fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
let brain = Brain::open(store)?;
let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
if args.save {
eprintln!("saved {}", brain.export_report(&view)?.display());
}
Ok(())
}
fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
let mut sections = Vec::new();
for &status in &args.statuses {
let mut matching: Vec<&Task> =
tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
sections.push((status.as_str(), matching));
}
for &kind in &args.kinds {
let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
matching.sort_by_key(|t| std::cmp::Reverse(t.id));
sections.push((kind_heading(kind), matching));
}
sections.retain(|(_, entries)| !entries.is_empty());
sections
}
fn kind_heading(kind: Kind) -> &'static str {
match kind {
Kind::Task => "tasks",
Kind::Note => "notes",
Kind::Decision => "decisions",
}
}
fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
let mut out = String::new();
for (heading, entries) in list_sections(tasks, args) {
if !out.is_empty() {
out.push('\n');
}
writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
for task in entries {
write!(out, "- #{} {}", task.id, task.title).expect("writing to a String cannot fail");
for tag in &task.tags {
write!(out, " #{tag}").expect("writing to a String cannot fail");
}
out.push('\n');
}
}
out
}
fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
Ok(serde_json::to_string_pretty(&entries)?)
}
#[cfg(test)]
#[path = "../../tests/cli.rs"]
mod tests;