Skip to main content

kanbrain_core/cli/
entries.rs

1//! Commands that read or change entries by id, for agents and scripts: `add`, `show`, `search`,
2//! `start`/`park`/`done`/`todo`, `comment`, `ask`, `wait`, `propose`, `accept`/`reject`, `describe`, `link`
3//! and `context`. Each prints a short confirmation, or with `--json`, the entry (or entries) as JSON.
4
5use super::manage;
6use crate::api::ApiError;
7use crate::api::json::{Entry, Summary};
8use crate::api::text::line;
9use crate::brain::{Brain, NewEntry};
10use crate::model::{Asked, DecisionState, Kind, RepoLink, Status, Task, parse_links, parse_tags};
11use crate::projects::ProjectInfo;
12use anyhow::{Context, Result, bail};
13use std::io::Read;
14use std::time::Duration;
15
16/// After the context: how to act on what it lists, for agents that haven't seen the CLI before. Left
17/// out in a project, whose agents have the commands in the section `init` wrote.
18const CONTEXT_USAGE: &str = "\
19Commands: `kb show ID` (everything about an entry), `search WORDS`, \
20`add [--note|--decision] [--tag T] [--link ID] [--body TEXT|-] TITLE`, \
21`comment ID TEXT|-`, `ask ID TEXT|-`, `wait ID`, `start|park|done|todo ID`, `link ID OTHER…` (`REPO#ID` in another repository). Add `--json` for JSON.";
22
23/// Usage lines for these commands, appended to the main usage text.
24pub const USAGE: &str = "\
25  add [--note|--decision] [--tag TAG]… [--link ID]… [--body TEXT|-] [--start] TITLE…
26            create a task (or note, decision) and print its id; `-` reads the body from stdin
27  show ID   print an entry compactly: state, time, description, comments, related, history
28  search WORDS…
29            find entries by title, tags, description or comments (#tag: tags only)
30  start|park|done|todo ID
31            change a task's status (start runs its clock; a person runs one task at a time)
32  comment ID TEXT…|-
33            add a comment, e.g. progress notes
34  ask ID TEXT…|-
35            ask the user a question on a task and park it until a person answers with a comment
36  wait ID [--timeout MINUTES]
37            block until a person answers what task ID waits on (or decision ID is accepted or
38            rejected), then print the answer; gives up after 8 minutes (0: never)
39  propose ID [--tag TAG]… [--link ID]… [--body TEXT|-] TITLE…
40            propose a decision for a task: linked to it, which asks about it and is parked
41  accept ID [TEXT…|-]
42  reject ID TEXT…|-
43            accept or reject a decision, with why (needed to reject); answers the tasks waiting on it
44  describe ID TEXT…|-
45            replace the description
46  link ID OTHER…
47            link an entry to others: an id here, or REPO#ID in another repository of the project
48  project [describe|notes TEXT…|-]
49            show the project's description (shared by its repositories) and this repository's role,
50            or replace one
51  project rename NAME… | repo NAME | move PROJECT… | remove [--purge]
52            rename the project, or this repository in it; move this repository to another project;
53            take every repository of the project out (--purge: and delete their tasks)
54  context   what's going on: active tasks, decisions and notes, for an agent to read first
55            (all but project take --json to print JSON instead)";
56
57/// One of the entry commands, and whether to print JSON.
58#[derive(Debug, PartialEq, Eq)]
59pub struct EntryArgs {
60    /// What to do.
61    pub command: EntryCommand,
62    /// `--json`: print the result as JSON.
63    pub json: bool,
64}
65
66/// What to do to which entry.
67#[derive(Debug, PartialEq, Eq)]
68pub enum EntryCommand {
69    /// `add`: create an entry.
70    Add(AddArgs),
71    /// `show ID`.
72    Show(u32),
73    /// `search WORDS…`.
74    Search(String),
75    /// `start|park|done|todo ID`.
76    SetStatus(u32, Status),
77    /// `comment ID TEXT…|-`.
78    Comment(u32, Text),
79    /// `ask ID TEXT…|-`: a question to the user, parking the task.
80    Ask(u32, Text),
81    /// `wait ID [--timeout MINUTES]`: block until a person answers, or the time is up (`None`: never).
82    Wait(u32, Option<Duration>),
83    /// `propose ID … TITLE…`: a proposed decision for the task.
84    Propose(u32, AddArgs),
85    /// `accept ID [TEXT…|-]` / `reject ID TEXT…|-`: a decision accepted or rejected, and why.
86    Resolve(u32, DecisionState, Option<Text>),
87    /// `describe ID TEXT…|-`.
88    Describe(u32, Text),
89    /// `link ID OTHER…`: ids here, and `REPO#ID` in the project's other repositories.
90    Link(u32, Vec<u32>, Vec<RepoLink>),
91    /// `context`.
92    Context,
93    /// `project`: show the project's description and this repository's role, or change the project.
94    Project(Option<ProjectChange>),
95}
96
97/// What `project` changes.
98#[derive(Debug, PartialEq, Eq)]
99pub enum ProjectChange {
100    /// `describe|notes TEXT…|-`: replace one of its texts.
101    Text(ProjectPart, Text),
102    /// `rename NAME…`: the project's name.
103    Rename(String),
104    /// `repo NAME`: this repository's name in its project.
105    Repo(String),
106    /// `move PROJECT…`: this repository to another project, created if new.
107    Move(String),
108    /// `remove [--purge]`: take every repository of the project out of it; `true` also deletes their tasks.
109    Remove(bool),
110}
111
112/// What `project` changes in one of its texts.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ProjectPart {
115    /// `describe`: the project's description, on the card all its repositories share.
116    Description,
117    /// `notes`: this repository's role among the project's, in its `.brain/project.md`.
118    Notes,
119}
120
121/// Text given on the command line, or `-` to read it from stdin (for multi-line markdown).
122#[derive(Debug, PartialEq, Eq)]
123pub enum Text {
124    /// The words after the id, joined by spaces.
125    Given(String),
126    /// Read stdin to the end.
127    Stdin,
128}
129
130/// Options of `add`.
131#[derive(Debug, PartialEq, Eq, Default)]
132pub struct AddArgs {
133    /// `--note` / `--decision`; a task otherwise.
134    pub kind: Kind,
135    /// The remaining words.
136    pub title: String,
137    /// `--tag`, repeatable.
138    pub tags: Vec<String>,
139    /// `--link`, repeatable: ids here.
140    pub links: Vec<u32>,
141    /// `--link REPO#ID`: entries of the project's other repositories.
142    pub repo_links: Vec<RepoLink>,
143    /// `--body`.
144    pub body: Option<Text>,
145    /// `--start`.
146    pub start: bool,
147}
148
149impl EntryArgs {
150    /// Parses `name` and the arguments after it, if `name` is one of these commands.
151    pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
152        // `--json` may go anywhere.
153        let mut json = false;
154        let mut rest = Vec::new();
155        for arg in args {
156            if arg == "--json" {
157                json = true;
158            } else {
159                rest.push(arg);
160            }
161        }
162        let status = match name {
163            "start" => Some(Status::Doing),
164            "park" => Some(Status::Parked),
165            "done" => Some(Status::Done),
166            "todo" => Some(Status::Todo),
167            _ => None,
168        };
169        let command = match (name, status) {
170            (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
171            ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..))?),
172            ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
173            ("search", None) => EntryCommand::Search(std::mem::take(&mut rest).join(" ")),
174            ("comment", None) => {
175                let (id, text) = id_and_text(&mut rest)?;
176                EntryCommand::Comment(id, text)
177            }
178            ("ask", None) => {
179                let (id, text) = id_and_text(&mut rest)?;
180                EntryCommand::Ask(id, text)
181            }
182            ("wait", None) => {
183                let id = first_id(&mut rest)?;
184                EntryCommand::Wait(id, wait_timeout(&mut rest)?)
185            }
186            ("propose", None) => {
187                let id = first_id(&mut rest)?;
188                let decision = AddArgs::parse(rest.drain(..))?;
189                if decision.kind != Kind::Task || decision.start {
190                    bail!("propose makes a decision: leave out --note, --decision and --start");
191                }
192                EntryCommand::Propose(id, decision)
193            }
194            ("accept", None) => {
195                let id = first_id(&mut rest)?;
196                let reason = if rest.is_empty() { None } else { Some(rest_as_text(&mut rest)?) };
197                EntryCommand::Resolve(id, DecisionState::Accepted, reason)
198            }
199            ("reject", None) => {
200                let id = first_id(&mut rest)?;
201                let reason = rest_as_text(&mut rest).context("reject needs why: TEXT, or - to read it from stdin")?;
202                EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
203            }
204            ("describe", None) => {
205                let (id, text) = id_and_text(&mut rest)?;
206                EntryCommand::Describe(id, text)
207            }
208            ("link", None) => {
209                let id = first_id(&mut rest)?;
210                let (others, repo_links) = parse_links(&std::mem::take(&mut rest).join(" "));
211                if others.is_empty() && repo_links.is_empty() {
212                    bail!("link needs the ids to link to");
213                }
214                EntryCommand::Link(id, others, repo_links)
215            }
216            ("context", None) => EntryCommand::Context,
217            ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
218            _ => return Ok(None),
219        };
220        if let Some(extra) = rest.first() {
221            bail!("unexpected argument `{extra}`");
222        }
223        Ok(Some(Self { command, json }))
224    }
225
226    /// Runs the command against `brain` and prints the result.
227    pub fn run(self, mut brain: Brain) -> Result<()> {
228        let json = self.json;
229        let show = matches!(self.command, EntryCommand::Show(_));
230        let id = match self.command {
231            EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
232            EntryCommand::Show(id) => {
233                brain.entry(id)?;
234                id
235            }
236            EntryCommand::Search(query) => return print_search(&brain, &query, json),
237            EntryCommand::SetStatus(id, status) => {
238                brain.change_status(id, status)?;
239                id
240            }
241            EntryCommand::Comment(id, text) => {
242                brain.comment(id, &text.read()?)?;
243                id
244            }
245            EntryCommand::Ask(id, text) => {
246                brain.ask(id, &text.read()?)?;
247                id
248            }
249            EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
250            EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
251            EntryCommand::Propose(task, args) => {
252                let decision = brain.propose(task, args.into_new_entry()?)?;
253                if !json {
254                    println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
255                    return Ok(());
256                }
257                decision
258            }
259            EntryCommand::Resolve(id, state, reason) => {
260                let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
261                brain.resolve(id, state, &reason)?;
262                id
263            }
264            EntryCommand::Describe(id, text) => {
265                brain.describe(id, &text.read()?)?;
266                id
267            }
268            EntryCommand::Link(id, others, repo_links) => {
269                brain.link(id, &others, &repo_links)?;
270                id
271            }
272            EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
273            EntryCommand::Project(_) if json => bail!("project is markdown only"),
274            EntryCommand::Project(change) => return run_project(&mut brain, change),
275            EntryCommand::Context => {
276                let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
277                println!("{}{usage}", brain.context_markdown());
278                return Ok(());
279            }
280        };
281        print_entry(&brain, id, json, show)
282    }
283}
284
285impl AddArgs {
286    /// Parses the options and title words after `add`.
287    fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
288        let mut add = Self::default();
289        let mut title = Vec::new();
290        while let Some(arg) = args.next() {
291            match arg.as_str() {
292                "--note" => add.kind = Kind::Note,
293                "--decision" => add.kind = Kind::Decision,
294                "--start" => add.start = true,
295                "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
296                "--link" => {
297                    let (links, repo_links) = parse_links(&args.next().context("--link needs an id")?);
298                    add.links.extend(links);
299                    add.repo_links.extend(repo_links);
300                }
301                "--body" => {
302                    let body = args.next().context("--body needs text, or - to read stdin")?;
303                    add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
304                }
305                other if other.starts_with("--") => bail!("unknown add option `{other}`"),
306                _ => title.push(arg),
307            }
308        }
309        add.title = title.join(" ");
310        if add.title.trim().is_empty() {
311            bail!("add needs a title");
312        }
313        if add.start && add.kind != Kind::Task {
314            bail!("only tasks can be started");
315        }
316        Ok(add)
317    }
318
319    /// The entry to create, reading the body from stdin if asked to.
320    fn into_new_entry(self) -> Result<NewEntry> {
321        Ok(NewEntry {
322            kind: self.kind,
323            title: self.title,
324            tags: self.tags,
325            links: self.links,
326            repo_links: self.repo_links,
327            description: self.body.map(Text::read).transpose()?,
328            start: self.start,
329        })
330    }
331}
332
333impl Text {
334    /// The text, reading stdin to the end for `-`.
335    fn read(self) -> Result<String> {
336        match self {
337            Text::Given(text) => Ok(text),
338            Text::Stdin => {
339                let mut text = String::new();
340                std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
341                Ok(text)
342            }
343        }
344    }
345}
346
347/// The first argument as an entry id (`12` or `#12`).
348fn first_id(args: &mut Vec<String>) -> Result<u32> {
349    if args.is_empty() {
350        bail!("an entry id is needed");
351    }
352    let arg = args.remove(0);
353    arg.trim_start_matches('#').parse().with_context(|| format!("`{arg}` isn't an entry id"))
354}
355
356/// An id, then the text: the remaining words, or `-` alone for stdin.
357fn id_and_text(args: &mut Vec<String>) -> Result<(u32, Text)> {
358    let id = first_id(args)?;
359    Ok((id, rest_as_text(args)?))
360}
361
362/// The remaining words as one text, or `-` alone for stdin.
363fn rest_as_text(args: &mut Vec<String>) -> Result<Text> {
364    let words = std::mem::take(args);
365    match words.as_slice() {
366        [] => bail!("text is needed, or - to read it from stdin"),
367        [dash] if dash == "-" => Ok(Text::Stdin),
368        _ => Ok(Text::Given(words.join(" "))),
369    }
370}
371
372/// What follows `project`: nothing, to show it; else what to change.
373fn project_args(args: &mut Vec<String>) -> Result<Option<ProjectChange>> {
374    if args.is_empty() {
375        return Ok(None);
376    }
377    let command = args.remove(0);
378    // A project's name may have spaces; a repository's is one word, which `project::check_repo_name` checks.
379    let words = |args: &mut Vec<String>, what: &str| match std::mem::take(args).join(" ") {
380        name if name.trim().is_empty() => bail!("project {command} needs {what}"),
381        name => Ok(name),
382    };
383    Ok(Some(match command.as_str() {
384        "describe" => ProjectChange::Text(ProjectPart::Description, rest_as_text(args)?),
385        "notes" => ProjectChange::Text(ProjectPart::Notes, rest_as_text(args)?),
386        "rename" => ProjectChange::Rename(words(args, "the new name")?),
387        "repo" => ProjectChange::Repo(words(args, "the repository's new name")?),
388        "move" => ProjectChange::Move(words(args, "the project to move to")?),
389        "remove" => {
390            let purge = args.first().is_some_and(|a| a == "--purge");
391            if purge {
392                args.remove(0);
393            }
394            ProjectChange::Remove(purge)
395        }
396        other => bail!("unknown project command `{other}`: describe, notes, rename, repo, move or remove"),
397    }))
398}
399
400/// What `kb project` shows: the project's description and, in a project of several repositories (or
401/// with notes written anyway), this repository's role. A single repository has no role to tell apart,
402/// so an empty one isn't listed as missing.
403fn project_text(project: &ProjectInfo) -> String {
404    /// The text, or that there's none yet.
405    fn shown(text: &str) -> &str {
406        if text.is_empty() { "(empty)" } else { text }
407    }
408    let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
409    if project.siblings.is_empty() && project.notes.is_empty() {
410        return card;
411    }
412    format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
413}
414
415/// Shows the project's description and this repository's role, or changes the project.
416fn run_project(brain: &mut Brain, change: Option<ProjectChange>) -> Result<()> {
417    let Some(project) = &brain.project else {
418        bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
419    };
420    let name = project.name.clone();
421    let central = brain.central.clone().context("the central brain isn't known here")?;
422    let dir = brain.store.dir().to_path_buf();
423    let done = match change {
424        None => {
425            print!("{}", project_text(project));
426            return Ok(());
427        }
428        Some(ProjectChange::Text(ProjectPart::Description, text)) => {
429            brain.set_project_description(&text.read()?)?;
430            vec![format!("project {name}: description saved")]
431        }
432        Some(ProjectChange::Text(ProjectPart::Notes, text)) => {
433            brain.set_repo_notes(&text.read()?)?;
434            vec![format!("project {name}: repository role saved")]
435        }
436        Some(ProjectChange::Rename(new)) => manage::rename_project(&central, &name, &new)?,
437        Some(ProjectChange::Repo(new)) => manage::rename_repo(&central, &dir, &new)?,
438        Some(ProjectChange::Move(to)) => manage::move_repo(&central, &dir, &to)?,
439        Some(ProjectChange::Remove(purge)) => manage::remove_project(&central, &name, purge)?,
440    };
441    for line in &done {
442        println!("{line}");
443    }
444    Ok(())
445}
446
447/// How often `wait` reads the brain again. Seconds, as a person takes far longer to answer; the files
448/// are re-read rather than watched, so a waiting agent costs no dependency and no model call.
449const WAIT_POLL: Duration = Duration::from_secs(1);
450
451/// How long `wait` waits without `--timeout`. Claude Code run headless (`claude -p`) doesn't exit while a
452/// background command runs, so a wait nobody answers would hold it forever; past this, an answer is no
453/// longer a conversation, and the next session's `context` picks it up. Under the 10 minutes Claude Code
454/// gives a tool call at most, so the agent gets this message, which says to stop, rather than a killed
455/// command it takes as a hiccup and runs again.
456const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
457
458/// `--timeout MINUTES` after `wait`'s id: `None` for 0 (no limit). Without it, `KANBRAIN_WAIT_TIMEOUT`
459/// (same minutes), so a run with nobody to answer, such as `e2e/agent.sh`, can make every wait short
460/// without changing what agents are told; else the default.
461fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
462    if args.is_empty() {
463        let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
464            return Ok(Some(WAIT_DEFAULT));
465        };
466        return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
467    }
468    if args.remove(0) != "--timeout" || args.is_empty() {
469        bail!("wait takes an id, then optionally --timeout MINUTES");
470    }
471    minutes(&args.remove(0))
472}
473
474/// A number of minutes as a time limit, `None` for 0 (no limit).
475fn minutes(arg: &str) -> Result<Option<Duration>> {
476    let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
477    Ok((minutes > 0).then(|| Duration::from_secs(minutes * 60)))
478}
479
480/// Blocks until entry `id` no longer waits on a person, then prints what they said; an error once
481/// `timeout` passes without it. An agent runs it in the background after `ask` or `propose`, where its
482/// tool wakes it when it exits: one wake-up with the answer, instead of checking by turns.
483fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
484    let started = std::time::Instant::now();
485    loop {
486        let (tasks, _) = brain.store.load()?;
487        let task = tasks.iter().find(|t| t.id == id).ok_or(ApiError::NotFound(id))?;
488        if let Some(said) = settled(task) {
489            println!("{said}");
490            return Ok(());
491        }
492        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
493            bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
494        }
495        std::thread::sleep(WAIT_POLL);
496    }
497}
498
499/// What `wait` prints once `task` no longer waits on a person, or `None` while it does: a decision that
500/// left proposed, with its verdict's comment; a task's answer; or that the task waits on nothing (never
501/// asked, or picked up since).
502fn settled(task: &Task) -> Option<String> {
503    let head = format!("#{} {}", task.id, task.title);
504    if task.kind == Kind::Decision {
505        if task.decision == DecisionState::Proposed {
506            return None;
507        }
508        let verdict = task.decision.as_str();
509        return Some(match task.comments.last() {
510            Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
511            None => format!("{head}: {verdict}"),
512        });
513    }
514    match task.asked() {
515        Some(Asked::Waiting(_)) => None,
516        Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
517        None => Some(format!("{head}: not waiting for an answer ({})", task.status.as_str())),
518    }
519}
520
521/// Prints the entry: as one line of JSON, as compact text (`show`), or as a one-line confirmation.
522/// JSON is on one line since its readers are programs, and agents pay for the indentation.
523fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
524    let task = brain.entry(id)?;
525    if json {
526        println!("{}", serde_json::to_string(&Entry::new(brain, task))?);
527    } else if full {
528        print!("{}", brain.entry_text(id)?);
529    } else {
530        println!("{}", line(task));
531    }
532    Ok(())
533}
534
535/// Prints search results, one `- #id title (state) #tag` line each, or every match as JSON.
536fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
537    if json {
538        let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
539        println!("{}", serde_json::to_string(&summaries)?);
540    } else {
541        print!("{}", brain.search_text(query));
542    }
543    Ok(())
544}
545
546#[cfg(test)]
547#[path = "../../tests/cli/entries.rs"]
548mod tests;