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