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