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