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