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