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 chrono::{DateTime, Local};
32use std::fmt::Write;
33use std::io::{IsTerminal, Read};
34use std::path::{Path, PathBuf};
35
36/// The binary running the command line, named in the usage text and `--version`.
37#[derive(Debug, Clone, Copy)]
38pub struct Program {
39    /// The command's name.
40    pub name: &'static str,
41}
42
43impl Program {
44    /// Parses the arguments after the program name and runs the command.
45    /// A command-line error comes with the usage of the command it's about.
46    pub fn main(self, args: impl IntoIterator<Item = String>) -> Result<()> {
47        let (project, args) = take_project(args.into_iter().collect()).map_err(|e| self.misused(&e, None))?;
48        let name = args.first().cloned();
49        // `kb add --help` and `kb help add`: that command's usage, as an error after it would show.
50        // Right after the command, or `project`'s subcommand, only: in a comment's words (`kb comment 2 pass -h to
51        // it`), it's text.
52        let help = |flag: &String| flag == "--help" || flag == "-h";
53        let asked = match args.as_slice() {
54            [word, command] if word == "help" => Some(command.as_str()),
55            [command, flag, ..] if help(flag) => Some(command.as_str()),
56            [command, _, flag, ..] if command == "project" && help(flag) => Some(command.as_str()),
57            _ => None,
58        };
59        if let Some(command) = asked {
60            let lines = self.usage_of(command);
61            if lines.is_empty() {
62                bail!("unknown command `{command}`\n\n`{} --help` lists every command", self.name);
63            }
64            println!("usage:\n{}", lines.join("\n"));
65            return Ok(());
66        }
67        let command = Command::parse(args).map_err(|e| self.misused(&e, name.as_deref()))?;
68        command.run(self, project.as_deref())
69    }
70
71    /// `error`, then the usage lines of `command` (when it's one), and where the rest is: the whole usage would bury
72    /// the one line that matters.
73    fn misused(self, error: &anyhow::Error, command: Option<&str>) -> anyhow::Error {
74        let lines = command.map(|c| self.usage_of(c)).unwrap_or_default();
75        let help = format!("`{} --help` lists every command", self.name);
76        if lines.is_empty() {
77            anyhow!("{error:#}\n\n{help}")
78        } else {
79            anyhow!("{error:#}\n\nusage:\n{}\n\n{help}", lines.join("\n"))
80        }
81    }
82
83    /// The usage lines of `command`, from the whole usage; none for what isn't one.
84    fn usage_of(self, command: &str) -> Vec<String> {
85        let usage = usage(self);
86        let header = format!("  {} ", self.name);
87        let mut lines = Vec::new();
88        let (mut inside, mut bare) = (false, false);
89        for line in usage.lines() {
90            if let Some(rest) = line.strip_prefix(&header) {
91                let names = rest.split_whitespace().next().unwrap_or_default();
92                // `accept` shares the description under `reject`'s line: a matched line with none yet, on it or
93                // under it, takes the next.
94                inside = names.split('|').any(|n| n == command) || bare;
95                bare = inside && !bare && !rest.contains("   ");
96            } else if !(inside && line.starts_with("   ")) {
97                inside = false;
98            } else {
99                bare = false;
100            }
101            if inside {
102                lines.push(line.to_string());
103            }
104        }
105        lines
106    }
107}
108
109/// Takes `--project NAME` out of the arguments, wherever it is before a `--` (see [`options`]): it picks the brain for
110/// any command.
111fn take_project(mut args: Vec<String>) -> Result<(Option<String>, Vec<String>)> {
112    let Some(at) = options(&args).iter().position(|a| a == "--project") else { return Ok((None, args)) };
113    if at + 1 >= args.len() {
114        bail!("--project needs a project name (or `{}`)", projects::CENTRAL);
115    }
116    let name = args.remove(at + 1);
117    args.remove(at);
118    Ok((Some(name), args))
119}
120
121/// The arguments that may be options: those before a `--`, after which every word is text (`kb comment 3 -- use
122/// --json`), as most command lines have it.
123pub(crate) fn options(args: &[String]) -> &[String] {
124    args.iter().position(|a| a == "--").map_or(args, |at| &args[..at])
125}
126
127/// Printed by `--help` and after a command-line error. The status, period and group names come
128/// from `Status`, `Unit` and `Group`, so they're only written there.
129pub fn usage(program: Program) -> String {
130    let name = program.name;
131    let pad = " ".repeat(name.len());
132    format!(
133        "{name} — project memory for you and your coding agents: tasks, notes and decisions in markdown files
134
135usage:
136  {name} list [{statuses}|all] [notes] [decisions] [--tag TAG] [--json]
137  {pad}           print tasks as markdown, one section per status, then notes and decisions
138  {pad}           (default: {default}; several can be given; notes or decisions alone
139  {pad}           list only those)
140  {name} init [NAME] [--repo REPO]
141  {pad}           make this folder a project: its own .brain/, registered as NAME (default: the
142  {pad}           folder's name) with a card for the application, and a section telling its agents
143  {pad}           about it in CLAUDE.md / AGENTS.md (run again to update them); in a project with
144  {pad}           several, the repository is named REPO (default: the folder's, made unique)
145  {name} init --all
146  {pad}           update the agents' section and hooks in every registered project to this version's
147  {pad}           (the installer and update run it)
148  {name} deinit [--purge]
149  {pad}           stop this folder (or --project NAME) being a project: unregister it, remove its
150  {pad}           section from CLAUDE.md / AGENTS.md, the other repositories' links to it and, with
151  {pad}           its last repository, the project's card; its .brain/ is kept unless --purge
152  {name} hook prompt|stop
153  {pad}           what the Claude Code hooks `init` adds run: they hold an agent to describing an
154  {pad}           undescribed project first, and to commenting on the tasks it started (they read the
155  {pad}           hook's JSON on stdin)
156  {name} status    print the running tasks, in whichever project, and how many wait for an answer
157  {pad}           (for status bars)
158  {name} report [{units}] [--group {groups}] [--tag TAG] [--save]
159  {pad}           print the current period's tracked time as markdown
160  {pad}           (default: {unit}, grouped by {group}); --save also writes it to reports/
161{entries}
162  {name} path      print the data directory in use (a project's .brain/ inside it)
163  {name} update    install the latest release over this one (and the other app, if it's beside it)
164  {name} completions bash|zsh|fish
165  {pad}           print a tab completion script for the shell (commands, ids, projects, tags)
166  {name} --version print the version
167
168--project NAME, anywhere on the line, runs a command on a registered project's brain (or `central`).
169--json prints JSON instead, for list, add, show, search, start|park|done|todo, comment, ask, propose, accept,
170reject, describe and link.
171-- ends a text's options: every word after it is the text (`{name} comment 3 -- use --json`).
172Data lives in the nearest .brain/ folder here or above (see init); else in the central brain:
173$KANBRAIN_DIR, else the platform data dir (e.g. ~/.local/share/kanbrain).
174The kanban board, `kanbrain` without arguments, shows the same data.
175
176kanbrain by singudotdev (https://github.com/singudotdev/kanbrain). AGPL-3.0-only with the additional terms in
177NOTICE; no warranty.",
178        statuses = Status::names("|"),
179        default = DEFAULT_LIST.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
180        units = Unit::names("|"),
181        groups = Group::names("|"),
182        unit = DEFAULT_REPORT_UNIT.as_str(),
183        group = Group::default().as_str(),
184        entries = entries::USAGE
185            .lines()
186            .map(|line| {
187                // Commands start at the margin and get the program's name; descriptions under them are
188                // indented further and get blanks instead, so they stay aligned.
189                let rest = line.strip_prefix("  ").unwrap_or(line);
190                format!("  {} {rest}", if rest.starts_with(' ') { &pad } else { name })
191            })
192            .collect::<Vec<_>>()
193            .join("\n"),
194    )
195}
196
197/// What `kb list` shows unless statuses are given: everything that isn't done.
198const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
199
200/// The period `kb report` shows unless another one is given.
201const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
202
203/// What to do, as chosen on the command line.
204#[derive(Debug, PartialEq, Eq)]
205pub enum Command {
206    /// `list`: print tasks as markdown.
207    List(ListArgs),
208    /// `init [NAME] [--repo REPO]`: make the current directory a project, named `NAME` or after the folder,
209    /// and the repository `REPO` in it.
210    Init(Option<String>, Option<String>),
211    /// `init --all`: bring every registered repository's agent instructions up to this version's.
212    InitAll,
213    /// `deinit [--purge]`: undo `init` for the current directory's project, or the one `--project` names;
214    /// `true` also deletes its `.brain/`.
215    Deinit(bool),
216    /// `hook prompt|stop`: what Claude Code's hooks run (see [`hook`]).
217    Hook(hook::HookEvent),
218    /// `status`: print the running tasks for status bars.
219    Status,
220    /// `report`: print a time report as markdown.
221    Report(ReportArgs),
222    /// `add`, `show`, `search`, `start`, `comment`, …: entries by id, for agents and scripts.
223    Entries(EntryArgs),
224    /// `path`: print the data folder.
225    Path,
226    /// `update`: install the latest release.
227    Update,
228    /// `completions SHELL`: print a tab completion script.
229    Completions(completions::Shell),
230    /// `complete ids|projects|tags …`: what the completion scripts offer, one per line. The words after
231    /// it are the command line being completed, ignored but for its `--project`.
232    Complete(completions::Candidates),
233    /// `--version` / `-V`.
234    Version,
235    /// `--help` / `-h`.
236    Help,
237}
238
239/// Options of `kb list`.
240#[derive(Debug, PartialEq, Eq)]
241pub struct ListArgs {
242    /// Task statuses to show, in list order.
243    pub statuses: Vec<Status>,
244    /// Notes and/or decisions to show after the tasks, in that order.
245    pub kinds: Vec<Kind>,
246    /// Only entries with this tag.
247    pub tag: Option<String>,
248    /// Print a JSON array instead of markdown.
249    pub json: bool,
250}
251
252/// Options of `kb report`.
253#[derive(Debug, PartialEq, Eq)]
254pub struct ReportArgs {
255    /// Period length; the current one is reported.
256    pub unit: Unit,
257    /// Rows per task or per tag.
258    pub group: Group,
259    /// Only count tasks with this tag.
260    pub tag: Option<String>,
261    /// Also write the report to `reports/`.
262    pub save: bool,
263}
264
265impl Command {
266    /// Parses the arguments after the program name.
267    pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
268        let mut args = args.into_iter();
269        let command = match args.next().as_deref() {
270            Some("list") => Command::List(ListArgs::parse(args.by_ref())?),
271            Some("init") => {
272                let words: Vec<String> = args.by_ref().collect();
273                match words.as_slice() {
274                    [all] if all == "--all" => Command::InitAll,
275                    _ => init_args(words)?,
276                }
277            }
278            Some("deinit") => Command::Deinit(match args.next() {
279                None => false,
280                Some(purge) if purge == "--purge" => true,
281                Some(other) => bail!("unexpected argument `{other}`"),
282            }),
283            Some("hook") => {
284                let name =
285                    args.next().with_context(|| format!("hook needs one of {}", hook::HookEvent::names(", ")))?;
286                Command::Hook(
287                    hook::HookEvent::parse(&name)
288                        .with_context(|| format!("unknown hook `{name}`: {}", hook::HookEvent::names(", ")))?,
289                )
290            }
291            Some("status") => Command::Status,
292            Some("report") => Command::Report(ReportArgs::parse(args.by_ref())?),
293            Some("path") => Command::Path,
294            Some("update") => Command::Update,
295            Some("completions") => {
296                let name = args
297                    .next()
298                    .with_context(|| format!("completions needs one of {}", completions::Shell::names(", ")))?;
299                Command::Completions(
300                    completions::Shell::parse(&name)
301                        .with_context(|| format!("unknown shell `{name}`: {}", completions::Shell::names(", ")))?,
302                )
303            }
304            Some("complete") => {
305                let name = args
306                    .next()
307                    .with_context(|| format!("complete needs one of {}", completions::Candidates::names(", ")))?;
308                let what = completions::Candidates::parse(&name).with_context(|| {
309                    format!("unknown candidates `{name}`: {}", completions::Candidates::names(", "))
310                })?;
311                args.by_ref().for_each(drop);
312                Command::Complete(what)
313            }
314            Some("-V" | "--version") => Command::Version,
315            None | Some("-h" | "--help" | "help") => Command::Help,
316            Some(other) => match EntryArgs::parse(other, args.by_ref())? {
317                Some(entries) => Command::Entries(entries),
318                None => bail!("unknown command `{other}`"),
319            },
320        };
321        if let Some(extra) = args.next() {
322            bail!("unexpected argument `{extra}`");
323        }
324        Ok(command)
325    }
326
327    /// Runs the command on the brain of `project` (by name), else the one found from here.
328    /// Everything except `--version` and `--help` refuses to run as root.
329    pub fn run(self, program: Program, project: Option<&str>) -> Result<()> {
330        match self {
331            Command::Version => {
332                println!("{} {}", program.name, env!("CARGO_PKG_VERSION"));
333                return Ok(());
334            }
335            Command::Help => {
336                println!("{}", usage(program));
337                return Ok(());
338            }
339            Command::Completions(shell) => {
340                print!("{}", completions::script(shell, program.name));
341                return Ok(());
342            }
343            Command::Update => {
344                platform::ensure_not_root()?;
345                return update::update(&projects::central_dir()?);
346            }
347            Command::Init(name, repo) => {
348                platform::ensure_not_root()?;
349                if project.is_some() {
350                    bail!("init works on the current directory; leave out --project");
351                }
352                let root = std::env::current_dir().context("reading the current directory")?;
353                init::init(&root, name.as_deref(), repo.as_deref(), &projects::central_dir()?)?
354                    .iter()
355                    .for_each(|line| println!("{line}"));
356                return Ok(());
357            }
358            Command::InitAll => {
359                platform::ensure_not_root()?;
360                init::init_all(&projects::central_dir()?)?.iter().for_each(|line| println!("{line}"));
361                return Ok(());
362            }
363            Command::Hook(event) => {
364                // Claude Code sends the event as JSON on stdin; typed at a terminal, there's none to wait for.
365                let mut input = String::new();
366                if !std::io::stdin().is_terminal() {
367                    std::io::stdin().read_to_string(&mut input).context("reading the hook's input")?;
368                }
369                let here = std::env::current_dir().context("reading the current directory")?;
370                if let Some(out) = hook::respond(event, &input, &projects::central_dir()?, &here) {
371                    println!("{out}");
372                }
373                return Ok(());
374            }
375            Command::Deinit(purge) => {
376                platform::ensure_not_root()?;
377                let central = projects::central_dir()?;
378                let dir = deinit_dir(&central, project)?;
379                manage::remove_repo(&central, &dir, purge)?.iter().for_each(|line| println!("{line}"));
380                return Ok(());
381            }
382            _ => platform::ensure_not_root()?,
383        }
384        let store = Store::open_named(project)?;
385        let central = projects::central_dir()?;
386        match self {
387            Command::List(args) => {
388                // Opened as the entry commands open it, repairs and all, so the ids it lists are the ones they take:
389                // a sync conflict copy listed under its original's id would be ambiguous.
390                let _lock = store.lock()?;
391                let brain = Brain::open_in(store, &central)?;
392                say_skipped(&brain);
393                let tasks = &brain.tasks;
394                if args.json {
395                    println!("{}", list_json(tasks, &args)?);
396                } else {
397                    match list_markdown(tasks, &args) {
398                        // As `search` says it, rather than nothing a reader could take for a failure.
399                        empty if empty.is_empty() => println!("nothing to list"),
400                        listed => print!("{listed}"),
401                    }
402                }
403                Ok(())
404            }
405            Command::Status => {
406                println!("{}", status_line(&central, now())?);
407                Ok(())
408            }
409            Command::Entries(args) => run_entries(store, &central, args),
410            Command::Report(args) => print_report(store, &args),
411            Command::Path => {
412                println!("{}", store.dir().display());
413                Ok(())
414            }
415            Command::Complete(what) => {
416                completions::candidates(what, store, &central)?.iter().for_each(|line| println!("{line}"));
417                Ok(())
418            }
419            // Handled above, before touching the data folder.
420            Command::Version
421            | Command::Help
422            | Command::Completions(_)
423            | Command::Update
424            | Command::Init(..)
425            | Command::InitAll
426            | Command::Deinit(_)
427            | Command::Hook(_) => Ok(()),
428        }
429    }
430}
431
432impl ListArgs {
433    /// Parses the options after `list`.
434    fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
435        let mut statuses = Vec::new();
436        let mut kinds = Vec::new();
437        let mut tag = None;
438        let mut json = false;
439        while let Some(arg) = args.next() {
440            match arg.as_str() {
441                "--json" => json = true,
442                "notes" | "note" => kinds.push(Kind::Note),
443                "decisions" | "decision" => kinds.push(Kind::Decision),
444                "--tag" => {
445                    let value = args.next().context("--tag needs a value")?;
446                    // One tag: a second would be dropped without a word.
447                    let mut tags = parse_tags(&value).into_iter();
448                    tag = tags.next();
449                    if tags.next().is_some() {
450                        bail!("--tag takes one tag, not `{value}`");
451                    }
452                }
453                "all" => statuses.extend(Status::ALL),
454                // Anything else must be a status name.
455                other => match Status::parse(other) {
456                    Some(status) => statuses.push(status),
457                    None => bail!("unknown list option `{other}`"),
458                },
459            }
460        }
461        // Asking only for notes or decisions lists only those.
462        if statuses.is_empty() && kinds.is_empty() {
463            statuses.extend(DEFAULT_LIST);
464        }
465        // List order, without repeats.
466        statuses.sort_by_key(|s| s.position());
467        statuses.dedup();
468        kinds.sort_by_key(|k| k.position());
469        kinds.dedup();
470        Ok(Self { statuses, kinds, tag, json })
471    }
472}
473
474impl ReportArgs {
475    /// Parses the options after `report`.
476    fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
477        let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
478        while let Some(arg) = args.next() {
479            match arg.as_str() {
480                "--group" => {
481                    let groups = Group::names(" or ");
482                    let value = args.next().with_context(|| format!("--group needs {groups}"))?;
483                    report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
484                }
485                "--tag" => {
486                    let value = args.next().context("--tag needs a value")?;
487                    report.tag = parse_tags(&value).into_iter().next();
488                }
489                "--save" => report.save = true,
490                // Anything else must be a period name.
491                other => match Unit::parse(other) {
492                    Some(unit) => report.unit = unit,
493                    None => bail!("unknown report option `{other}`"),
494                },
495            }
496        }
497        Ok(report)
498    }
499}
500
501/// Environment variables coding agents set in the commands they run, so `kb` can tell them from a
502/// person at a terminal: the generic `AGENT` and `AI_AGENT`, then Claude Code's, Codex's and Gemini CLI's.
503const AGENT_VARIABLES: &[&str] =
504    &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
505
506/// `init`'s arguments: an optional project name, and `--repo REPO` anywhere.
507fn init_args(words: Vec<String>) -> Result<Command> {
508    let mut words = words.into_iter();
509    let (mut name, mut repo) = (None, None);
510    while let Some(word) = words.next() {
511        match word.as_str() {
512            "--repo" => repo = Some(words.next().context("--repo needs the repository's name")?),
513            other if other.starts_with("--") => bail!("unknown init option `{other}`"),
514            _ if name.is_none() => name = Some(word),
515            _ => bail!("unexpected argument `{word}`"),
516        }
517    }
518    Ok(Command::Init(name, repo))
519}
520
521/// Who this command runs for, recorded on the comments and status changes it makes: `KANBRAIN_AUTHOR`
522/// (`human` or `agent`, in any case) if set and not empty, as the agents' variables are read, else an agent if one
523/// of [`AGENT_VARIABLES`] is set, else a person. `var` reads a variable, as `std::env::var_os` does for the command
524/// line.
525fn author(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Result<Author> {
526    if let Some(value) = var("KANBRAIN_AUTHOR").filter(|v| !v.is_empty()) {
527        let value = value.to_string_lossy();
528        return Author::parse(&value)
529            .with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
530    }
531    let agent = AGENT_VARIABLES.iter().any(|name| var(name).is_some_and(|v| !v.is_empty()));
532    Ok(if agent { Author::Agent } else { Author::Human })
533}
534
535/// One line for status bars: `#id title HH:MM:SS` for each running session, or `idle`, then how many tasks wait for an
536/// answer, if any. Looks in every brain, wherever it's run from, since each runs its own tasks; a project's task says
537/// which project, and several are joined: `shop/api #3 Fix login 00:42:17 · blog #1 Post 00:05:00 · 2 waiting`. A brain
538/// it can't read (one a newer kanbrain wrote, say) is left out rather than the whole line lost.
539fn status_line(central: &Path, now: DateTime<Local>) -> Result<String> {
540    let mut brains = vec![(None, central.to_path_buf())];
541    let repos = projects::load(central)?;
542    brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
543    let mut running = Vec::new();
544    let mut waiting = 0;
545    for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
546        let Ok((tasks, _)) = Store::at(&dir).and_then(|store| store.load()) else { continue };
547        let project = name.map(|n| format!("{n} ")).unwrap_or_default();
548        for (task, clock) in tasks.iter().filter_map(|t| Some((t, t.running_for(now)?))) {
549            running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
550        }
551        waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
552    }
553    if running.is_empty() {
554        running.push("idle".to_string());
555    }
556    if waiting > 0 {
557        running.push(format!("{waiting} waiting"));
558    }
559    Ok(running.join(" · "))
560}
561
562/// The `.brain/` `deinit` takes out: `project`'s, else the one here. Not through `Store::open_named`, which refuses a
563/// deleted repository: this is how it's taken out.
564fn deinit_dir(central: &Path, project: Option<&str>) -> Result<PathBuf> {
565    let central_refused = || anyhow!("the central brain isn't a project: there's nothing to deinit");
566    match project {
567        Some(projects::CENTRAL) => Err(central_refused()),
568        Some(label) => match projects::find(central, label) {
569            Ok(repo) => Ok(repo.dir),
570            Err(_) if label.eq_ignore_ascii_case(projects::CENTRAL) => Err(central_refused()),
571            Err(e) => Err(e),
572        },
573        None => {
574            let here = std::env::current_dir().context("reading the current directory")?;
575            store::find_project(&here)
576                .context("no .brain/ here or above: run deinit inside a project, or name one with --project")
577        }
578    }
579}
580
581/// Runs one of the entry commands on the brain in `store`.
582fn run_entries(store: Store, central: &Path, mut args: EntryArgs) -> Result<()> {
583    let author = author(|name| std::env::var_os(name))?;
584    // Before the lock: `-` waits for stdin, maybe for someone typing, and every other `kb` and the app would wait
585    // meanwhile.
586    args.read_stdin()?;
587    // A project change writes the central brain's registry or cards too, under its lock: taken first, in the order
588    // the app takes them, or this and the app could each hold one lock and wait forever for the other's.
589    let _central = match args.command {
590        EntryCommand::Project(Some(_)) => Some(Store::at(central)?.lock()?),
591        _ => None,
592    };
593    // Held from reading the files to writing them, so parallel commands each see the others' changes; opening may
594    // write too, as it repairs. `wait` lets go once open, not to hold it while it waits.
595    let lock = store.lock()?;
596    let brain = Brain { author, ..Brain::open_in(store, central)? };
597    say_skipped(&brain);
598    let _lock = (!matches!(args.command, EntryCommand::Wait(..))).then_some(lock);
599    args.run(brain)
600}
601
602/// Says on stderr which entries' files couldn't be read: left out silently, an agent told `no entry #7` would add
603/// the task again, though its file is there. A file is an entry's when it's broken as only one is (a bad history
604/// line, say), or named as one (`0007-title.md`); markdown of other names, or dated (`2024-01-15.md`), with no id is
605/// left unsaid: a central brain kept in a notes vault holds many.
606fn say_skipped(brain: &Brain) {
607    let entry_file = |warning: &&String| {
608        let digits = warning.chars().take_while(char::is_ascii_digit).count();
609        let named = digits >= 4
610            && warning[digits..]
611                .strip_prefix('-')
612                .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_lowercase()));
613        named || !warning.contains("missing `- id:` line")
614    };
615    for warning in brain.warnings.iter().filter(entry_file) {
616        eprintln!("kb: skipped {warning}; fix the file or remove it");
617    }
618}
619
620/// Prints the current period's report as markdown, and saves it with `--save`.
621fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
622    // Opening may repair files, and `--save` writes one.
623    let _lock = store.lock()?;
624    let brain = Brain::open(store)?;
625    let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
626    print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
627    if args.save {
628        eprintln!("saved {}", brain.export_report(&view)?.display());
629    }
630    Ok(())
631}
632
633/// What `list` prints, in order: one section per status (tasks only), then notes, then decisions,
634/// each entry carrying `args.tag` if one was given.
635fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
636    let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
637    let mut sections = Vec::new();
638    for &status in &args.statuses {
639        let mut matching: Vec<&Task> =
640            tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
641        matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
642        sections.push((status.as_str(), matching));
643    }
644    for &kind in &args.kinds {
645        let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
646        matching.sort_by_key(|t| std::cmp::Reverse(t.id));
647        sections.push((kind_heading(kind), matching));
648    }
649    sections.retain(|(_, entries)| !entries.is_empty());
650    sections
651}
652
653/// The `list` word and section heading for notes and decisions.
654fn kind_heading(kind: Kind) -> &'static str {
655    match kind {
656        Kind::Task => "tasks",
657        Kind::Note => "notes",
658        Kind::Decision => "decisions",
659    }
660}
661
662/// One `## heading` section per non-empty section, each entry as `- #id title #tag …`, then ` · ` what its section
663/// doesn't say (`waiting for an answer`, `proposed`…).
664fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
665    let mut out = String::new();
666    for (heading, entries) in list_sections(tasks, args) {
667        if !out.is_empty() {
668            out.push('\n');
669        }
670        writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
671        for task in entries {
672            out.push_str("- ");
673            crate::api::text::head(&mut out, task).expect("writing to a String cannot fail");
674            // As `context` says it: a parked task's question, a decision not in force.
675            if let Some(note) = crate::api::text::state_note(task) {
676                write!(out, " · {note}").expect("writing to a String cannot fail");
677            }
678            out.push('\n');
679        }
680    }
681    out
682}
683
684/// The same entries as `list_markdown`, in the same order, as a JSON array of summaries on one line, as every
685/// command's JSON is.
686fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
687    let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
688    Ok(serde_json::to_string(&entries)?)
689}
690
691#[cfg(test)]
692#[path = "../../tests/cli.rs"]
693mod tests;