1mod 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
24const 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
32pub 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#[derive(Debug, PartialEq, Eq)]
73#[non_exhaustive]
74pub struct EntryArgs {
75 pub command: EntryCommand,
77 pub json: bool,
79}
80
81#[derive(Debug, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum EntryCommand {
85 #[non_exhaustive]
87 Add(AddArgs),
88 #[non_exhaustive]
90 Show(u32),
91 #[non_exhaustive]
93 Search(String),
94 #[non_exhaustive]
96 SetStatus(u32, Status),
97 #[non_exhaustive]
99 Comment(u32, Text),
100 #[non_exhaustive]
102 Ask(u32, Text),
103 #[non_exhaustive]
105 Wait(u32, Option<Duration>),
106 #[non_exhaustive]
108 Propose(u32, AddArgs),
109 #[non_exhaustive]
111 Resolve(u32, DecisionState, Option<Text>),
112 #[non_exhaustive]
114 Describe(u32, Text),
115 #[non_exhaustive]
117 Rename(u32, String),
118 #[non_exhaustive]
120 Tag(u32, Vec<TagChange>),
121 #[non_exhaustive]
123 RenameTag(String, String, bool),
124 #[non_exhaustive]
126 Link(u32, Vec<u32>, Vec<RepoLink>),
127 #[non_exhaustive]
129 Context,
130 #[non_exhaustive]
132 Project(Option<ProjectChange>),
133}
134
135#[derive(Debug, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum ProjectChange {
139 #[non_exhaustive]
141 Text(ProjectPart, Text),
142 #[non_exhaustive]
144 Rename(String, bool),
145 #[non_exhaustive]
147 Repo(String),
148 #[non_exhaustive]
150 Move(String),
151 #[non_exhaustive]
153 Remove(bool),
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum ProjectPart {
160 #[non_exhaustive]
162 Description,
163 #[non_exhaustive]
165 Notes,
166}
167
168#[derive(Debug, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum TagChange {
172 #[non_exhaustive]
174 Add(String),
175 #[non_exhaustive]
177 Remove(String),
178}
179
180#[derive(Debug, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum Text {
184 #[non_exhaustive]
186 Given(String),
187 #[non_exhaustive]
189 Stdin,
190}
191
192#[derive(Debug, PartialEq, Eq, Default)]
194#[non_exhaustive]
195pub struct AddArgs {
196 pub kind: Kind,
198 pub title: String,
200 pub tags: Vec<String>,
202 pub links: Vec<u32>,
204 pub repo_links: Vec<RepoLink>,
206 pub body: Option<Text>,
208 pub start: bool,
210}
211
212impl EntryArgs {
213 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 pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
238 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 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 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 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 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 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 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 "--" => 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 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 fn read_stdin(&mut self) -> Result<()> {
478 if *self == Text::Stdin {
479 *self = Text::Given(stdin_text()?);
480 }
481 Ok(())
482 }
483
484 fn read(self) -> Result<String> {
486 match self {
487 Text::Given(text) => Ok(text),
488 Text::Stdin => stdin_text(),
489 }
490 }
491}
492
493fn 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
504fn 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;