1mod claude;
16mod completions;
17mod entries;
18mod hook;
19mod init;
20mod manage;
21pub mod update;
22
23pub use entries::{AddArgs, EntryArgs, EntryCommand, ProjectChange, ProjectPart, TagChange, Text};
24pub use init::init;
25pub use manage::{move_repo, remove_project, remove_repo, rename_project, rename_repo};
26
27use crate::api::json::Summary;
28use crate::brain::{Brain, Group, ReportView};
29use crate::model::{Asked, Author, Kind, Status, Task, fmt_clock, now, parse_tags};
30use crate::period::{Period, Unit};
31use crate::store::{self, Store};
32use crate::{export, platform, projects};
33use anyhow::{Context, Result, anyhow, bail};
34use chrono::{DateTime, Local};
35use std::fmt::Write;
36use std::io::{IsTerminal, Read};
37use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, Copy)]
41pub struct Program {
42 pub name: &'static str,
44}
45
46impl Program {
47 pub fn main(self, args: impl IntoIterator<Item = String>) -> Result<()> {
50 let (project, args) = take_project(args.into_iter().collect()).map_err(|e| self.misused(&e, None))?;
51 let name = args.first().cloned();
52 let help = |flag: &String| flag == "--help" || flag == "-h";
56 let asked = match args.as_slice() {
57 [word, command] if word == "help" => Some(command.as_str()),
58 [command, flag, ..] if help(flag) => Some(command.as_str()),
59 [command, _, flag, ..] if command == "project" && help(flag) => Some(command.as_str()),
60 _ => None,
61 };
62 if let Some(command) = asked {
63 let lines = self.usage_of(command);
64 if lines.is_empty() {
65 bail!("unknown command `{command}`\n\n`{} --help` lists every command", self.name);
66 }
67 println!("usage:\n{}", lines.join("\n"));
68 return Ok(());
69 }
70 let command = Command::parse(args).map_err(|e| self.misused(&e, name.as_deref()))?;
71 command.run(self, project.as_deref())
72 }
73
74 fn misused(self, error: &anyhow::Error, command: Option<&str>) -> anyhow::Error {
77 let lines = command.map(|c| self.usage_of(c)).unwrap_or_default();
78 let help = format!("`{} --help` lists every command", self.name);
79 if lines.is_empty() {
80 anyhow!("{error:#}\n\n{help}")
81 } else {
82 anyhow!("{error:#}\n\nusage:\n{}\n\n{help}", lines.join("\n"))
83 }
84 }
85
86 fn usage_of(self, command: &str) -> Vec<String> {
88 let usage = usage(self);
89 let header = format!(" {} ", self.name);
90 let mut lines = Vec::new();
91 let (mut inside, mut bare) = (false, false);
92 for line in usage.lines() {
93 if let Some(rest) = line.strip_prefix(&header) {
94 let names = rest.split_whitespace().next().unwrap_or_default();
95 inside = names.split('|').any(|n| n == command) || bare;
98 bare = inside && !bare && !rest.contains(" ");
99 } else if !(inside && line.starts_with(" ")) {
100 inside = false;
101 } else {
102 bare = false;
103 }
104 if inside {
105 lines.push(line.to_string());
106 }
107 }
108 lines
109 }
110}
111
112fn take_project(mut args: Vec<String>) -> Result<(Option<String>, Vec<String>)> {
115 let Some(at) = options(&args).iter().position(|a| a == "--project") else { return Ok((None, args)) };
116 if at + 1 >= args.len() {
117 bail!("--project needs a project name (or `{}`)", projects::CENTRAL);
118 }
119 let name = args.remove(at + 1);
120 args.remove(at);
121 Ok((Some(name), args))
122}
123
124pub(crate) fn options(args: &[String]) -> &[String] {
127 args.iter().position(|a| a == "--").map_or(args, |at| &args[..at])
128}
129
130pub fn usage(program: Program) -> String {
133 let name = program.name;
134 let pad = " ".repeat(name.len());
135 format!(
136 "{name} — project memory for you and your coding agents: tasks, notes and decisions in markdown files
137
138usage:
139 {name} list [{statuses}|all] [notes] [decisions] [--tag TAG] [--json]
140 {pad} print tasks as markdown, one section per status, then notes and decisions
141 {pad} (default: {default}; several can be given; notes or decisions alone
142 {pad} list only those)
143 {name} init [NAME] [--repo REPO]
144 {pad} make this folder a project: its own .brain/, registered as NAME (default: the
145 {pad} folder's name) with a card for the application, and a section telling its agents
146 {pad} about it in CLAUDE.md / AGENTS.md (run again to update them); in a project with
147 {pad} several, the repository is named REPO (default: the folder's, made unique)
148 {name} init --all
149 {pad} update the agents' section and hooks in every registered project to this version's
150 {pad} (the installer and update run it)
151 {name} deinit [--purge]
152 {pad} stop this folder (or --project NAME) being a project: unregister it, remove its
153 {pad} section from CLAUDE.md / AGENTS.md, the other repositories' links to it and, with
154 {pad} its last repository, the project's card; its .brain/ is kept unless --purge
155 {name} hook prompt|stop|edit|tool
156 {pad} what the Claude Code hooks `init` adds run: they hold an agent to describing an
157 {pad} undescribed project first, to commenting on the tasks it started, and to `kb` for
158 {pad} the brain's files, and tell it of your comments on its task with its next tool
159 {pad} (they read the hook's JSON on stdin)
160 {name} status print the running tasks, in whichever project, and how many wait for an answer
161 {pad} (for status bars)
162 {name} report [{units}] [--group {groups}] [--tag TAG] [--save]
163 {pad} print the current period's tracked time as markdown
164 {pad} (default: {unit}, grouped by {group}); --save also writes it to reports/
165{entries}
166 {name} path print the data directory in use (a project's .brain/ inside it)
167 {name} update install the latest release over this one (and the other app, if it's beside it)
168 {name} completions bash|zsh|fish
169 {pad} print a tab completion script for the shell (commands, ids, projects, tags)
170 {name} --version print the version
171
172--project NAME, anywhere on the line, runs a command on a registered project's brain (or `central`).
173--json prints JSON instead, for list, add, show, search, start|park|done|todo, comment, ask, propose, accept,
174reject, describe, rename, tag and link.
175-- ends a text's options: every word after it is the text (`{name} comment 3 -- use --json`).
176Data lives in the nearest .brain/ folder here or above (see init); else in the central brain:
177$KANBRAIN_DIR, else the platform data dir (e.g. ~/.local/share/kanbrain).
178The kanban board, `kanbrain` without arguments, shows the same data.
179
180kanbrain by singudotdev (https://github.com/singudotdev/kanbrain). AGPL-3.0-only with the additional terms in
181NOTICE; no warranty.",
182 statuses = Status::names("|"),
183 default = DEFAULT_LIST.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
184 units = Unit::names("|"),
185 groups = Group::names("|"),
186 unit = DEFAULT_REPORT_UNIT.as_str(),
187 group = Group::default().as_str(),
188 entries = entries::USAGE
189 .lines()
190 .map(|line| {
191 let rest = line.strip_prefix(" ").unwrap_or(line);
194 format!(" {} {rest}", if rest.starts_with(' ') { &pad } else { name })
195 })
196 .collect::<Vec<_>>()
197 .join("\n"),
198 )
199}
200
201const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
203
204const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
206
207#[derive(Debug, PartialEq, Eq)]
209#[non_exhaustive]
210pub enum Command {
211 #[non_exhaustive]
213 List(ListArgs),
214 #[non_exhaustive]
217 Init(Option<String>, Option<String>),
218 #[non_exhaustive]
220 InitAll,
221 #[non_exhaustive]
224 Deinit(bool),
225 #[non_exhaustive]
227 Hook(hook::HookEvent),
228 #[non_exhaustive]
230 Status,
231 #[non_exhaustive]
233 Report(ReportArgs),
234 #[non_exhaustive]
236 Entries(EntryArgs),
237 #[non_exhaustive]
239 Path,
240 #[non_exhaustive]
242 Update,
243 #[non_exhaustive]
245 Completions(completions::Shell),
246 #[non_exhaustive]
249 Complete(completions::Candidates),
250 #[non_exhaustive]
252 Version,
253 #[non_exhaustive]
255 Help,
256}
257
258#[derive(Debug, PartialEq, Eq)]
260#[non_exhaustive]
261pub struct ListArgs {
262 pub statuses: Vec<Status>,
264 pub kinds: Vec<Kind>,
266 pub tag: Option<String>,
268 pub json: bool,
270}
271
272#[derive(Debug, PartialEq, Eq)]
274#[non_exhaustive]
275pub struct ReportArgs {
276 pub unit: Unit,
278 pub group: Group,
280 pub tag: Option<String>,
282 pub save: bool,
284}
285
286impl Command {
287 pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
289 let mut args = args.into_iter();
290 let command = match args.next().as_deref() {
291 Some("list") => Command::List(ListArgs::parse(args.by_ref())?),
292 Some("init") => {
293 let words: Vec<String> = args.by_ref().collect();
294 match words.as_slice() {
295 [all] if all == "--all" => Command::InitAll,
296 _ => init_args(words)?,
297 }
298 }
299 Some("deinit") => Command::Deinit(match args.next() {
300 None => false,
301 Some(purge) if purge == "--purge" => true,
302 Some(other) => bail!("unexpected argument `{other}`"),
303 }),
304 Some("hook") => {
305 let name =
306 args.next().with_context(|| format!("hook needs one of {}", hook::HookEvent::names(", ")))?;
307 Command::Hook(
308 hook::HookEvent::parse(&name)
309 .with_context(|| format!("unknown hook `{name}`: {}", hook::HookEvent::names(", ")))?,
310 )
311 }
312 Some("status") => Command::Status,
313 Some("report") => Command::Report(ReportArgs::parse(args.by_ref())?),
314 Some("path") => Command::Path,
315 Some("update") => Command::Update,
316 Some("completions") => {
317 let name = args
318 .next()
319 .with_context(|| format!("completions needs one of {}", completions::Shell::names(", ")))?;
320 Command::Completions(
321 completions::Shell::parse(&name)
322 .with_context(|| format!("unknown shell `{name}`: {}", completions::Shell::names(", ")))?,
323 )
324 }
325 Some("complete") => {
326 let name = args
327 .next()
328 .with_context(|| format!("complete needs one of {}", completions::Candidates::names(", ")))?;
329 let what = completions::Candidates::parse(&name).with_context(|| {
330 format!("unknown candidates `{name}`: {}", completions::Candidates::names(", "))
331 })?;
332 args.by_ref().for_each(drop);
333 Command::Complete(what)
334 }
335 Some("-V" | "--version") => Command::Version,
336 None | Some("-h" | "--help" | "help") => Command::Help,
337 Some(other) => match EntryArgs::parse(other, args.by_ref())? {
338 Some(entries) => Command::Entries(entries),
339 None => bail!("unknown command `{other}`"),
340 },
341 };
342 if let Some(extra) = args.next() {
343 bail!("unexpected argument `{extra}`");
344 }
345 Ok(command)
346 }
347
348 pub fn run(self, program: Program, project: Option<&str>) -> Result<()> {
351 match self {
352 Command::Version => {
353 println!("{} {}", program.name, env!("CARGO_PKG_VERSION"));
354 return Ok(());
355 }
356 Command::Help => {
357 println!("{}", usage(program));
358 return Ok(());
359 }
360 Command::Completions(shell) => {
361 print!("{}", completions::script(shell, program.name));
362 return Ok(());
363 }
364 Command::Update => {
365 platform::ensure_not_root()?;
366 return update::update(&projects::central_dir()?);
367 }
368 Command::Init(name, repo) => {
369 platform::ensure_not_root()?;
370 if project.is_some() {
371 bail!("init works on the current directory; leave out --project");
372 }
373 let root = std::env::current_dir().context("reading the current directory")?;
374 init::init(&root, name.as_deref(), repo.as_deref(), &projects::central_dir()?)?
375 .iter()
376 .for_each(|line| println!("{line}"));
377 return Ok(());
378 }
379 Command::InitAll => {
380 platform::ensure_not_root()?;
381 init::init_all(&projects::central_dir()?)?.iter().for_each(|line| println!("{line}"));
382 return Ok(());
383 }
384 Command::Hook(event) => {
385 let mut input = String::new();
387 if !std::io::stdin().is_terminal() {
388 std::io::stdin().read_to_string(&mut input).context("reading the hook's input")?;
389 }
390 let here = std::env::current_dir().context("reading the current directory")?;
391 if let Some(out) = hook::respond(event, &input, &projects::central_dir()?, &here) {
392 println!("{out}");
393 }
394 return Ok(());
395 }
396 Command::Deinit(purge) => {
397 platform::ensure_not_root()?;
398 let central = projects::central_dir()?;
399 let dir = deinit_dir(¢ral, project)?;
400 manage::remove_repo(¢ral, &dir, purge)?.iter().for_each(|line| println!("{line}"));
401 return Ok(());
402 }
403 _ => platform::ensure_not_root()?,
404 }
405 let store = Store::open_named(project)?;
406 let central = projects::central_dir()?;
407 match self {
408 Command::List(args) => {
409 let _lock = store.lock()?;
412 let brain = Brain::open_in(store, ¢ral)?;
413 say_skipped(&brain);
414 let tasks = &brain.tasks;
415 if args.json {
416 println!("{}", list_json(tasks, &args)?);
417 } else {
418 match list_markdown(tasks, &args) {
419 empty if empty.is_empty() => println!("nothing to list"),
421 listed => print!("{listed}"),
422 }
423 }
424 Ok(())
425 }
426 Command::Status => {
427 println!("{}", status_line(¢ral, now())?);
428 Ok(())
429 }
430 Command::Entries(args) => run_entries(store, ¢ral, args),
431 Command::Report(args) => print_report(store, &args),
432 Command::Path => {
433 println!("{}", store.dir().display());
434 Ok(())
435 }
436 Command::Complete(what) => {
437 completions::candidates(what, store, ¢ral)?.iter().for_each(|line| println!("{line}"));
438 Ok(())
439 }
440 Command::Version
442 | Command::Help
443 | Command::Completions(_)
444 | Command::Update
445 | Command::Init(..)
446 | Command::InitAll
447 | Command::Deinit(_)
448 | Command::Hook(_) => Ok(()),
449 }
450 }
451}
452
453impl ListArgs {
454 fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
456 let mut statuses = Vec::new();
457 let mut kinds = Vec::new();
458 let mut tag = None;
459 let mut json = false;
460 while let Some(arg) = args.next() {
461 match arg.as_str() {
462 "--json" => json = true,
463 "notes" | "note" => kinds.push(Kind::Note),
464 "decisions" | "decision" => kinds.push(Kind::Decision),
465 "--tag" => {
466 let value = args.next().context("--tag needs a value")?;
467 let mut tags = parse_tags(&value).into_iter();
469 tag = tags.next();
470 if tags.next().is_some() {
471 bail!("--tag takes one tag, not `{value}`");
472 }
473 }
474 "all" => statuses.extend(Status::ALL),
475 other => match Status::parse(other) {
477 Some(status) => statuses.push(status),
478 None => bail!("unknown list option `{other}`"),
479 },
480 }
481 }
482 if statuses.is_empty() && kinds.is_empty() {
484 statuses.extend(DEFAULT_LIST);
485 }
486 statuses.sort_by_key(|s| s.position());
488 statuses.dedup();
489 kinds.sort_by_key(|k| k.position());
490 kinds.dedup();
491 Ok(Self { statuses, kinds, tag, json })
492 }
493}
494
495impl ReportArgs {
496 fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
498 let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
499 while let Some(arg) = args.next() {
500 match arg.as_str() {
501 "--group" => {
502 let groups = Group::names(" or ");
503 let value = args.next().with_context(|| format!("--group needs {groups}"))?;
504 report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
505 }
506 "--tag" => {
507 let value = args.next().context("--tag needs a value")?;
508 report.tag = parse_tags(&value).into_iter().next();
509 }
510 "--save" => report.save = true,
511 other => match Unit::parse(other) {
513 Some(unit) => report.unit = unit,
514 None => bail!("unknown report option `{other}`"),
515 },
516 }
517 }
518 Ok(report)
519 }
520}
521
522const AGENT_VARIABLES: &[&str] =
525 &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
526
527fn init_args(words: Vec<String>) -> Result<Command> {
529 let mut words = words.into_iter();
530 let (mut name, mut repo) = (None, None);
531 while let Some(word) = words.next() {
532 match word.as_str() {
533 "--repo" => repo = Some(words.next().context("--repo needs the repository's name")?),
534 other if other.starts_with("--") => bail!("unknown init option `{other}`"),
535 _ if name.is_none() => name = Some(word),
536 _ => bail!("unexpected argument `{word}`"),
537 }
538 }
539 Ok(Command::Init(name, repo))
540}
541
542fn author(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Result<Author> {
547 if let Some(value) = var("KANBRAIN_AUTHOR").filter(|v| !v.is_empty()) {
548 let value = value.to_string_lossy();
549 return Author::parse(&value)
550 .with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
551 }
552 let agent = AGENT_VARIABLES.iter().any(|name| var(name).is_some_and(|v| !v.is_empty()));
553 Ok(if agent { Author::Agent } else { Author::Human })
554}
555
556fn status_line(central: &Path, now: DateTime<Local>) -> Result<String> {
561 let mut brains = vec![(None, central.to_path_buf())];
562 let repos = projects::load(central)?;
563 brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
564 let mut running = Vec::new();
565 let mut waiting = 0;
566 for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
567 let Ok((tasks, _)) = Store::at(&dir).and_then(|store| store.load()) else { continue };
568 let project = name.map(|n| format!("{n} ")).unwrap_or_default();
569 for (task, clock) in tasks.iter().filter_map(|t| Some((t, t.running_for(now)?))) {
570 running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
571 }
572 waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
573 }
574 if running.is_empty() {
575 running.push("idle".to_string());
576 }
577 if waiting > 0 {
578 running.push(format!("{waiting} waiting"));
579 }
580 Ok(running.join(" · "))
581}
582
583fn deinit_dir(central: &Path, project: Option<&str>) -> Result<PathBuf> {
586 let central_refused = || anyhow!("the central brain isn't a project: there's nothing to deinit");
587 match project {
588 Some(projects::CENTRAL) => Err(central_refused()),
589 Some(label) => match projects::find(central, label) {
590 Ok(repo) => Ok(repo.dir),
591 Err(_) if label.eq_ignore_ascii_case(projects::CENTRAL) => Err(central_refused()),
592 Err(e) => Err(e),
593 },
594 None => {
595 let here = std::env::current_dir().context("reading the current directory")?;
596 store::find_project(&here)
597 .context("no .brain/ here or above: run deinit inside a project, or name one with --project")
598 }
599 }
600}
601
602fn run_entries(store: Store, central: &Path, mut args: EntryArgs) -> Result<()> {
604 let author = author(|name| std::env::var_os(name))?;
605 args.read_stdin()?;
608 let _central = match args.command {
611 EntryCommand::Project(Some(_)) => Some(Store::at(central)?.lock()?),
612 _ => None,
613 };
614 let lock = store.lock()?;
617 let brain = Brain { author, ..Brain::open_in(store, central)? };
618 say_skipped(&brain);
619 let _lock = (!matches!(args.command, EntryCommand::Wait(..))).then_some(lock);
620 args.run(brain)
621}
622
623fn say_skipped(brain: &Brain) {
628 let entry_file = |warning: &&String| store::entry_named(warning) || !warning.contains(crate::model::MISSING_ID);
629 for warning in brain.warnings.iter().filter(entry_file) {
630 eprintln!("kb: skipped {warning}; fix the file or remove it");
631 }
632}
633
634fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
636 let _lock = store.lock()?;
638 let brain = Brain::open(store)?;
639 let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
640 print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
641 if args.save {
642 eprintln!("saved {}", brain.export_report(&view)?.display());
643 }
644 Ok(())
645}
646
647fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
650 let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
651 let mut sections = Vec::new();
652 for &status in &args.statuses {
653 let mut matching: Vec<&Task> =
654 tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
655 matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
656 sections.push((status.as_str(), matching));
657 }
658 for &kind in &args.kinds {
659 let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
660 matching.sort_by_key(|t| std::cmp::Reverse(t.id));
661 sections.push((kind_heading(kind), matching));
662 }
663 sections.retain(|(_, entries)| !entries.is_empty());
664 sections
665}
666
667fn kind_heading(kind: Kind) -> &'static str {
669 match kind {
670 Kind::Task => "tasks",
671 Kind::Note => "notes",
672 Kind::Decision => "decisions",
673 }
674}
675
676fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
679 let mut out = String::new();
680 for (heading, entries) in list_sections(tasks, args) {
681 if !out.is_empty() {
682 out.push('\n');
683 }
684 writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
685 for task in entries {
686 out.push_str("- ");
687 crate::api::text::head(&mut out, task).expect("writing to a String cannot fail");
688 if let Some(note) = crate::api::text::state_note(task) {
690 write!(out, " · {note}").expect("writing to a String cannot fail");
691 }
692 out.push('\n');
693 }
694 }
695 out
696}
697
698fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
701 let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
702 Ok(serde_json::to_string(&entries)?)
703}
704
705#[cfg(test)]
706#[path = "../../tests/cli.rs"]
707mod tests;