Skip to main content

kanbrain_core/cli/
mod.rs

1//! The command line: which command to run, with its options.
2//!
3//! - [`entries`]: commands that read or change entries by id, for agents and scripts.
4//! - [`mod@init`]: a project's `.brain/`, and the section telling its agents about it; `deinit` undoes it.
5//! - [`claude`]: the Claude Code hooks `init` adds: the context at each session start, and [`hook`]'s checks.
6//! - [`hook`]: `kb hook prompt|stop`, which hold an agent to describing an undescribed project first, and
7//!   to commenting on the task it started.
8//! - [`completions`]: `kb completions SHELL`, tab completion, and the ids, projects and tags it offers.
9//! - [`update`]: `kb update`, the latest release over this one, through the installer.
10
11mod 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/// The binary running the command line, named in the usage text and `--version`.
33#[derive(Debug, Clone, Copy)]
34pub struct Program {
35    /// The command's name.
36    pub name: &'static str,
37}
38
39impl Program {
40    /// Parses the arguments after the program name and runs the command.
41    /// A command-line error comes with the usage text.
42    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
50/// Takes `--project NAME` out of the arguments, wherever it is: it picks the brain for any command.
51fn 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
61/// Printed by `--help` and after a command-line error. The status, period and group names come
62/// from `Status`, `Unit` and `Group`, so they're only written there.
63pub 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 task it started (they read the
87  {pad}           hook's JSON on stdin)
88  {name} status    print the running task, 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                // Commands start at the margin and get the program's name; descriptions under them are
117                // indented further and get blanks instead, so they stay aligned.
118                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
126/// What `kb list` shows unless statuses are given: everything that isn't done.
127const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
128
129/// The period `kb report` shows unless another one is given.
130const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
131
132/// What to do, as chosen on the command line.
133#[derive(Debug, PartialEq, Eq)]
134pub enum Command {
135    /// `list`: print tasks as markdown.
136    List(ListArgs),
137    /// `init [NAME]`: make the current directory a project, named `NAME` or after the folder.
138    Init(Option<String>),
139    /// `init --all`: bring every registered repository's agent instructions up to this version's.
140    InitAll,
141    /// `deinit`: undo `init` for the current directory's project, or the one `--project` names.
142    Deinit,
143    /// `hook prompt|stop`: what Claude Code's hooks run (see [`hook`]).
144    Hook(hook::HookEvent),
145    /// `status`: print the running task for status bars.
146    Status,
147    /// `report`: print a time report as markdown.
148    Report(ReportArgs),
149    /// `add`, `show`, `search`, `start`, `comment`, …: entries by id, for agents and scripts.
150    Entries(EntryArgs),
151    /// `path`: print the data folder.
152    Path,
153    /// `update`: install the latest release.
154    Update,
155    /// `completions SHELL`: print a tab completion script.
156    Completions(completions::Shell),
157    /// `complete ids|projects|tags …`: what the completion scripts offer, one per line. The words after
158    /// it are the command line being completed, ignored but for its `--project`.
159    Complete(completions::Candidates),
160    /// `--version` / `-V`.
161    Version,
162    /// `--help` / `-h`.
163    Help,
164}
165
166/// Options of `kb list`.
167#[derive(Debug, PartialEq, Eq)]
168pub struct ListArgs {
169    /// Task statuses to show, in list order.
170    pub statuses: Vec<Status>,
171    /// Notes and/or decisions to show after the tasks, in that order.
172    pub kinds: Vec<Kind>,
173    /// Only entries with this tag.
174    pub tag: Option<String>,
175    /// Print a JSON array instead of markdown.
176    pub json: bool,
177}
178
179/// Options of `kb report`.
180#[derive(Debug, PartialEq, Eq)]
181pub struct ReportArgs {
182    /// Period length; the current one is reported.
183    pub unit: Unit,
184    /// Rows per task or per tag.
185    pub group: Group,
186    /// Only count tasks with this tag.
187    pub tag: Option<String>,
188    /// Also write the report to `reports/`.
189    pub save: bool,
190}
191
192impl Command {
193    /// Parses the arguments after the program name.
194    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    /// Runs the command on the brain of `project` (by name), else the one found from here.
241    /// Everything except `--version` and `--help` refuses to run as root.
242    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                // Claude Code sends the event as JSON on stdin; typed at a terminal, there's none to wait for.
278                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                // Not through `Store::open_named`, which would create the folder of a deleted repository.
291                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(&central, 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, &central)?.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(&central),
319            Command::Entries(args) => {
320                let author = author()?;
321                args.run(Brain { author, ..Brain::open_in(store, &central)? })
322            }
323            Command::Report(args) => print_report(store, &args),
324            Command::Path => {
325                println!("{}", store.dir().display());
326                Ok(())
327            }
328            Command::Complete(what) => {
329                completions::candidates(what, store, &central)?.iter().for_each(|line| println!("{line}"));
330                Ok(())
331            }
332            // Handled above, before touching the data folder.
333            Command::Version
334            | Command::Help
335            | Command::Completions(_)
336            | Command::Update
337            | Command::Init(_)
338            | Command::InitAll
339            | Command::Deinit
340            | Command::Hook(_) => Ok(()),
341        }
342    }
343}
344
345impl ListArgs {
346    /// Parses the options after `list`.
347    fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
348        let mut statuses = Vec::new();
349        let mut kinds = Vec::new();
350        let mut tag = None;
351        let mut json = false;
352        while let Some(arg) = args.next() {
353            match arg.as_str() {
354                "--json" => json = true,
355                "notes" | "note" => kinds.push(Kind::Note),
356                "decisions" | "decision" => kinds.push(Kind::Decision),
357                "--tag" => {
358                    let value = args.next().context("--tag needs a value")?;
359                    tag = parse_tags(&value).into_iter().next();
360                }
361                "all" => statuses.extend(Status::ALL),
362                // Anything else must be a status name.
363                other => match Status::parse(other) {
364                    Some(status) => statuses.push(status),
365                    None => bail!("unknown list option `{other}`"),
366                },
367            }
368        }
369        // Asking only for notes or decisions lists only those.
370        if statuses.is_empty() && kinds.is_empty() {
371            statuses.extend(DEFAULT_LIST);
372        }
373        // List order, without repeats.
374        statuses.sort_by_key(|s| s.position());
375        statuses.dedup();
376        kinds.sort_by_key(|k| k.position());
377        kinds.dedup();
378        Ok(Self { statuses, kinds, tag, json })
379    }
380}
381
382impl ReportArgs {
383    /// Parses the options after `report`.
384    fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
385        let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
386        while let Some(arg) = args.next() {
387            match arg.as_str() {
388                "--group" => {
389                    let groups = Group::names(" or ");
390                    let value = args.next().with_context(|| format!("--group needs {groups}"))?;
391                    report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
392                }
393                "--tag" => {
394                    let value = args.next().context("--tag needs a value")?;
395                    report.tag = parse_tags(&value).into_iter().next();
396                }
397                "--save" => report.save = true,
398                // Anything else must be a period name.
399                other => match Unit::parse(other) {
400                    Some(unit) => report.unit = unit,
401                    None => bail!("unknown report option `{other}`"),
402                },
403            }
404        }
405        Ok(report)
406    }
407}
408
409/// Environment variables coding agents set in the commands they run, so `kb` can tell them from a
410/// person at a terminal: the generic `AGENT` and `AI_AGENT`, then Claude Code's, Codex's and Gemini CLI's.
411const AGENT_VARIABLES: &[&str] =
412    &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
413
414/// Who this command runs for, recorded on the comments and status changes it makes: `KANBRAIN_AUTHOR`
415/// (`human` or `agent`) if set, else an agent if one of [`AGENT_VARIABLES`] is set, else a person.
416fn author() -> Result<Author> {
417    if let Some(value) = std::env::var_os("KANBRAIN_AUTHOR") {
418        let value = value.to_string_lossy();
419        return Author::parse(&value)
420            .with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
421    }
422    let agent = AGENT_VARIABLES.iter().any(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty()));
423    Ok(if agent { Author::Agent } else { Author::Human })
424}
425
426/// One line for status bars: `#id title HH:MM:SS` for each running session, or `idle`, then how many
427/// tasks wait for an answer, if any. Looks in every brain, wherever it's run from, since each runs its
428/// own task; a project's task says which project, and several are joined:
429/// `shop/api #3 Fix login 00:42:17 · blog #1 Post 00:05:00 · 2 waiting`.
430fn print_status(central: &Path) -> Result<()> {
431    let now = now();
432    let mut brains = vec![(None, central.to_path_buf())];
433    let repos = projects::load(central)?;
434    brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
435    let mut running = Vec::new();
436    let mut waiting = 0;
437    for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
438        let (tasks, _) = Store::at(&dir)?.load()?;
439        if let Some((task, clock)) = tasks.iter().find_map(|t| Some((t, t.running_for(now)?))) {
440            let project = name.map(|n| format!("{n} ")).unwrap_or_default();
441            running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
442        }
443        waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
444    }
445    if running.is_empty() {
446        running.push("idle".to_string());
447    }
448    if waiting > 0 {
449        running.push(format!("{waiting} waiting"));
450    }
451    println!("{}", running.join(" · "));
452    Ok(())
453}
454
455/// Prints the current period's report as markdown, and saves it with `--save`.
456fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
457    let brain = Brain::open(store)?;
458    let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
459    print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
460    if args.save {
461        eprintln!("saved {}", brain.export_report(&view)?.display());
462    }
463    Ok(())
464}
465
466/// What `list` prints, in order: one section per status (tasks only), then notes, then decisions,
467/// each entry carrying `args.tag` if one was given.
468fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
469    let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
470    let mut sections = Vec::new();
471    for &status in &args.statuses {
472        let mut matching: Vec<&Task> =
473            tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
474        matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
475        sections.push((status.as_str(), matching));
476    }
477    for &kind in &args.kinds {
478        let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
479        matching.sort_by_key(|t| std::cmp::Reverse(t.id));
480        sections.push((kind_heading(kind), matching));
481    }
482    sections.retain(|(_, entries)| !entries.is_empty());
483    sections
484}
485
486/// The `list` word and section heading for notes and decisions.
487fn kind_heading(kind: Kind) -> &'static str {
488    match kind {
489        Kind::Task => "tasks",
490        Kind::Note => "notes",
491        Kind::Decision => "decisions",
492    }
493}
494
495/// One `## heading` section per non-empty section, each entry as `- #id title #tag …`.
496fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
497    let mut out = String::new();
498    for (heading, entries) in list_sections(tasks, args) {
499        if !out.is_empty() {
500            out.push('\n');
501        }
502        writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
503        for task in entries {
504            write!(out, "- #{} {}", task.id, task.title).expect("writing to a String cannot fail");
505            for tag in &task.tags {
506                write!(out, " #{tag}").expect("writing to a String cannot fail");
507            }
508            out.push('\n');
509        }
510    }
511    out
512}
513
514/// The same entries as `list_markdown`, in the same order, as a JSON array of summaries.
515fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
516    let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
517    Ok(serde_json::to_string_pretty(&entries)?)
518}
519
520#[cfg(test)]
521#[path = "../../tests/cli.rs"]
522mod tests;