Skip to main content

kanbrain_core/cli/entries/
mod.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`, `rename`,
3//! `tag`, `link` and `context`. Each prints a short confirmation, or with `--json`, the entry (or entries) as JSON.
4
5mod args;
6mod project;
7mod show;
8mod tags;
9mod wait;
10
11use crate::api::text::line;
12use crate::brain::{Brain, NewEntry};
13use crate::model::{DecisionState, Kind, RepoLink, Status, one_line, parse_tags};
14use crate::store;
15use anyhow::{Context, Result, bail};
16use args::{first_id, id_and_text, new_title, rest_as_text, search_words, typed_links};
17use project::{project_args, run_project};
18use show::{print_entry, print_search};
19use std::io::Read;
20use std::time::Duration;
21use tags::{one_tag, rename_tag, retag, tag_changes};
22use wait::{wait, wait_timeout};
23
24/// After the context: how to act on what it lists, for agents that haven't seen the CLI before. Left
25/// out in a project, whose agents have the commands in the section `init` wrote.
26const CONTEXT_USAGE: &str = "\
27Commands: `kb show ID` (everything about an entry), `search WORDS`, \
28`add [--note|--decision] [--tag T] [--link ID] [--body TEXT|-] TITLE`, \
29`comment ID TEXT|-`, `ask ID TEXT|-`, `wait ID`, `start|park|done|todo ID`, \
30`link ID OTHER…` (`REPO#ID` in another repository). Add `--json` for JSON.";
31
32/// Usage lines for these commands, appended to the main usage text.
33pub const USAGE: &str = "\
34  add [--note|--decision] [--tag TAG]… [--link ID]… [--body TEXT|-] [--start] TITLE…
35            create a task (or note, decision) and print its id; `-` reads the body from stdin
36  show ID   print an entry compactly: state, time, description, comments, related, history
37  search WORDS…
38            find entries by title, tags, description or comments (#tag: tags only)
39  start|park|done|todo ID
40            change a task's status (start runs its clock; a person runs one task at a time)
41  comment ID TEXT…|-
42            add a comment, e.g. progress notes
43  ask ID TEXT…|-
44            ask the user a question on a task and park it until a person answers with a comment
45  wait ID [--timeout MINUTES]
46            block until a person answers what task ID waits on (or decision ID is accepted or
47            rejected), then print the answer; gives up after 8 minutes (0: never)
48  propose ID [--tag TAG]… [--link ID]… [--body TEXT|-] TITLE…
49            propose a decision for a task: linked to it, which asks about it and is parked
50  accept ID [TEXT…|-]
51  reject ID TEXT…|-
52            accept or reject a decision, with why (needed to reject); answers a question that names it (#ID)
53  describe ID TEXT…|-
54            replace the description
55  rename ID TITLE…
56            replace the title (and its file's name)
57  tag ID +TAG|-TAG…
58            add tags to an entry (+TAG) or remove them (-TAG), in order
59  tag OLD NEW
60            rename a tag in every entry that has it
61  link ID OTHER…
62            link an entry to others: an id here, or REPO#ID in another repository of the project
63  project [describe|notes TEXT…|-]
64            show the project's description (shared by its repositories) and this repository's role,
65            or replace one
66  project rename NAME… | repo NAME | move PROJECT… | remove [--purge]
67            rename the project, or this repository in it; move this repository to another project;
68            take every repository of the project out (--purge: and delete their tasks)
69  context   what's going on: active tasks, decisions and notes, for an agent to read first";
70
71/// One of the entry commands, and whether to print JSON.
72#[derive(Debug, PartialEq, Eq)]
73#[non_exhaustive]
74pub struct EntryArgs {
75    /// What to do.
76    pub command: EntryCommand,
77    /// `--json`: print the result as JSON.
78    pub json: bool,
79}
80
81/// What to do to which entry.
82#[derive(Debug, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum EntryCommand {
85    /// `add`: create an entry.
86    #[non_exhaustive]
87    Add(AddArgs),
88    /// `show ID`.
89    #[non_exhaustive]
90    Show(u32),
91    /// `search WORDS…`.
92    #[non_exhaustive]
93    Search(String),
94    /// `start|park|done|todo ID`.
95    #[non_exhaustive]
96    SetStatus(u32, Status),
97    /// `comment ID TEXT…|-`.
98    #[non_exhaustive]
99    Comment(u32, Text),
100    /// `ask ID TEXT…|-`: a question to the user, parking the task.
101    #[non_exhaustive]
102    Ask(u32, Text),
103    /// `wait ID [--timeout MINUTES]`: block until a person answers, or the time is up (`None`: never).
104    #[non_exhaustive]
105    Wait(u32, Option<Duration>),
106    /// `propose ID … TITLE…`: a proposed decision for the task.
107    #[non_exhaustive]
108    Propose(u32, AddArgs),
109    /// `accept ID [TEXT…|-]` / `reject ID TEXT…|-`: a decision accepted or rejected, and why.
110    #[non_exhaustive]
111    Resolve(u32, DecisionState, Option<Text>),
112    /// `describe ID TEXT…|-`.
113    #[non_exhaustive]
114    Describe(u32, Text),
115    /// `rename ID TITLE…`.
116    #[non_exhaustive]
117    Rename(u32, String),
118    /// `tag ID +TAG|-TAG…`: tags added and removed, in order.
119    #[non_exhaustive]
120    Tag(u32, Vec<TagChange>),
121    /// `tag OLD NEW`: a tag renamed in every entry; true when OLD was written `#OLD`, which no id is.
122    #[non_exhaustive]
123    RenameTag(String, String, bool),
124    /// `link ID OTHER…`: ids here, and `REPO#ID` in the project's other repositories.
125    #[non_exhaustive]
126    Link(u32, Vec<u32>, Vec<RepoLink>),
127    /// `context`.
128    #[non_exhaustive]
129    Context,
130    /// `project`: show the project's description and this repository's role, or change the project.
131    #[non_exhaustive]
132    Project(Option<ProjectChange>),
133}
134
135/// What `project` changes.
136#[derive(Debug, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum ProjectChange {
139    /// `describe|notes TEXT…|-`: replace one of its texts.
140    #[non_exhaustive]
141    Text(ProjectPart, Text),
142    /// `rename NAME…`: the project's name; `true` when given after `--`, as meant whatever it starts with.
143    #[non_exhaustive]
144    Rename(String, bool),
145    /// `repo NAME`: this repository's name in its project.
146    #[non_exhaustive]
147    Repo(String),
148    /// `move PROJECT…`: this repository to another project, created if new.
149    #[non_exhaustive]
150    Move(String),
151    /// `remove [--purge]`: take every repository of the project out of it; `true` also deletes their tasks.
152    #[non_exhaustive]
153    Remove(bool),
154}
155
156/// What `project` changes in one of its texts.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum ProjectPart {
160    /// `describe`: the project's description, on the card all its repositories share.
161    #[non_exhaustive]
162    Description,
163    /// `notes`: this repository's role among the project's, in its `.brain/project.md`.
164    #[non_exhaustive]
165    Notes,
166}
167
168/// A change `tag` makes to an entry's tags.
169#[derive(Debug, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum TagChange {
172    /// `+TAG`: added after its others, unless it has it.
173    #[non_exhaustive]
174    Add(String),
175    /// `-TAG`: removed, if it has it.
176    #[non_exhaustive]
177    Remove(String),
178}
179
180/// Text given on the command line, or `-` to read it from stdin (for multi-line markdown).
181#[derive(Debug, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum Text {
184    /// The words after the id, joined by spaces.
185    #[non_exhaustive]
186    Given(String),
187    /// Read stdin to the end.
188    #[non_exhaustive]
189    Stdin,
190}
191
192/// Options of `add`.
193#[derive(Debug, PartialEq, Eq, Default)]
194#[non_exhaustive]
195pub struct AddArgs {
196    /// `--note` / `--decision`; a task otherwise.
197    pub kind: Kind,
198    /// The remaining words.
199    pub title: String,
200    /// `--tag`, repeatable.
201    pub tags: Vec<String>,
202    /// `--link`, repeatable: ids here.
203    pub links: Vec<u32>,
204    /// `--link REPO#ID`: entries of the project's other repositories.
205    pub repo_links: Vec<RepoLink>,
206    /// `--body`.
207    pub body: Option<Text>,
208    /// `--start`.
209    pub start: bool,
210}
211
212impl EntryArgs {
213    /// Reads the text given as `-` from stdin, if any.
214    pub fn read_stdin(&mut self) -> Result<()> {
215        let text = match &mut self.command {
216            EntryCommand::Add(args) | EntryCommand::Propose(_, args) => args.body.as_mut(),
217            EntryCommand::Comment(_, text)
218            | EntryCommand::Ask(_, text)
219            | EntryCommand::Describe(_, text)
220            | EntryCommand::Project(Some(ProjectChange::Text(_, text))) => Some(text),
221            EntryCommand::Resolve(_, _, reason) => reason.as_mut(),
222            EntryCommand::Show(_)
223            | EntryCommand::Search(_)
224            | EntryCommand::SetStatus(..)
225            | EntryCommand::Wait(..)
226            | EntryCommand::Rename(..)
227            | EntryCommand::Tag(..)
228            | EntryCommand::RenameTag(..)
229            | EntryCommand::Link(..)
230            | EntryCommand::Context
231            | EntryCommand::Project(_) => None,
232        };
233        text.map_or(Ok(()), Text::read_stdin)
234    }
235
236    /// Parses `name` and the arguments after it, if `name` is one of these commands.
237    pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
238        // `--json` may go anywhere before a `--` (see `cli::options`).
239        let mut json = false;
240        let mut rest = Vec::new();
241        let mut text = false;
242        for arg in args {
243            text |= arg == "--";
244            if arg == "--json" && !text {
245                json = true;
246            } else {
247                rest.push(arg);
248            }
249        }
250        let status = match name {
251            "start" => Some(Status::Doing),
252            "park" => Some(Status::Parked),
253            "done" => Some(Status::Done),
254            "todo" => Some(Status::Todo),
255            _ => None,
256        };
257        let command = match (name, status) {
258            (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
259            ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..), "add needs a title")?),
260            ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
261            ("search", None) => {
262                match search_words(std::mem::take(&mut rest)) {
263                    // Every entry would match: `list` is for that.
264                    words if words.trim().is_empty() => {
265                        bail!("search needs words to look for, or a #tag; `kb list` lists them all")
266                    }
267                    words => EntryCommand::Search(words),
268                }
269            }
270            ("comment", None) => {
271                let (id, text) = id_and_text(&mut rest, "comment needs its text")?;
272                EntryCommand::Comment(id, text)
273            }
274            ("ask", None) => {
275                let (id, text) = id_and_text(&mut rest, "ask needs the question")?;
276                EntryCommand::Ask(id, text)
277            }
278            ("wait", None) => {
279                let id = first_id(&mut rest)?;
280                EntryCommand::Wait(id, wait_timeout(&mut rest)?)
281            }
282            ("propose", None) => {
283                let id = first_id(&mut rest)?;
284                let decision = AddArgs::parse(rest.drain(..), "propose needs the decision's title")?;
285                if decision.kind != Kind::Task || decision.start {
286                    bail!("propose makes a decision: leave out --note, --decision and --start");
287                }
288                EntryCommand::Propose(id, decision)
289            }
290            ("accept", None) => {
291                let id = first_id(&mut rest)?;
292                let reason =
293                    if rest.is_empty() { None } else { Some(rest_as_text(&mut rest, "accept needs its comment")?) };
294                EntryCommand::Resolve(id, DecisionState::Accepted, reason)
295            }
296            ("reject", None) => {
297                let id = first_id(&mut rest)?;
298                let reason = rest_as_text(&mut rest, "reject needs why")?;
299                EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
300            }
301            ("describe", None) => {
302                let (id, text) = id_and_text(&mut rest, "describe needs the description")?;
303                EntryCommand::Describe(id, text)
304            }
305            ("rename", None) => {
306                let id = first_id(&mut rest)?;
307                EntryCommand::Rename(id, new_title(std::mem::take(&mut rest))?)
308            }
309            ("tag", None) => match std::mem::take(&mut rest).as_slice() {
310                // A word after the first that isn't a change names the tag's new name.
311                words @ [old, new] if !new.starts_with(['+', '-']) => {
312                    let (old, new) = (one_tag(old)?, one_tag(new)?);
313                    if old == new {
314                        bail!("#{old} is already its name");
315                    }
316                    EntryCommand::RenameTag(old, new, words[0].starts_with('#'))
317                }
318                words => {
319                    let mut words = words.to_vec();
320                    let id = first_id(&mut words)?;
321                    EntryCommand::Tag(id, tag_changes(words)?)
322                }
323            },
324            ("link", None) => {
325                let id = first_id(&mut rest)?;
326                let (others, repo_links) = typed_links(&std::mem::take(&mut rest).join(" "))?;
327                if others.is_empty() && repo_links.is_empty() {
328                    bail!("link needs the ids to link to");
329                }
330                EntryCommand::Link(id, others, repo_links)
331            }
332            ("context", None) => EntryCommand::Context,
333            ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
334            _ => return Ok(None),
335        };
336        if let Some(extra) = rest.first() {
337            bail!("unexpected argument `{extra}`");
338        }
339        Ok(Some(Self { command, json }))
340    }
341
342    /// Runs the command against `brain` and prints the result.
343    pub fn run(self, mut brain: Brain) -> Result<()> {
344        let json = self.json;
345        let show = matches!(self.command, EntryCommand::Show(_));
346        let id = match self.command {
347            EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
348            EntryCommand::Show(id) => {
349                brain.entry(id)?;
350                id
351            }
352            EntryCommand::Search(query) => return print_search(&brain, &query, json),
353            EntryCommand::SetStatus(id, status) => {
354                // On stderr, so stdout stays the entry's line for scripts.
355                if !brain.set_status(id, status)? {
356                    eprintln!("#{id} was already {}: nothing changed", status.as_str());
357                }
358                id
359            }
360            EntryCommand::Comment(id, text) => {
361                brain.add_comment(id, &text.read()?)?;
362                id
363            }
364            EntryCommand::Ask(id, text) => {
365                brain.ask(id, &text.read()?)?;
366                id
367            }
368            EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
369            EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
370            EntryCommand::Propose(task, args) => {
371                let decision = brain.propose(task, args.into_new_entry()?)?;
372                if !json {
373                    println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
374                    return Ok(());
375                }
376                decision
377            }
378            EntryCommand::Resolve(id, state, reason) => {
379                let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
380                if !brain.resolve(id, state, &reason)? {
381                    eprintln!("#{id} was already {}: nothing changed", state.as_str());
382                }
383                id
384            }
385            EntryCommand::Describe(id, text) => {
386                brain.set_description(id, &text.read()?)?;
387                id
388            }
389            EntryCommand::Rename(id, title) => {
390                if !retitle(&mut brain, id, &title)? {
391                    eprintln!("#{id} already had that title: nothing changed");
392                }
393                id
394            }
395            EntryCommand::Tag(id, changes) => {
396                if !retag(&mut brain, id, &changes)? {
397                    eprintln!("#{id}'s tags were already so: nothing changed");
398                }
399                id
400            }
401            EntryCommand::RenameTag(old, new, hashed) => return rename_tag(&mut brain, &old, &new, hashed, json),
402            EntryCommand::Link(id, others, repo_links) => {
403                brain.link(id, &others, &repo_links)?;
404                id
405            }
406            EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
407            EntryCommand::Project(_) if json => bail!("project is markdown only"),
408            EntryCommand::Project(change) => return run_project(&mut brain, change),
409            EntryCommand::Context => {
410                // A release that adds a hook reaches the project with its next session. Never in the context's way:
411                // a session starts all the same without it.
412                let dir = brain.store.dir();
413                if let Some(root) = dir.parent().filter(|_| dir.file_name() == Some(store::PROJECT_DIR.as_ref())) {
414                    let _ = super::claude::update_hooks(root);
415                }
416                let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
417                println!("{}{usage}", brain.context_markdown());
418                return Ok(());
419            }
420        };
421        print_entry(&brain, id, json, show)
422    }
423}
424
425impl AddArgs {
426    /// Parses the options and title words after `add` or `propose ID`; `no_title` is the error without a title.
427    fn parse(mut args: impl Iterator<Item = String>, no_title: &str) -> Result<Self> {
428        let mut add = Self::default();
429        let mut title = Vec::new();
430        while let Some(arg) = args.next() {
431            match arg.as_str() {
432                "--note" => add.kind = Kind::Note,
433                "--decision" => add.kind = Kind::Decision,
434                "--start" => add.start = true,
435                "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
436                "--link" => {
437                    let (links, repo_links) = typed_links(&args.next().context("--link needs an id")?)?;
438                    add.links.extend(links);
439                    add.repo_links.extend(repo_links);
440                }
441                "--body" => {
442                    let body = args.next().context("--body needs text, or - to read stdin")?;
443                    add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
444                }
445                // Every word after it is the title's, however it starts.
446                "--" => title.extend(args.by_ref()),
447                other if other.starts_with("--") => bail!("unknown add option `{other}`"),
448                _ => title.push(arg),
449            }
450        }
451        add.title = title.join(" ");
452        if add.title.trim().is_empty() {
453            bail!("{no_title}");
454        }
455        if add.start && add.kind != Kind::Task {
456            bail!("only tasks can be started");
457        }
458        Ok(add)
459    }
460
461    /// The entry to create, reading the body from stdin if asked to.
462    fn into_new_entry(self) -> Result<NewEntry> {
463        Ok(NewEntry {
464            kind: self.kind,
465            title: self.title,
466            tags: self.tags,
467            links: self.links,
468            repo_links: self.repo_links,
469            description: self.body.map(Text::read).transpose()?,
470            start: self.start,
471        })
472    }
473}
474
475impl Text {
476    /// Reads stdin to the end for `-`, so the text is given from then on.
477    fn read_stdin(&mut self) -> Result<()> {
478        if *self == Text::Stdin {
479            *self = Text::Given(stdin_text()?);
480        }
481        Ok(())
482    }
483
484    /// The text, reading stdin to the end for `-`.
485    fn read(self) -> Result<String> {
486        match self {
487            Text::Given(text) => Ok(text),
488            Text::Stdin => stdin_text(),
489        }
490    }
491}
492
493/// Everything on stdin, for a `-`. Nothing there is refused: it's a heredoc left out (an agent's shell tool gives an
494/// empty stdin) far more often than a wish to clear the text, and `describe` would save it over the description.
495fn stdin_text() -> Result<String> {
496    let mut text = String::new();
497    std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
498    if text.trim().is_empty() {
499        bail!("nothing came on stdin for `-`: give the text there, e.g. with a heredoc (<<'EOF' … EOF)");
500    }
501    Ok(text)
502}
503
504/// Saves `title`, on one line, as entry `id`'s, as the kanban app's form does (its file is renamed after it); returns
505/// whether it changed, as nothing is written when it didn't.
506fn retitle(brain: &mut Brain, id: u32, title: &str) -> Result<bool> {
507    let entry = brain.entry(id)?;
508    let title = one_line(title);
509    if title == entry.title {
510        return Ok(false);
511    }
512    let tags = entry.tags.clone();
513    brain.update(id, &title, tags)?;
514    Ok(true)
515}
516
517#[cfg(test)]
518#[path = "../../../tests/cli/entries.rs"]
519mod tests;