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