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