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`, `rename`,
3//! `tag`, `link` 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, not_a_link, one_line, parse_links, parse_tags};
11use crate::projects::ProjectInfo;
12use crate::store;
13use anyhow::{Context, Result, anyhow, bail};
14use serde::Serialize;
15use std::io::Read;
16use std::time::Duration;
17
18/// After the context: how to act on what it lists, for agents that haven't seen the CLI before. Left
19/// out in a project, whose agents have the commands in the section `init` wrote.
20const CONTEXT_USAGE: &str = "\
21Commands: `kb show ID` (everything about an entry), `search WORDS`, \
22`add [--note|--decision] [--tag T] [--link ID] [--body TEXT|-] TITLE`, \
23`comment ID TEXT|-`, `ask ID TEXT|-`, `wait ID`, `start|park|done|todo ID`, \
24`link ID OTHER…` (`REPO#ID` in another repository). Add `--json` for JSON.";
25
26/// Usage lines for these commands, appended to the main usage text.
27pub const USAGE: &str = "\
28  add [--note|--decision] [--tag TAG]… [--link ID]… [--body TEXT|-] [--start] TITLE…
29            create a task (or note, decision) and print its id; `-` reads the body from stdin
30  show ID   print an entry compactly: state, time, description, comments, related, history
31  search WORDS…
32            find entries by title, tags, description or comments (#tag: tags only)
33  start|park|done|todo ID
34            change a task's status (start runs its clock; a person runs one task at a time)
35  comment ID TEXT…|-
36            add a comment, e.g. progress notes
37  ask ID TEXT…|-
38            ask the user a question on a task and park it until a person answers with a comment
39  wait ID [--timeout MINUTES]
40            block until a person answers what task ID waits on (or decision ID is accepted or
41            rejected), then print the answer; gives up after 8 minutes (0: never)
42  propose ID [--tag TAG]… [--link ID]… [--body TEXT|-] TITLE…
43            propose a decision for a task: linked to it, which asks about it and is parked
44  accept ID [TEXT…|-]
45  reject ID TEXT…|-
46            accept or reject a decision, with why (needed to reject); answers a question that names it (#ID)
47  describe ID TEXT…|-
48            replace the description
49  rename ID TITLE…
50            replace the title (and its file's name)
51  tag ID +TAG|-TAG…
52            add tags to an entry (+TAG) or remove them (-TAG), in order
53  tag OLD NEW
54            rename a tag in every entry that has it
55  link ID OTHER…
56            link an entry to others: an id here, or REPO#ID in another repository of the project
57  project [describe|notes TEXT…|-]
58            show the project's description (shared by its repositories) and this repository's role,
59            or replace one
60  project rename NAME… | repo NAME | move PROJECT… | remove [--purge]
61            rename the project, or this repository in it; move this repository to another project;
62            take every repository of the project out (--purge: and delete their tasks)
63  context   what's going on: active tasks, decisions and notes, for an agent to read first";
64
65/// One of the entry commands, and whether to print JSON.
66#[derive(Debug, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct EntryArgs {
69    /// What to do.
70    pub command: EntryCommand,
71    /// `--json`: print the result as JSON.
72    pub json: bool,
73}
74
75/// What to do to which entry.
76#[derive(Debug, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum EntryCommand {
79    /// `add`: create an entry.
80    #[non_exhaustive]
81    Add(AddArgs),
82    /// `show ID`.
83    #[non_exhaustive]
84    Show(u32),
85    /// `search WORDS…`.
86    #[non_exhaustive]
87    Search(String),
88    /// `start|park|done|todo ID`.
89    #[non_exhaustive]
90    SetStatus(u32, Status),
91    /// `comment ID TEXT…|-`.
92    #[non_exhaustive]
93    Comment(u32, Text),
94    /// `ask ID TEXT…|-`: a question to the user, parking the task.
95    #[non_exhaustive]
96    Ask(u32, Text),
97    /// `wait ID [--timeout MINUTES]`: block until a person answers, or the time is up (`None`: never).
98    #[non_exhaustive]
99    Wait(u32, Option<Duration>),
100    /// `propose ID … TITLE…`: a proposed decision for the task.
101    #[non_exhaustive]
102    Propose(u32, AddArgs),
103    /// `accept ID [TEXT…|-]` / `reject ID TEXT…|-`: a decision accepted or rejected, and why.
104    #[non_exhaustive]
105    Resolve(u32, DecisionState, Option<Text>),
106    /// `describe ID TEXT…|-`.
107    #[non_exhaustive]
108    Describe(u32, Text),
109    /// `rename ID TITLE…`.
110    #[non_exhaustive]
111    Rename(u32, String),
112    /// `tag ID +TAG|-TAG…`: tags added and removed, in order.
113    #[non_exhaustive]
114    Tag(u32, Vec<TagChange>),
115    /// `tag OLD NEW`: a tag renamed in every entry; true when OLD was written `#OLD`, which no id is.
116    #[non_exhaustive]
117    RenameTag(String, String, bool),
118    /// `link ID OTHER…`: ids here, and `REPO#ID` in the project's other repositories.
119    #[non_exhaustive]
120    Link(u32, Vec<u32>, Vec<RepoLink>),
121    /// `context`.
122    #[non_exhaustive]
123    Context,
124    /// `project`: show the project's description and this repository's role, or change the project.
125    #[non_exhaustive]
126    Project(Option<ProjectChange>),
127}
128
129/// What `project` changes.
130#[derive(Debug, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum ProjectChange {
133    /// `describe|notes TEXT…|-`: replace one of its texts.
134    #[non_exhaustive]
135    Text(ProjectPart, Text),
136    /// `rename NAME…`: the project's name; `true` when given after `--`, as meant whatever it starts with.
137    #[non_exhaustive]
138    Rename(String, bool),
139    /// `repo NAME`: this repository's name in its project.
140    #[non_exhaustive]
141    Repo(String),
142    /// `move PROJECT…`: this repository to another project, created if new.
143    #[non_exhaustive]
144    Move(String),
145    /// `remove [--purge]`: take every repository of the project out of it; `true` also deletes their tasks.
146    #[non_exhaustive]
147    Remove(bool),
148}
149
150/// What `project` changes in one of its texts.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152#[non_exhaustive]
153pub enum ProjectPart {
154    /// `describe`: the project's description, on the card all its repositories share.
155    #[non_exhaustive]
156    Description,
157    /// `notes`: this repository's role among the project's, in its `.brain/project.md`.
158    #[non_exhaustive]
159    Notes,
160}
161
162/// A change `tag` makes to an entry's tags.
163#[derive(Debug, PartialEq, Eq)]
164#[non_exhaustive]
165pub enum TagChange {
166    /// `+TAG`: added after its others, unless it has it.
167    #[non_exhaustive]
168    Add(String),
169    /// `-TAG`: removed, if it has it.
170    #[non_exhaustive]
171    Remove(String),
172}
173
174/// Text given on the command line, or `-` to read it from stdin (for multi-line markdown).
175#[derive(Debug, PartialEq, Eq)]
176#[non_exhaustive]
177pub enum Text {
178    /// The words after the id, joined by spaces.
179    #[non_exhaustive]
180    Given(String),
181    /// Read stdin to the end.
182    #[non_exhaustive]
183    Stdin,
184}
185
186/// Options of `add`.
187#[derive(Debug, PartialEq, Eq, Default)]
188#[non_exhaustive]
189pub struct AddArgs {
190    /// `--note` / `--decision`; a task otherwise.
191    pub kind: Kind,
192    /// The remaining words.
193    pub title: String,
194    /// `--tag`, repeatable.
195    pub tags: Vec<String>,
196    /// `--link`, repeatable: ids here.
197    pub links: Vec<u32>,
198    /// `--link REPO#ID`: entries of the project's other repositories.
199    pub repo_links: Vec<RepoLink>,
200    /// `--body`.
201    pub body: Option<Text>,
202    /// `--start`.
203    pub start: bool,
204}
205
206impl EntryArgs {
207    /// Reads the text given as `-` from stdin, if any.
208    pub fn read_stdin(&mut self) -> Result<()> {
209        let text = match &mut self.command {
210            EntryCommand::Add(args) | EntryCommand::Propose(_, args) => args.body.as_mut(),
211            EntryCommand::Comment(_, text)
212            | EntryCommand::Ask(_, text)
213            | EntryCommand::Describe(_, text)
214            | EntryCommand::Project(Some(ProjectChange::Text(_, text))) => Some(text),
215            EntryCommand::Resolve(_, _, reason) => reason.as_mut(),
216            EntryCommand::Show(_)
217            | EntryCommand::Search(_)
218            | EntryCommand::SetStatus(..)
219            | EntryCommand::Wait(..)
220            | EntryCommand::Rename(..)
221            | EntryCommand::Tag(..)
222            | EntryCommand::RenameTag(..)
223            | EntryCommand::Link(..)
224            | EntryCommand::Context
225            | EntryCommand::Project(_) => None,
226        };
227        text.map_or(Ok(()), Text::read_stdin)
228    }
229
230    /// Parses `name` and the arguments after it, if `name` is one of these commands.
231    pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
232        // `--json` may go anywhere before a `--` (see `cli::options`).
233        let mut json = false;
234        let mut rest = Vec::new();
235        let mut text = false;
236        for arg in args {
237            text |= arg == "--";
238            if arg == "--json" && !text {
239                json = true;
240            } else {
241                rest.push(arg);
242            }
243        }
244        let status = match name {
245            "start" => Some(Status::Doing),
246            "park" => Some(Status::Parked),
247            "done" => Some(Status::Done),
248            "todo" => Some(Status::Todo),
249            _ => None,
250        };
251        let command = match (name, status) {
252            (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
253            ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..), "add needs a title")?),
254            ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
255            ("search", None) => {
256                match search_words(std::mem::take(&mut rest)) {
257                    // Every entry would match: `list` is for that.
258                    words if words.trim().is_empty() => {
259                        bail!("search needs words to look for, or a #tag; `kb list` lists them all")
260                    }
261                    words => EntryCommand::Search(words),
262                }
263            }
264            ("comment", None) => {
265                let (id, text) = id_and_text(&mut rest, "comment needs its text")?;
266                EntryCommand::Comment(id, text)
267            }
268            ("ask", None) => {
269                let (id, text) = id_and_text(&mut rest, "ask needs the question")?;
270                EntryCommand::Ask(id, text)
271            }
272            ("wait", None) => {
273                let id = first_id(&mut rest)?;
274                EntryCommand::Wait(id, wait_timeout(&mut rest)?)
275            }
276            ("propose", None) => {
277                let id = first_id(&mut rest)?;
278                let decision = AddArgs::parse(rest.drain(..), "propose needs the decision's title")?;
279                if decision.kind != Kind::Task || decision.start {
280                    bail!("propose makes a decision: leave out --note, --decision and --start");
281                }
282                EntryCommand::Propose(id, decision)
283            }
284            ("accept", None) => {
285                let id = first_id(&mut rest)?;
286                let reason =
287                    if rest.is_empty() { None } else { Some(rest_as_text(&mut rest, "accept needs its comment")?) };
288                EntryCommand::Resolve(id, DecisionState::Accepted, reason)
289            }
290            ("reject", None) => {
291                let id = first_id(&mut rest)?;
292                let reason = rest_as_text(&mut rest, "reject needs why")?;
293                EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
294            }
295            ("describe", None) => {
296                let (id, text) = id_and_text(&mut rest, "describe needs the description")?;
297                EntryCommand::Describe(id, text)
298            }
299            ("rename", None) => {
300                let id = first_id(&mut rest)?;
301                EntryCommand::Rename(id, new_title(std::mem::take(&mut rest))?)
302            }
303            ("tag", None) => match std::mem::take(&mut rest).as_slice() {
304                // A word after the first that isn't a change names the tag's new name.
305                words @ [old, new] if !new.starts_with(['+', '-']) => {
306                    let (old, new) = (one_tag(old)?, one_tag(new)?);
307                    if old == new {
308                        bail!("#{old} is already its name");
309                    }
310                    EntryCommand::RenameTag(old, new, words[0].starts_with('#'))
311                }
312                words => {
313                    let mut words = words.to_vec();
314                    let id = first_id(&mut words)?;
315                    EntryCommand::Tag(id, tag_changes(words)?)
316                }
317            },
318            ("link", None) => {
319                let id = first_id(&mut rest)?;
320                let (others, repo_links) = typed_links(&std::mem::take(&mut rest).join(" "))?;
321                if others.is_empty() && repo_links.is_empty() {
322                    bail!("link needs the ids to link to");
323                }
324                EntryCommand::Link(id, others, repo_links)
325            }
326            ("context", None) => EntryCommand::Context,
327            ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
328            _ => return Ok(None),
329        };
330        if let Some(extra) = rest.first() {
331            bail!("unexpected argument `{extra}`");
332        }
333        Ok(Some(Self { command, json }))
334    }
335
336    /// Runs the command against `brain` and prints the result.
337    pub fn run(self, mut brain: Brain) -> Result<()> {
338        let json = self.json;
339        let show = matches!(self.command, EntryCommand::Show(_));
340        let id = match self.command {
341            EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
342            EntryCommand::Show(id) => {
343                brain.entry(id)?;
344                id
345            }
346            EntryCommand::Search(query) => return print_search(&brain, &query, json),
347            EntryCommand::SetStatus(id, status) => {
348                // On stderr, so stdout stays the entry's line for scripts.
349                if !brain.set_status(id, status)? {
350                    eprintln!("#{id} was already {}: nothing changed", status.as_str());
351                }
352                id
353            }
354            EntryCommand::Comment(id, text) => {
355                brain.add_comment(id, &text.read()?)?;
356                id
357            }
358            EntryCommand::Ask(id, text) => {
359                brain.ask(id, &text.read()?)?;
360                id
361            }
362            EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
363            EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
364            EntryCommand::Propose(task, args) => {
365                let decision = brain.propose(task, args.into_new_entry()?)?;
366                if !json {
367                    println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
368                    return Ok(());
369                }
370                decision
371            }
372            EntryCommand::Resolve(id, state, reason) => {
373                let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
374                if !brain.resolve(id, state, &reason)? {
375                    eprintln!("#{id} was already {}: nothing changed", state.as_str());
376                }
377                id
378            }
379            EntryCommand::Describe(id, text) => {
380                brain.set_description(id, &text.read()?)?;
381                id
382            }
383            EntryCommand::Rename(id, title) => {
384                if !retitle(&mut brain, id, &title)? {
385                    eprintln!("#{id} already had that title: nothing changed");
386                }
387                id
388            }
389            EntryCommand::Tag(id, changes) => {
390                if !retag(&mut brain, id, &changes)? {
391                    eprintln!("#{id}'s tags were already so: nothing changed");
392                }
393                id
394            }
395            EntryCommand::RenameTag(old, new, hashed) => return rename_tag(&mut brain, &old, &new, hashed, json),
396            EntryCommand::Link(id, others, repo_links) => {
397                brain.link(id, &others, &repo_links)?;
398                id
399            }
400            EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
401            EntryCommand::Project(_) if json => bail!("project is markdown only"),
402            EntryCommand::Project(change) => return run_project(&mut brain, change),
403            EntryCommand::Context => {
404                // A release that adds a hook reaches the project with its next session. Never in the context's way:
405                // a session starts all the same without it.
406                let dir = brain.store.dir();
407                if let Some(root) = dir.parent().filter(|_| dir.file_name() == Some(store::PROJECT_DIR.as_ref())) {
408                    let _ = super::claude::update_hooks(root);
409                }
410                let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
411                println!("{}{usage}", brain.context_markdown());
412                return Ok(());
413            }
414        };
415        print_entry(&brain, id, json, show)
416    }
417}
418
419impl AddArgs {
420    /// Parses the options and title words after `add` or `propose ID`; `no_title` is the error without a title.
421    fn parse(mut args: impl Iterator<Item = String>, no_title: &str) -> Result<Self> {
422        let mut add = Self::default();
423        let mut title = Vec::new();
424        while let Some(arg) = args.next() {
425            match arg.as_str() {
426                "--note" => add.kind = Kind::Note,
427                "--decision" => add.kind = Kind::Decision,
428                "--start" => add.start = true,
429                "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
430                "--link" => {
431                    let (links, repo_links) = typed_links(&args.next().context("--link needs an id")?)?;
432                    add.links.extend(links);
433                    add.repo_links.extend(repo_links);
434                }
435                "--body" => {
436                    let body = args.next().context("--body needs text, or - to read stdin")?;
437                    add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
438                }
439                // Every word after it is the title's, however it starts.
440                "--" => title.extend(args.by_ref()),
441                other if other.starts_with("--") => bail!("unknown add option `{other}`"),
442                _ => title.push(arg),
443            }
444        }
445        add.title = title.join(" ");
446        if add.title.trim().is_empty() {
447            bail!("{no_title}");
448        }
449        if add.start && add.kind != Kind::Task {
450            bail!("only tasks can be started");
451        }
452        Ok(add)
453    }
454
455    /// The entry to create, reading the body from stdin if asked to.
456    fn into_new_entry(self) -> Result<NewEntry> {
457        Ok(NewEntry {
458            kind: self.kind,
459            title: self.title,
460            tags: self.tags,
461            links: self.links,
462            repo_links: self.repo_links,
463            description: self.body.map(Text::read).transpose()?,
464            start: self.start,
465        })
466    }
467}
468
469impl Text {
470    /// Reads stdin to the end for `-`, so the text is given from then on.
471    fn read_stdin(&mut self) -> Result<()> {
472        if *self == Text::Stdin {
473            *self = Text::Given(stdin_text()?);
474        }
475        Ok(())
476    }
477
478    /// The text, reading stdin to the end for `-`.
479    fn read(self) -> Result<String> {
480        match self {
481            Text::Given(text) => Ok(text),
482            Text::Stdin => stdin_text(),
483        }
484    }
485}
486
487/// Everything on stdin, for a `-`. Nothing there is refused: it's a heredoc left out (an agent's shell tool gives an
488/// empty stdin) far more often than a wish to clear the text, and `describe` would save it over the description.
489fn stdin_text() -> Result<String> {
490    let mut text = String::new();
491    std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
492    if text.trim().is_empty() {
493        bail!("nothing came on stdin for `-`: give the text there, e.g. with a heredoc (<<'EOF' … EOF)");
494    }
495    Ok(text)
496}
497
498/// The words `search` looks for, without the first `--`, which only ends the options: `kb search -- --` looks for
499/// `--`.
500fn search_words(mut words: Vec<String>) -> String {
501    if let Some(at) = words.iter().position(|w| w == "--") {
502        words.remove(at);
503    }
504    words.join(" ")
505}
506
507/// The links typed in `value`; a word that isn't one is refused rather than left out.
508fn typed_links(value: &str) -> Result<(Vec<u32>, Vec<RepoLink>)> {
509    if let Some(word) = not_a_link(value) {
510        bail!("`{word}` isn't an entry id (12 or #12) or another repository's entry (REPO#ID)");
511    }
512    Ok(parse_links(value))
513}
514
515/// The words after `rename`'s id as the title, as `add` takes its own: `--` makes every word after it the title's,
516/// however it starts.
517fn new_title(words: Vec<String>) -> Result<String> {
518    let mut title = Vec::new();
519    let mut words = words.into_iter();
520    while let Some(word) = words.next() {
521        match word.as_str() {
522            "--" => title.extend(words.by_ref()),
523            other if other.starts_with("--") => bail!("unknown rename option `{other}`"),
524            _ => title.push(word),
525        }
526    }
527    let title = title.join(" ");
528    if title.trim().is_empty() {
529        bail!("rename needs the new title");
530    }
531    Ok(title)
532}
533
534/// Saves `title`, on one line, as entry `id`'s, as the kanban app's form does (its file is renamed after it); returns
535/// whether it changed, as nothing is written when it didn't.
536fn retitle(brain: &mut Brain, id: u32, title: &str) -> Result<bool> {
537    let entry = brain.entry(id)?;
538    let title = one_line(title);
539    if title == entry.title {
540        return Ok(false);
541    }
542    let tags = entry.tags.clone();
543    brain.update(id, &title, tags)?;
544    Ok(true)
545}
546
547/// What `tag` says a word after the id has to be.
548const TAG_SIGNS: &str = "+TAG adds a tag, -TAG removes one";
549
550/// The words after `tag`'s id as changes, in order. A word may name several tags (`+ui,#bug`), read as `add --tag`
551/// reads them.
552fn tag_changes(words: Vec<String>) -> Result<Vec<TagChange>> {
553    let mut changes = Vec::new();
554    for word in words {
555        let change = match word.as_bytes() {
556            // Not an option `--` ends: `tag` takes no text.
557            [b'-', b'-'] => bail!("unexpected argument `--`"),
558            [b'-', b'-', ..] => bail!("unknown tag option `{word}`: {TAG_SIGNS}"),
559            [b'+', ..] => TagChange::Add,
560            [b'-', ..] => TagChange::Remove,
561            _ => bail!("`{word}`: {TAG_SIGNS}"),
562        };
563        let tags = parse_tags(&word[1..]);
564        if tags.is_empty() {
565            bail!("`{word}` names no tag");
566        }
567        changes.extend(tags.into_iter().map(change));
568    }
569    if changes.is_empty() {
570        bail!("tag needs what to change: {TAG_SIGNS}");
571    }
572    Ok(changes)
573}
574
575/// Makes `changes` to entry `id`'s tags and saves them as the kanban app's form does, colors included; returns
576/// whether they changed, as nothing is written when they didn't.
577fn retag(brain: &mut Brain, id: u32, changes: &[TagChange]) -> Result<bool> {
578    let entry = brain.entry(id)?;
579    let mut tags = entry.tags.clone();
580    for change in changes {
581        match change {
582            TagChange::Add(tag) if !tags.contains(tag) => tags.push(tag.clone()),
583            TagChange::Add(_) => {}
584            TagChange::Remove(tag) => tags.retain(|t| t != tag),
585        }
586    }
587    if tags == entry.tags {
588        return Ok(false);
589    }
590    let title = entry.title.clone();
591    brain.update(id, &title, tags)?;
592    Ok(true)
593}
594
595/// `word` as the one tag it names (`#ops` is `ops`), for `tag OLD NEW`.
596fn one_tag(word: &str) -> Result<String> {
597    match parse_tags(word).as_slice() {
598        [tag] => Ok(tag.clone()),
599        [] => bail!("`{word}` names no tag"),
600        _ => bail!("`{word}` names several tags: tag OLD NEW renames one"),
601    }
602}
603
604/// Renames tag `old` to `new` in every entry of `brain` and prints the entries renamed, one line each (or as `search
605/// --json` lists them). `tag 5 ui`, with an entry #5, may be `tag 5 +ui` mistyped: a tag named as an id is renamed only
606/// when written `#5` (`hashed`), as a rename that wasn't meant would change every entry that has it.
607fn rename_tag(brain: &mut Brain, old: &str, new: &str, hashed: bool, json: bool) -> Result<()> {
608    if !hashed && old.parse().ok().and_then(|id| brain.task(id)).is_some() {
609        bail!("#{old} is an entry: to tag it, `kb tag {old} +{new}`; to rename tag #{old}, `kb tag '#{old}' {new}`");
610    }
611    let renamed = brain.rename_tag(old, new)?;
612    let entries = renamed.iter().filter_map(|&id| brain.task(id));
613    if json {
614        println!("{}", serde_json::to_string(&entries.map(Summary::new).collect::<Vec<_>>())?);
615    } else {
616        entries.for_each(|task| println!("{}", line(task)));
617    }
618    Ok(())
619}
620
621/// The first argument as an entry id (`12` or `#12`).
622fn first_id(args: &mut Vec<String>) -> Result<u32> {
623    if args.is_empty() {
624        bail!("an entry id is needed");
625    }
626    let arg = args.remove(0);
627    // The parser's own words ("invalid digit found in string") say nothing more to whoever typed it.
628    arg.trim_start_matches('#').parse().map_err(|_| anyhow!("`{arg}` isn't an entry id"))
629}
630
631/// An id, then the text: the remaining words, or `-` alone for stdin; `needs` says what's missing without it.
632fn id_and_text(args: &mut Vec<String>, needs: &str) -> Result<(u32, Text)> {
633    let id = first_id(args)?;
634    Ok((id, rest_as_text(args, needs)?))
635}
636
637/// The remaining words as one text, or `-` alone for stdin; `needs` says what's missing without them.
638fn rest_as_text(args: &mut Vec<String>, needs: &str) -> Result<Text> {
639    let mut words = std::mem::take(args);
640    // Only says where options end (see `cli::options`); the text is what follows.
641    if let Some(at) = words.iter().position(|w| w == "--") {
642        words.remove(at);
643    }
644    match words.as_slice() {
645        [] => bail!("{needs}: TEXT, or - to read it from stdin"),
646        [dash] if dash == "-" => Ok(Text::Stdin),
647        _ => Ok(Text::Given(words.join(" "))),
648    }
649}
650
651/// What follows `project`: nothing, to show it; else what to change.
652fn project_args(args: &mut Vec<String>) -> Result<Option<ProjectChange>> {
653    if args.is_empty() {
654        return Ok(None);
655    }
656    let command = args.remove(0);
657    // A project's name may have spaces; a repository's is one word, which `project::check_repo_name` checks.
658    let words = |args: &mut Vec<String>, what: &str| match std::mem::take(args).join(" ") {
659        name if name.trim().is_empty() => bail!("project {command} needs {what}"),
660        name => Ok(name),
661    };
662    Ok(Some(match command.as_str() {
663        "describe" => {
664            ProjectChange::Text(ProjectPart::Description, rest_as_text(args, "project describe needs the description")?)
665        }
666        "notes" => ProjectChange::Text(ProjectPart::Notes, rest_as_text(args, "project notes needs the notes")?),
667        "rename" => {
668            let literal = args.first().is_some_and(|a| a == "--");
669            if literal {
670                args.remove(0);
671            }
672            ProjectChange::Rename(words(args, "the new name")?, literal)
673        }
674        "repo" => ProjectChange::Repo(words(args, "the repository's new name")?),
675        "move" => ProjectChange::Move(words(args, "the project to move to")?),
676        "remove" => {
677            let purge = args.first().is_some_and(|a| a == "--purge");
678            if purge {
679                args.remove(0);
680            }
681            ProjectChange::Remove(purge)
682        }
683        other => bail!("unknown project command `{other}`: describe, notes, rename, repo, move or remove"),
684    }))
685}
686
687/// What `kb project` shows: the project's description and, in a project of several repositories (or
688/// with notes written anyway), this repository's role. A single repository has no role to tell apart,
689/// so an empty one isn't listed as missing.
690fn project_text(project: &ProjectInfo) -> String {
691    /// The text, or that there's none yet.
692    fn shown(text: &str) -> &str {
693        if text.is_empty() { "(empty)" } else { text }
694    }
695    let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
696    if project.siblings.is_empty() && project.notes.is_empty() {
697        return card;
698    }
699    format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
700}
701
702/// Shows the project's description and this repository's role, or changes the project.
703fn run_project(brain: &mut Brain, change: Option<ProjectChange>) -> Result<()> {
704    let Some(project) = &brain.project else {
705        bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
706    };
707    let name = project.name.clone();
708    let central = brain.central.clone().context("the central brain isn't known here")?;
709    let dir = brain.store.dir().to_path_buf();
710    let done = match change {
711        None => {
712            print!("{}", project_text(project));
713            return Ok(());
714        }
715        Some(ProjectChange::Text(ProjectPart::Description, text)) => {
716            brain.set_project_description(&text.read()?)?;
717            vec![format!("project {name}: description saved")]
718        }
719        Some(ProjectChange::Text(ProjectPart::Notes, text)) => {
720            brain.set_repo_notes(&text.read()?)?;
721            vec![format!("project {name}: repository role saved")]
722        }
723        // `rename OLD NEW`, as other tools have it, would name the project "OLD NEW", names having spaces.
724        Some(ProjectChange::Rename(new, false))
725            if new.strip_prefix(&name).is_some_and(|rest| rest.starts_with(' ')) =>
726        {
727            let rest = new[name.len()..].trim();
728            bail!(
729                "`project rename` takes the new name only, for this project: `kb project rename {rest}`; to name it \
730                 `{new}`, `kb project rename -- {new}`"
731            )
732        }
733        Some(ProjectChange::Rename(new, _)) => manage::rename_project(&central, &name, &new)?,
734        Some(ProjectChange::Repo(new)) => manage::rename_repo(&central, &dir, &new)?,
735        Some(ProjectChange::Move(to)) => manage::move_repo(&central, &dir, &to)?,
736        Some(ProjectChange::Remove(purge)) => manage::remove_project(&central, &name, purge)?,
737    };
738    for line in &done {
739        println!("{line}");
740    }
741    Ok(())
742}
743
744/// How often `wait` reads the brain again. Seconds, as a person takes far longer to answer; the files
745/// are re-read rather than watched, so a waiting agent costs no dependency and no model call.
746const WAIT_POLL: Duration = Duration::from_secs(1);
747
748/// How long `wait` waits without `--timeout`. Claude Code run headless (`claude -p`) doesn't exit while a
749/// background command runs, so a wait nobody answers would hold it forever; past this, an answer is no
750/// longer a conversation, and the next session's `context` picks it up. Under the 10 minutes Claude Code
751/// gives a tool call at most, so the agent gets this message, which says to stop, rather than a killed
752/// command it takes as a hiccup and runs again.
753const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
754
755/// `--timeout MINUTES` after `wait`'s id: `None` for 0 (no limit). Without it, `KANBRAIN_WAIT_TIMEOUT`
756/// (same minutes), so a run with nobody to answer, such as `e2e/agent.sh`, can make every wait short
757/// without changing what agents are told; else the default.
758fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
759    if args.is_empty() {
760        let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
761            return Ok(Some(WAIT_DEFAULT));
762        };
763        return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
764    }
765    if args.remove(0) != "--timeout" || args.is_empty() {
766        bail!("wait takes an id, then optionally --timeout MINUTES");
767    }
768    minutes(&args.remove(0))
769}
770
771/// A number of minutes as a time limit, `None` for 0 (no limit).
772fn minutes(arg: &str) -> Result<Option<Duration>> {
773    let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
774    let seconds = minutes.checked_mul(60).with_context(|| format!("`{arg}` minutes is longer than any wait"))?;
775    Ok((minutes > 0).then(|| Duration::from_secs(seconds)))
776}
777
778/// Blocks until entry `id` no longer waits on a person, then prints what they said; an error once
779/// `timeout` passes without it. An agent runs it in the background after `ask` or `propose`, where its
780/// tool wakes it when it exits: one wake-up with the answer, instead of checking by turns.
781fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
782    let started = std::time::Instant::now();
783    let mut watch = Watch::default();
784    loop {
785        if let Some(said) = watch.look(id, brain.store.load().map(|(tasks, _)| tasks)) {
786            println!("{}", said?);
787            return Ok(());
788        }
789        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
790            bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
791        }
792        std::thread::sleep(WAIT_POLL);
793    }
794}
795
796/// What `wait` keeps from one look at the brain to the next, apart from the clock so a test can play
797/// a run of looks without sleeping through them.
798#[derive(Default)]
799struct Watch {
800    /// The question it found waiting: answered and then moved before the next look, the answer is still what's said.
801    asked_at: Option<chrono::DateTime<chrono::Local>>,
802    /// Whether it looked before: the first look fails at once on a brain it can't read or without the entry, as
803    /// waiting on either would only wait out the time limit.
804    looked: bool,
805    /// Whether the entry was missing from the last look: its file renamed for a new title between listing the
806    /// folder and reading it is back by the next one, so only missing twice in a row it was deleted.
807    missed: bool,
808}
809
810impl Watch {
811    /// Takes one look's read of the brain: `Some` ends the wait, with what to print or why it can't go on;
812    /// `None` looks again.
813    fn look(&mut self, id: u32, read: Result<Vec<Task>>) -> Option<Result<String>> {
814        let first = !std::mem::replace(&mut self.looked, true);
815        // After the first look, a read that fails (the folder caught mid-change) is looked again at next time.
816        match read {
817            Ok(tasks) => match tasks.into_iter().find(|t| t.id == id) {
818                None if first || self.missed => Some(Err(ApiError::NotFound(id).into())),
819                None => {
820                    self.missed = true;
821                    None
822                }
823                Some(task) => {
824                    self.missed = false;
825                    if let Some(Asked::Waiting(question)) = task.asked() {
826                        self.asked_at.get_or_insert(question.created);
827                    }
828                    settled(&task, self.asked_at).map(Ok)
829                }
830            },
831            Err(e) if first => Some(Err(e)),
832            Err(_) => None,
833        }
834    }
835}
836
837/// What `wait` prints once `task` no longer waits on a person, or `None` while it does: a decision that
838/// left proposed, with its verdict's comment; a task's answer, also when it was moved after it (the question
839/// `wait` found waiting was asked at `asked_at`); or that the task waits on nothing (never asked, or picked up since).
840fn settled(task: &Task, asked_at: Option<chrono::DateTime<chrono::Local>>) -> Option<String> {
841    let head = format!("#{} {}", task.id, task.title);
842    if task.kind == Kind::Decision {
843        if task.decision == DecisionState::Proposed {
844            return None;
845        }
846        let verdict = task.decision.as_str();
847        return Some(match task.comments.last() {
848            Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
849            None => format!("{head}: {verdict}"),
850        });
851    }
852    match task.asked() {
853        Some(Asked::Waiting(_)) => None,
854        Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
855        None if task.is_task() => {
856            let answers = |c: &&crate::model::Comment| c.author == crate::model::Author::Human || c.answer;
857            let answer = asked_at
858                .and_then(|at| task.comments.iter().rev().find(|c| c.created >= at && !c.question && answers(c)));
859            Some(match answer {
860                Some(answer) => format!("{head}: answered, now {}\n\n{}", task.status.as_str(), answer.body),
861                None => format!("{head}: not waiting for an answer ({})", task.status.as_str()),
862            })
863        }
864        // A note has no status to name.
865        None => Some(format!("{head}: a note, not waiting for an answer")),
866    }
867}
868
869/// An entry's JSON as `show --json` prints it: [`Entry`], and the entries of the project's other repositories
870/// linking to it, as `web#1`, which `show` lists under Related along with its own `repo_links`.
871#[derive(Serialize)]
872struct Shown<'a> {
873    /// Its fields first, at the top level.
874    #[serde(flatten)]
875    entry: Entry<'a>,
876    /// Left out when empty, as `Entry`'s links are.
877    #[serde(skip_serializing_if = "Vec::is_empty")]
878    repo_linked_from: Vec<String>,
879}
880
881impl<'a> Shown<'a> {
882    /// Reads the project's other repositories for the links to `task`.
883    fn new(brain: &Brain, task: &'a Task) -> Self {
884        let here = brain.project.as_ref().map(|p| RepoLink { repo: p.repo.clone(), id: task.id });
885        let repo_linked_from = brain
886            .repo_related(task.id)
887            .into_iter()
888            .filter(|(_, other)| here.as_ref().is_some_and(|here| other.repo_links.contains(here)))
889            .map(|(repo, other)| RepoLink { repo, id: other.id }.to_string())
890            .collect();
891        Self { entry: Entry::new(brain, task), repo_linked_from }
892    }
893}
894
895/// Prints the entry: as one line of JSON, as compact text (`show`), or as a one-line confirmation.
896/// JSON is on one line since its readers are programs, and agents pay for the indentation.
897fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
898    let task = brain.entry(id)?;
899    if json {
900        println!("{}", serde_json::to_string(&Shown::new(brain, task))?);
901    } else if full {
902        print!("{}", brain.entry_text(id)?);
903    } else {
904        println!("{}", line(task));
905    }
906    Ok(())
907}
908
909/// Prints search results, one `- #id title (state) #tag` line each, or every match as JSON.
910fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
911    if json {
912        let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
913        println!("{}", serde_json::to_string(&summaries)?);
914    } else {
915        print!("{}", brain.search_text(query));
916    }
917    Ok(())
918}
919
920#[cfg(test)]
921#[path = "../../tests/cli/entries.rs"]
922mod tests;