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
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 (they read the hook's JSON on stdin)
159 {name} status print the running tasks, in whichever project, and how many wait for an answer
160 {pad} (for status bars)
161 {name} report [{units}] [--group {groups}] [--tag TAG] [--save]
162 {pad} print the current period's tracked time as markdown
163 {pad} (default: {unit}, grouped by {group}); --save also writes it to reports/
164{entries}
165 {name} path print the data directory in use (a project's .brain/ inside it)
166 {name} update install the latest release over this one (and the other app, if it's beside it)
167 {name} completions bash|zsh|fish
168 {pad} print a tab completion script for the shell (commands, ids, projects, tags)
169 {name} --version print the version
170
171--project NAME, anywhere on the line, runs a command on a registered project's brain (or `central`).
172--json prints JSON instead, for list, add, show, search, start|park|done|todo, comment, ask, propose, accept,
173reject, describe, rename, tag and link.
174-- ends a text's options: every word after it is the text (`{name} comment 3 -- use --json`).
175Data lives in the nearest .brain/ folder here or above (see init); else in the central brain:
176$KANBRAIN_DIR, else the platform data dir (e.g. ~/.local/share/kanbrain).
177The kanban board, `kanbrain` without arguments, shows the same data.
178
179kanbrain by singudotdev (https://github.com/singudotdev/kanbrain). AGPL-3.0-only with the additional terms in
180NOTICE; no warranty.",
181 statuses = Status::names("|"),
182 default = DEFAULT_LIST.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
183 units = Unit::names("|"),
184 groups = Group::names("|"),
185 unit = DEFAULT_REPORT_UNIT.as_str(),
186 group = Group::default().as_str(),
187 entries = entries::USAGE
188 .lines()
189 .map(|line| {
190 let rest = line.strip_prefix(" ").unwrap_or(line);
193 format!(" {} {rest}", if rest.starts_with(' ') { &pad } else { name })
194 })
195 .collect::<Vec<_>>()
196 .join("\n"),
197 )
198}
199
200const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
202
203const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
205
206#[derive(Debug, PartialEq, Eq)]
208#[non_exhaustive]
209pub enum Command {
210 #[non_exhaustive]
212 List(ListArgs),
213 #[non_exhaustive]
216 Init(Option<String>, Option<String>),
217 #[non_exhaustive]
219 InitAll,
220 #[non_exhaustive]
223 Deinit(bool),
224 #[non_exhaustive]
226 Hook(hook::HookEvent),
227 #[non_exhaustive]
229 Status,
230 #[non_exhaustive]
232 Report(ReportArgs),
233 #[non_exhaustive]
235 Entries(EntryArgs),
236 #[non_exhaustive]
238 Path,
239 #[non_exhaustive]
241 Update,
242 #[non_exhaustive]
244 Completions(completions::Shell),
245 #[non_exhaustive]
248 Complete(completions::Candidates),
249 #[non_exhaustive]
251 Version,
252 #[non_exhaustive]
254 Help,
255}
256
257#[derive(Debug, PartialEq, Eq)]
259#[non_exhaustive]
260pub struct ListArgs {
261 pub statuses: Vec<Status>,
263 pub kinds: Vec<Kind>,
265 pub tag: Option<String>,
267 pub json: bool,
269}
270
271#[derive(Debug, PartialEq, Eq)]
273#[non_exhaustive]
274pub struct ReportArgs {
275 pub unit: Unit,
277 pub group: Group,
279 pub tag: Option<String>,
281 pub save: bool,
283}
284
285impl Command {
286 pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
288 let mut args = args.into_iter();
289 let command = match args.next().as_deref() {
290 Some("list") => Command::List(ListArgs::parse(args.by_ref())?),
291 Some("init") => {
292 let words: Vec<String> = args.by_ref().collect();
293 match words.as_slice() {
294 [all] if all == "--all" => Command::InitAll,
295 _ => init_args(words)?,
296 }
297 }
298 Some("deinit") => Command::Deinit(match args.next() {
299 None => false,
300 Some(purge) if purge == "--purge" => true,
301 Some(other) => bail!("unexpected argument `{other}`"),
302 }),
303 Some("hook") => {
304 let name =
305 args.next().with_context(|| format!("hook needs one of {}", hook::HookEvent::names(", ")))?;
306 Command::Hook(
307 hook::HookEvent::parse(&name)
308 .with_context(|| format!("unknown hook `{name}`: {}", hook::HookEvent::names(", ")))?,
309 )
310 }
311 Some("status") => Command::Status,
312 Some("report") => Command::Report(ReportArgs::parse(args.by_ref())?),
313 Some("path") => Command::Path,
314 Some("update") => Command::Update,
315 Some("completions") => {
316 let name = args
317 .next()
318 .with_context(|| format!("completions needs one of {}", completions::Shell::names(", ")))?;
319 Command::Completions(
320 completions::Shell::parse(&name)
321 .with_context(|| format!("unknown shell `{name}`: {}", completions::Shell::names(", ")))?,
322 )
323 }
324 Some("complete") => {
325 let name = args
326 .next()
327 .with_context(|| format!("complete needs one of {}", completions::Candidates::names(", ")))?;
328 let what = completions::Candidates::parse(&name).with_context(|| {
329 format!("unknown candidates `{name}`: {}", completions::Candidates::names(", "))
330 })?;
331 args.by_ref().for_each(drop);
332 Command::Complete(what)
333 }
334 Some("-V" | "--version") => Command::Version,
335 None | Some("-h" | "--help" | "help") => Command::Help,
336 Some(other) => match EntryArgs::parse(other, args.by_ref())? {
337 Some(entries) => Command::Entries(entries),
338 None => bail!("unknown command `{other}`"),
339 },
340 };
341 if let Some(extra) = args.next() {
342 bail!("unexpected argument `{extra}`");
343 }
344 Ok(command)
345 }
346
347 pub fn run(self, program: Program, project: Option<&str>) -> Result<()> {
350 match self {
351 Command::Version => {
352 println!("{} {}", program.name, env!("CARGO_PKG_VERSION"));
353 return Ok(());
354 }
355 Command::Help => {
356 println!("{}", usage(program));
357 return Ok(());
358 }
359 Command::Completions(shell) => {
360 print!("{}", completions::script(shell, program.name));
361 return Ok(());
362 }
363 Command::Update => {
364 platform::ensure_not_root()?;
365 return update::update(&projects::central_dir()?);
366 }
367 Command::Init(name, repo) => {
368 platform::ensure_not_root()?;
369 if project.is_some() {
370 bail!("init works on the current directory; leave out --project");
371 }
372 let root = std::env::current_dir().context("reading the current directory")?;
373 init::init(&root, name.as_deref(), repo.as_deref(), &projects::central_dir()?)?
374 .iter()
375 .for_each(|line| println!("{line}"));
376 return Ok(());
377 }
378 Command::InitAll => {
379 platform::ensure_not_root()?;
380 init::init_all(&projects::central_dir()?)?.iter().for_each(|line| println!("{line}"));
381 return Ok(());
382 }
383 Command::Hook(event) => {
384 let mut input = String::new();
386 if !std::io::stdin().is_terminal() {
387 std::io::stdin().read_to_string(&mut input).context("reading the hook's input")?;
388 }
389 let here = std::env::current_dir().context("reading the current directory")?;
390 if let Some(out) = hook::respond(event, &input, &projects::central_dir()?, &here) {
391 println!("{out}");
392 }
393 return Ok(());
394 }
395 Command::Deinit(purge) => {
396 platform::ensure_not_root()?;
397 let central = projects::central_dir()?;
398 let dir = deinit_dir(¢ral, project)?;
399 manage::remove_repo(¢ral, &dir, purge)?.iter().for_each(|line| println!("{line}"));
400 return Ok(());
401 }
402 _ => platform::ensure_not_root()?,
403 }
404 let store = Store::open_named(project)?;
405 let central = projects::central_dir()?;
406 match self {
407 Command::List(args) => {
408 let _lock = store.lock()?;
411 let brain = Brain::open_in(store, ¢ral)?;
412 say_skipped(&brain);
413 let tasks = &brain.tasks;
414 if args.json {
415 println!("{}", list_json(tasks, &args)?);
416 } else {
417 match list_markdown(tasks, &args) {
418 empty if empty.is_empty() => println!("nothing to list"),
420 listed => print!("{listed}"),
421 }
422 }
423 Ok(())
424 }
425 Command::Status => {
426 println!("{}", status_line(¢ral, now())?);
427 Ok(())
428 }
429 Command::Entries(args) => run_entries(store, ¢ral, args),
430 Command::Report(args) => print_report(store, &args),
431 Command::Path => {
432 println!("{}", store.dir().display());
433 Ok(())
434 }
435 Command::Complete(what) => {
436 completions::candidates(what, store, ¢ral)?.iter().for_each(|line| println!("{line}"));
437 Ok(())
438 }
439 Command::Version
441 | Command::Help
442 | Command::Completions(_)
443 | Command::Update
444 | Command::Init(..)
445 | Command::InitAll
446 | Command::Deinit(_)
447 | Command::Hook(_) => Ok(()),
448 }
449 }
450}
451
452impl ListArgs {
453 fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
455 let mut statuses = Vec::new();
456 let mut kinds = Vec::new();
457 let mut tag = None;
458 let mut json = false;
459 while let Some(arg) = args.next() {
460 match arg.as_str() {
461 "--json" => json = true,
462 "notes" | "note" => kinds.push(Kind::Note),
463 "decisions" | "decision" => kinds.push(Kind::Decision),
464 "--tag" => {
465 let value = args.next().context("--tag needs a value")?;
466 let mut tags = parse_tags(&value).into_iter();
468 tag = tags.next();
469 if tags.next().is_some() {
470 bail!("--tag takes one tag, not `{value}`");
471 }
472 }
473 "all" => statuses.extend(Status::ALL),
474 other => match Status::parse(other) {
476 Some(status) => statuses.push(status),
477 None => bail!("unknown list option `{other}`"),
478 },
479 }
480 }
481 if statuses.is_empty() && kinds.is_empty() {
483 statuses.extend(DEFAULT_LIST);
484 }
485 statuses.sort_by_key(|s| s.position());
487 statuses.dedup();
488 kinds.sort_by_key(|k| k.position());
489 kinds.dedup();
490 Ok(Self { statuses, kinds, tag, json })
491 }
492}
493
494impl ReportArgs {
495 fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
497 let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
498 while let Some(arg) = args.next() {
499 match arg.as_str() {
500 "--group" => {
501 let groups = Group::names(" or ");
502 let value = args.next().with_context(|| format!("--group needs {groups}"))?;
503 report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
504 }
505 "--tag" => {
506 let value = args.next().context("--tag needs a value")?;
507 report.tag = parse_tags(&value).into_iter().next();
508 }
509 "--save" => report.save = true,
510 other => match Unit::parse(other) {
512 Some(unit) => report.unit = unit,
513 None => bail!("unknown report option `{other}`"),
514 },
515 }
516 }
517 Ok(report)
518 }
519}
520
521const AGENT_VARIABLES: &[&str] =
524 &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
525
526fn init_args(words: Vec<String>) -> Result<Command> {
528 let mut words = words.into_iter();
529 let (mut name, mut repo) = (None, None);
530 while let Some(word) = words.next() {
531 match word.as_str() {
532 "--repo" => repo = Some(words.next().context("--repo needs the repository's name")?),
533 other if other.starts_with("--") => bail!("unknown init option `{other}`"),
534 _ if name.is_none() => name = Some(word),
535 _ => bail!("unexpected argument `{word}`"),
536 }
537 }
538 Ok(Command::Init(name, repo))
539}
540
541fn author(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Result<Author> {
546 if let Some(value) = var("KANBRAIN_AUTHOR").filter(|v| !v.is_empty()) {
547 let value = value.to_string_lossy();
548 return Author::parse(&value)
549 .with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
550 }
551 let agent = AGENT_VARIABLES.iter().any(|name| var(name).is_some_and(|v| !v.is_empty()));
552 Ok(if agent { Author::Agent } else { Author::Human })
553}
554
555fn status_line(central: &Path, now: DateTime<Local>) -> Result<String> {
560 let mut brains = vec![(None, central.to_path_buf())];
561 let repos = projects::load(central)?;
562 brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
563 let mut running = Vec::new();
564 let mut waiting = 0;
565 for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
566 let Ok((tasks, _)) = Store::at(&dir).and_then(|store| store.load()) else { continue };
567 let project = name.map(|n| format!("{n} ")).unwrap_or_default();
568 for (task, clock) in tasks.iter().filter_map(|t| Some((t, t.running_for(now)?))) {
569 running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
570 }
571 waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
572 }
573 if running.is_empty() {
574 running.push("idle".to_string());
575 }
576 if waiting > 0 {
577 running.push(format!("{waiting} waiting"));
578 }
579 Ok(running.join(" · "))
580}
581
582fn deinit_dir(central: &Path, project: Option<&str>) -> Result<PathBuf> {
585 let central_refused = || anyhow!("the central brain isn't a project: there's nothing to deinit");
586 match project {
587 Some(projects::CENTRAL) => Err(central_refused()),
588 Some(label) => match projects::find(central, label) {
589 Ok(repo) => Ok(repo.dir),
590 Err(_) if label.eq_ignore_ascii_case(projects::CENTRAL) => Err(central_refused()),
591 Err(e) => Err(e),
592 },
593 None => {
594 let here = std::env::current_dir().context("reading the current directory")?;
595 store::find_project(&here)
596 .context("no .brain/ here or above: run deinit inside a project, or name one with --project")
597 }
598 }
599}
600
601fn run_entries(store: Store, central: &Path, mut args: EntryArgs) -> Result<()> {
603 let author = author(|name| std::env::var_os(name))?;
604 args.read_stdin()?;
607 let _central = match args.command {
610 EntryCommand::Project(Some(_)) => Some(Store::at(central)?.lock()?),
611 _ => None,
612 };
613 let lock = store.lock()?;
616 let brain = Brain { author, ..Brain::open_in(store, central)? };
617 say_skipped(&brain);
618 let _lock = (!matches!(args.command, EntryCommand::Wait(..))).then_some(lock);
619 args.run(brain)
620}
621
622fn say_skipped(brain: &Brain) {
627 let entry_file = |warning: &&String| store::entry_named(warning) || !warning.contains(crate::model::MISSING_ID);
628 for warning in brain.warnings.iter().filter(entry_file) {
629 eprintln!("kb: skipped {warning}; fix the file or remove it");
630 }
631}
632
633fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
635 let _lock = store.lock()?;
637 let brain = Brain::open(store)?;
638 let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
639 print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
640 if args.save {
641 eprintln!("saved {}", brain.export_report(&view)?.display());
642 }
643 Ok(())
644}
645
646fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
649 let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
650 let mut sections = Vec::new();
651 for &status in &args.statuses {
652 let mut matching: Vec<&Task> =
653 tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
654 matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
655 sections.push((status.as_str(), matching));
656 }
657 for &kind in &args.kinds {
658 let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
659 matching.sort_by_key(|t| std::cmp::Reverse(t.id));
660 sections.push((kind_heading(kind), matching));
661 }
662 sections.retain(|(_, entries)| !entries.is_empty());
663 sections
664}
665
666fn kind_heading(kind: Kind) -> &'static str {
668 match kind {
669 Kind::Task => "tasks",
670 Kind::Note => "notes",
671 Kind::Decision => "decisions",
672 }
673}
674
675fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
678 let mut out = String::new();
679 for (heading, entries) in list_sections(tasks, args) {
680 if !out.is_empty() {
681 out.push('\n');
682 }
683 writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
684 for task in entries {
685 out.push_str("- ");
686 crate::api::text::head(&mut out, task).expect("writing to a String cannot fail");
687 if let Some(note) = crate::api::text::state_note(task) {
689 write!(out, " · {note}").expect("writing to a String cannot fail");
690 }
691 out.push('\n');
692 }
693 }
694 out
695}
696
697fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
700 let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
701 Ok(serde_json::to_string(&entries)?)
702}
703
704#[cfg(test)]
705#[path = "../../tests/cli.rs"]
706mod tests;