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 tasks 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 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/// The binary running the command line, named in the usage text and `--version`.
35#[derive(Debug, Clone, Copy)]
36pub struct Program {
37    /// The command's name.
38    pub name: &'static str,
39}
40
41impl Program {
42    /// Parses the arguments after the program name and runs the command.
43    /// A command-line error comes with the usage text.
44    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
52/// Takes `--project NAME` out of the arguments, wherever it is: it picks the brain for any command.
53fn 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
63/// Printed by `--help` and after a command-line error. The status, period and group names come
64/// from `Status`, `Unit` and `Group`, so they're only written there.
65pub 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                // Commands start at the margin and get the program's name; descriptions under them are
121                // indented further and get blanks instead, so they stay aligned.
122                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
130/// What `kb list` shows unless statuses are given: everything that isn't done.
131const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
132
133/// The period `kb report` shows unless another one is given.
134const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
135
136/// What to do, as chosen on the command line.
137#[derive(Debug, PartialEq, Eq)]
138pub enum Command {
139    /// `list`: print tasks as markdown.
140    List(ListArgs),
141    /// `init [NAME] [--repo REPO]`: make the current directory a project, named `NAME` or after the folder,
142    /// and the repository `REPO` in it.
143    Init(Option<String>, Option<String>),
144    /// `init --all`: bring every registered repository's agent instructions up to this version's.
145    InitAll,
146    /// `deinit [--purge]`: undo `init` for the current directory's project, or the one `--project` names;
147    /// `true` also deletes its `.brain/`.
148    Deinit(bool),
149    /// `hook prompt|stop`: what Claude Code's hooks run (see [`hook`]).
150    Hook(hook::HookEvent),
151    /// `status`: print the running tasks for status bars.
152    Status,
153    /// `report`: print a time report as markdown.
154    Report(ReportArgs),
155    /// `add`, `show`, `search`, `start`, `comment`, …: entries by id, for agents and scripts.
156    Entries(EntryArgs),
157    /// `path`: print the data folder.
158    Path,
159    /// `update`: install the latest release.
160    Update,
161    /// `completions SHELL`: print a tab completion script.
162    Completions(completions::Shell),
163    /// `complete ids|projects|tags …`: what the completion scripts offer, one per line. The words after
164    /// it are the command line being completed, ignored but for its `--project`.
165    Complete(completions::Candidates),
166    /// `--version` / `-V`.
167    Version,
168    /// `--help` / `-h`.
169    Help,
170}
171
172/// Options of `kb list`.
173#[derive(Debug, PartialEq, Eq)]
174pub struct ListArgs {
175    /// Task statuses to show, in list order.
176    pub statuses: Vec<Status>,
177    /// Notes and/or decisions to show after the tasks, in that order.
178    pub kinds: Vec<Kind>,
179    /// Only entries with this tag.
180    pub tag: Option<String>,
181    /// Print a JSON array instead of markdown.
182    pub json: bool,
183}
184
185/// Options of `kb report`.
186#[derive(Debug, PartialEq, Eq)]
187pub struct ReportArgs {
188    /// Period length; the current one is reported.
189    pub unit: Unit,
190    /// Rows per task or per tag.
191    pub group: Group,
192    /// Only count tasks with this tag.
193    pub tag: Option<String>,
194    /// Also write the report to `reports/`.
195    pub save: bool,
196}
197
198impl Command {
199    /// Parses the arguments after the program name.
200    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    /// Runs the command on the brain of `project` (by name), else the one found from here.
254    /// Everything except `--version` and `--help` refuses to run as root.
255    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                // Claude Code sends the event as JSON on stdin; typed at a terminal, there's none to wait for.
291                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                // Not through `Store::open_named`, which would create the folder of a deleted repository.
304                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(&central, 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(&central, &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(&central),
332            Command::Entries(args) => {
333                let author = author()?;
334                // Held from reading the files to writing them, so parallel commands each see the others'
335                // changes; not by `wait`, which would hold it while it waits.
336                let _lock = (!matches!(args.command, EntryCommand::Wait(..))).then(|| store.lock()).transpose()?;
337                args.run(Brain { author, ..Brain::open_in(store, &central)? })
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, &central)?.iter().for_each(|line| println!("{line}"));
346                Ok(())
347            }
348            // Handled above, before touching the data folder.
349            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    /// Parses the options after `list`.
363    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                // Anything else must be a status name.
379                other => match Status::parse(other) {
380                    Some(status) => statuses.push(status),
381                    None => bail!("unknown list option `{other}`"),
382                },
383            }
384        }
385        // Asking only for notes or decisions lists only those.
386        if statuses.is_empty() && kinds.is_empty() {
387            statuses.extend(DEFAULT_LIST);
388        }
389        // List order, without repeats.
390        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    /// Parses the options after `report`.
400    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                // Anything else must be a period name.
415                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
425/// Environment variables coding agents set in the commands they run, so `kb` can tell them from a
426/// person at a terminal: the generic `AGENT` and `AI_AGENT`, then Claude Code's, Codex's and Gemini CLI's.
427const AGENT_VARIABLES: &[&str] =
428    &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
429
430/// `init`'s arguments: an optional project name, and `--repo REPO` anywhere.
431fn 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
445/// Who this command runs for, recorded on the comments and status changes it makes: `KANBRAIN_AUTHOR`
446/// (`human` or `agent`) if set, else an agent if one of [`AGENT_VARIABLES`] is set, else a person.
447fn 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
457/// One line for status bars: `#id title HH:MM:SS` for each running session, or `idle`, then how many
458/// tasks wait for an answer, if any. Looks in every brain, wherever it's run from, since each runs its
459/// own tasks; a project's task says which project, and several are joined:
460/// `shop/api #3 Fix login 00:42:17 · blog #1 Post 00:05:00 · 2 waiting`.
461fn 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
486/// Prints the current period's report as markdown, and saves it with `--save`.
487fn 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
497/// What `list` prints, in order: one section per status (tasks only), then notes, then decisions,
498/// each entry carrying `args.tag` if one was given.
499fn 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
517/// The `list` word and section heading for notes and decisions.
518fn kind_heading(kind: Kind) -> &'static str {
519    match kind {
520        Kind::Task => "tasks",
521        Kind::Note => "notes",
522        Kind::Decision => "decisions",
523    }
524}
525
526/// One `## heading` section per non-empty section, each entry as `- #id title #tag …`.
527fn 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
545/// The same entries as `list_markdown`, in the same order, as a JSON array of summaries.
546fn 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;