1mod claude;
12mod completions;
13mod entries;
14mod hook;
15mod init;
16mod update;
17
18pub use entries::{AddArgs, EntryArgs, EntryCommand, ProjectPart, Text};
19pub use init::init;
20
21use crate::api::json::Summary;
22use crate::brain::{Brain, Group, ReportView};
23use crate::model::{Asked, Author, Kind, Status, Task, fmt_clock, now, parse_tags};
24use crate::period::{Period, Unit};
25use crate::store::{self, Store};
26use crate::{export, platform, projects};
27use anyhow::{Context, Result, anyhow, bail};
28use std::fmt::Write;
29use std::io::{IsTerminal, Read};
30use std::path::Path;
31
32#[derive(Debug, Clone, Copy)]
34pub struct Program {
35 pub name: &'static str,
37}
38
39impl Program {
40 pub fn main(self, args: impl IntoIterator<Item = String>) -> Result<()> {
43 let parsed =
44 take_project(args.into_iter().collect()).and_then(|(project, args)| Ok((project, Command::parse(args)?)));
45 let (project, command) = parsed.map_err(|e| anyhow!("{e:#}\n\n{}", usage(self)))?;
46 command.run(self, project.as_deref())
47 }
48}
49
50fn take_project(mut args: Vec<String>) -> Result<(Option<String>, Vec<String>)> {
52 let Some(at) = args.iter().position(|a| a == "--project") else { return Ok((None, args)) };
53 if at + 1 >= args.len() {
54 bail!("--project needs a project name (or `{}`)", projects::CENTRAL);
55 }
56 let name = args.remove(at + 1);
57 args.remove(at);
58 Ok((Some(name), args))
59}
60
61pub fn usage(program: Program) -> String {
64 let name = program.name;
65 let pad = " ".repeat(name.len());
66 format!(
67 "{name} — project memory for you and your coding agents: tasks, notes and decisions in markdown files
68
69usage:
70 {name} list [{statuses}|all] [notes] [decisions] [--tag TAG] [--json]
71 {pad} print tasks as markdown, one section per status, then notes and decisions
72 {pad} (default: {default}; several can be given; notes or decisions alone
73 {pad} list only those)
74 {name} init [NAME]
75 {pad} make this folder a project: its own .brain/, registered as NAME (default: the
76 {pad} folder's name) with a card for the application, and a section telling its agents
77 {pad} about it in CLAUDE.md / AGENTS.md (run again to update them)
78 {name} init --all
79 {pad} update the agents' section and hooks in every registered project to this version's
80 {pad} (the installer and update run it)
81 {name} deinit stop this folder (or --project NAME) being a project: unregister it, remove its
82 {pad} section from CLAUDE.md / AGENTS.md and, with its last repository, the project's
83 {pad} card; its .brain/ is kept
84 {name} hook prompt|stop
85 {pad} what the Claude Code hooks `init` adds run: they hold an agent to describing an
86 {pad} undescribed project first, and to commenting on the task it started (they read the
87 {pad} hook's JSON on stdin)
88 {name} status print the running task, in whichever project, and how many wait for an answer
89 {pad} (for status bars)
90 {name} report [{units}] [--group {groups}] [--tag TAG] [--save]
91 {pad} print the current period's tracked time as markdown
92 {pad} (default: {unit}, grouped by {group}); --save also writes it to reports/
93{entries}
94 {name} path print the data directory in use (a project's .brain/ inside it)
95 {name} update install the latest release over this one (and the other app, if it's beside it)
96 {name} completions bash|zsh|fish
97 {pad} print a tab completion script for the shell (commands, ids, projects, tags)
98 {name} --version print the version
99
100--project NAME, anywhere on the line, runs a command on a registered project's brain (or `central`).
101Data lives in the nearest .brain/ folder here or above (see init); else in the central brain:
102$KANBRAIN_DIR, else the platform data dir (e.g. ~/.local/share/kanbrain).
103The kanban board, `kanbrain` without arguments, shows the same data.
104
105kanbrain by singudotdev (https://github.com/singudotdev/kanbrain). AGPL-3.0-only with the additional terms in
106NOTICE; no warranty.",
107 statuses = Status::names("|"),
108 default = DEFAULT_LIST.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
109 units = Unit::names("|"),
110 groups = Group::names("|"),
111 unit = DEFAULT_REPORT_UNIT.as_str(),
112 group = Group::default().as_str(),
113 entries = entries::USAGE
114 .lines()
115 .map(|line| {
116 let rest = line.strip_prefix(" ").unwrap_or(line);
119 format!(" {} {rest}", if rest.starts_with(' ') { &pad } else { name })
120 })
121 .collect::<Vec<_>>()
122 .join("\n"),
123 )
124}
125
126const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
128
129const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
131
132#[derive(Debug, PartialEq, Eq)]
134pub enum Command {
135 List(ListArgs),
137 Init(Option<String>),
139 InitAll,
141 Deinit,
143 Hook(hook::HookEvent),
145 Status,
147 Report(ReportArgs),
149 Entries(EntryArgs),
151 Path,
153 Update,
155 Completions(completions::Shell),
157 Complete(completions::Candidates),
160 Version,
162 Help,
164}
165
166#[derive(Debug, PartialEq, Eq)]
168pub struct ListArgs {
169 pub statuses: Vec<Status>,
171 pub kinds: Vec<Kind>,
173 pub tag: Option<String>,
175 pub json: bool,
177}
178
179#[derive(Debug, PartialEq, Eq)]
181pub struct ReportArgs {
182 pub unit: Unit,
184 pub group: Group,
186 pub tag: Option<String>,
188 pub save: bool,
190}
191
192impl Command {
193 pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
195 let mut args = args.into_iter();
196 let command = match args.next().as_deref() {
197 Some("list") => Command::List(ListArgs::parse(args.by_ref())?),
198 Some("init") => match args.next() {
199 Some(all) if all == "--all" => Command::InitAll,
200 name => Command::Init(name),
201 },
202 Some("deinit") => Command::Deinit,
203 Some("hook") => {
204 let name = args.next().context("hook needs prompt or stop")?;
205 Command::Hook(
206 hook::HookEvent::parse(&name).with_context(|| format!("unknown hook `{name}`: prompt or stop"))?,
207 )
208 }
209 Some("status") => Command::Status,
210 Some("report") => Command::Report(ReportArgs::parse(args.by_ref())?),
211 Some("path") => Command::Path,
212 Some("update") => Command::Update,
213 Some("completions") => {
214 let name = args.next().context("completions needs bash, zsh or fish")?;
215 Command::Completions(
216 completions::Shell::parse(&name)
217 .with_context(|| format!("unknown shell `{name}`: bash, zsh or fish"))?,
218 )
219 }
220 Some("complete") => {
221 let name = args.next().context("complete needs ids, projects or tags")?;
222 let what = completions::Candidates::parse(&name)
223 .with_context(|| format!("unknown candidates `{name}`: ids, projects or tags"))?;
224 args.by_ref().for_each(drop);
225 Command::Complete(what)
226 }
227 Some("-V" | "--version") => Command::Version,
228 None | Some("-h" | "--help") => Command::Help,
229 Some(other) => match EntryArgs::parse(other, args.by_ref())? {
230 Some(entries) => Command::Entries(entries),
231 None => bail!("unknown command `{other}`"),
232 },
233 };
234 if let Some(extra) = args.next() {
235 bail!("unexpected argument `{extra}`");
236 }
237 Ok(command)
238 }
239
240 pub fn run(self, program: Program, project: Option<&str>) -> Result<()> {
243 match self {
244 Command::Version => {
245 println!("{} {}", program.name, env!("CARGO_PKG_VERSION"));
246 return Ok(());
247 }
248 Command::Help => {
249 println!("{}", usage(program));
250 return Ok(());
251 }
252 Command::Completions(shell) => {
253 print!("{}", completions::script(shell, program.name));
254 return Ok(());
255 }
256 Command::Update => {
257 platform::ensure_not_root()?;
258 return update::update();
259 }
260 Command::Init(name) => {
261 platform::ensure_not_root()?;
262 if project.is_some() {
263 bail!("init works on the current directory; leave out --project");
264 }
265 let root = std::env::current_dir().context("reading the current directory")?;
266 init::init(&root, name.as_deref(), &projects::central_dir()?)?
267 .iter()
268 .for_each(|line| println!("{line}"));
269 return Ok(());
270 }
271 Command::InitAll => {
272 platform::ensure_not_root()?;
273 init::init_all(&projects::central_dir()?)?.iter().for_each(|line| println!("{line}"));
274 return Ok(());
275 }
276 Command::Hook(event) => {
277 let mut input = String::new();
279 if !std::io::stdin().is_terminal() {
280 std::io::stdin().read_to_string(&mut input).context("reading the hook's input")?;
281 }
282 if let Some(out) = hook::respond(event, &input, &projects::central_dir()?) {
283 println!("{out}");
284 }
285 return Ok(());
286 }
287 Command::Deinit => {
288 platform::ensure_not_root()?;
289 let central = projects::central_dir()?;
290 let dir = match project {
292 Some(projects::CENTRAL) => bail!("the central brain isn't a project: there's nothing to deinit"),
293 Some(label) => projects::find(¢ral, label)?.dir,
294 None => {
295 let here = std::env::current_dir().context("reading the current directory")?;
296 store::find_project(&here).context(
297 "no .brain/ here or above: run deinit inside a project, or name one with --project",
298 )?
299 }
300 };
301 init::deinit(&dir, ¢ral)?.iter().for_each(|line| println!("{line}"));
302 return Ok(());
303 }
304 _ => platform::ensure_not_root()?,
305 }
306 let store = Store::open_named(project)?;
307 let central = projects::central_dir()?;
308 match self {
309 Command::List(args) => {
310 let (tasks, _) = store.load()?;
311 if args.json {
312 println!("{}", list_json(&tasks, &args)?);
313 } else {
314 print!("{}", list_markdown(&tasks, &args));
315 }
316 Ok(())
317 }
318 Command::Status => print_status(¢ral),
319 Command::Entries(args) => {
320 let author = author()?;
321 args.run(Brain { author, ..Brain::open_in(store, ¢ral)? })
322 }
323 Command::Report(args) => print_report(store, &args),
324 Command::Path => {
325 println!("{}", store.dir().display());
326 Ok(())
327 }
328 Command::Complete(what) => {
329 completions::candidates(what, store, ¢ral)?.iter().for_each(|line| println!("{line}"));
330 Ok(())
331 }
332 Command::Version
334 | Command::Help
335 | Command::Completions(_)
336 | Command::Update
337 | Command::Init(_)
338 | Command::InitAll
339 | Command::Deinit
340 | Command::Hook(_) => Ok(()),
341 }
342 }
343}
344
345impl ListArgs {
346 fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
348 let mut statuses = Vec::new();
349 let mut kinds = Vec::new();
350 let mut tag = None;
351 let mut json = false;
352 while let Some(arg) = args.next() {
353 match arg.as_str() {
354 "--json" => json = true,
355 "notes" | "note" => kinds.push(Kind::Note),
356 "decisions" | "decision" => kinds.push(Kind::Decision),
357 "--tag" => {
358 let value = args.next().context("--tag needs a value")?;
359 tag = parse_tags(&value).into_iter().next();
360 }
361 "all" => statuses.extend(Status::ALL),
362 other => match Status::parse(other) {
364 Some(status) => statuses.push(status),
365 None => bail!("unknown list option `{other}`"),
366 },
367 }
368 }
369 if statuses.is_empty() && kinds.is_empty() {
371 statuses.extend(DEFAULT_LIST);
372 }
373 statuses.sort_by_key(|s| s.position());
375 statuses.dedup();
376 kinds.sort_by_key(|k| k.position());
377 kinds.dedup();
378 Ok(Self { statuses, kinds, tag, json })
379 }
380}
381
382impl ReportArgs {
383 fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
385 let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
386 while let Some(arg) = args.next() {
387 match arg.as_str() {
388 "--group" => {
389 let groups = Group::names(" or ");
390 let value = args.next().with_context(|| format!("--group needs {groups}"))?;
391 report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
392 }
393 "--tag" => {
394 let value = args.next().context("--tag needs a value")?;
395 report.tag = parse_tags(&value).into_iter().next();
396 }
397 "--save" => report.save = true,
398 other => match Unit::parse(other) {
400 Some(unit) => report.unit = unit,
401 None => bail!("unknown report option `{other}`"),
402 },
403 }
404 }
405 Ok(report)
406 }
407}
408
409const AGENT_VARIABLES: &[&str] =
412 &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
413
414fn author() -> Result<Author> {
417 if let Some(value) = std::env::var_os("KANBRAIN_AUTHOR") {
418 let value = value.to_string_lossy();
419 return Author::parse(&value)
420 .with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
421 }
422 let agent = AGENT_VARIABLES.iter().any(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty()));
423 Ok(if agent { Author::Agent } else { Author::Human })
424}
425
426fn print_status(central: &Path) -> Result<()> {
431 let now = now();
432 let mut brains = vec![(None, central.to_path_buf())];
433 let repos = projects::load(central)?;
434 brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
435 let mut running = Vec::new();
436 let mut waiting = 0;
437 for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
438 let (tasks, _) = Store::at(&dir)?.load()?;
439 if let Some((task, clock)) = tasks.iter().find_map(|t| Some((t, t.running_for(now)?))) {
440 let project = name.map(|n| format!("{n} ")).unwrap_or_default();
441 running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
442 }
443 waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
444 }
445 if running.is_empty() {
446 running.push("idle".to_string());
447 }
448 if waiting > 0 {
449 running.push(format!("{waiting} waiting"));
450 }
451 println!("{}", running.join(" · "));
452 Ok(())
453}
454
455fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
457 let brain = Brain::open(store)?;
458 let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
459 print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
460 if args.save {
461 eprintln!("saved {}", brain.export_report(&view)?.display());
462 }
463 Ok(())
464}
465
466fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
469 let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
470 let mut sections = Vec::new();
471 for &status in &args.statuses {
472 let mut matching: Vec<&Task> =
473 tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
474 matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
475 sections.push((status.as_str(), matching));
476 }
477 for &kind in &args.kinds {
478 let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
479 matching.sort_by_key(|t| std::cmp::Reverse(t.id));
480 sections.push((kind_heading(kind), matching));
481 }
482 sections.retain(|(_, entries)| !entries.is_empty());
483 sections
484}
485
486fn kind_heading(kind: Kind) -> &'static str {
488 match kind {
489 Kind::Task => "tasks",
490 Kind::Note => "notes",
491 Kind::Decision => "decisions",
492 }
493}
494
495fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
497 let mut out = String::new();
498 for (heading, entries) in list_sections(tasks, args) {
499 if !out.is_empty() {
500 out.push('\n');
501 }
502 writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
503 for task in entries {
504 write!(out, "- #{} {}", task.id, task.title).expect("writing to a String cannot fail");
505 for tag in &task.tags {
506 write!(out, " #{tag}").expect("writing to a String cannot fail");
507 }
508 out.push('\n');
509 }
510 }
511 out
512}
513
514fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
516 let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
517 Ok(serde_json::to_string_pretty(&entries)?)
518}
519
520#[cfg(test)]
521#[path = "../../tests/cli.rs"]
522mod tests;