Skip to main content

kanbrain_core/cli/
init.rs

1//! `kb init`: makes the current folder a project. It gets its own `.brain/` with a card
2//! (`project.md`: its name and what it's about), is registered in the central brain so the kanban app
3//! shows it and can filter by it, and its coding agents are told how to use it, by a short section in
4//! their instructions file (`CLAUDE.md`, `AGENTS.md`). Without that section an agent doesn't know the
5//! brain exists until someone pastes it by hand.
6//!
7//! `kb deinit` undoes it, except for `.brain/` itself: its tasks, notes and decisions are the
8//! repository's, often committed with it, so deleting them is left to the user.
9
10use super::claude;
11use crate::model::counted;
12use crate::projects;
13use crate::store::{self, PROJECT_DIR, Store};
14use anyhow::{Context, Result, bail};
15use std::fs;
16use std::path::Path;
17
18/// Instructions files agents read at the start of a session: Claude Code's, and the cross-agent one.
19const AGENT_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
20/// Created when the project has none of `AGENT_FILES`.
21const DEFAULT_AGENT_FILE: &str = "CLAUDE.md";
22/// Marks where the section starts and ends, so running `init` again updates it in place instead of
23/// adding another, and everything around it is left alone.
24const START: &str = "<!-- kanbrain:start -->";
25/// See [`START`].
26const END: &str = "<!-- kanbrain:end -->";
27
28/// The section. Read at the start of every session, so it's short, yet the whole CLI an agent needs is
29/// here. It has to win over the agent's own habits: Claude Code, for one, has a built-in to-do list,
30/// and works through a plan without a word to the board unless told the board is watched live.
31const SECTION: &str = "\
32## Project memory: kanbrain
33
34This project's tasks, to-dos, decisions and notes live in `.brain/`, managed only with `kb`, kanbrain's CLI.
35The user follows them live on a kanban board: keep it showing what you're doing as you go, not at the end.
36
37- Start of session: `kb context`, unless its output is already above (a session hook adds it).
38- Work that changes the repository, or that the user should see, gets a task; questions and lookups don't. Find it
39  (`search WORDS`) or create it (`add TITLE`), then `start ID` (`add --start TITLE` does both). \"Todos\", a plan or
40  what's pending, in any language, are tasks here, one `add` each; your built-in to-do list isn't on the board, so
41  keep it for steps within the task you've started. A task with real work gets a body, `add --body - TITLE`:
42  **Goal** (what and why, a line or two) and **Done when** (what the user will check).
43- While working: `comment ID TEXT` when a step is done, when you find a cause, and before you stop (one or two
44  lines: what changed, what's next); `done ID` as soon as one is finished. Stopping before it is: `comment`, then
45  `park ID`. Doing isn't limited for you: several of your tasks can run at once. Never park, finish or move a task
46  you didn't start (the user's, or another session's). A pull request, issue, CI run, commit or page named in a
47  comment or body goes with its link, `[PR #39](URL)`, not just `PR #39`: the user opens it from the board.
48- Splitting work between subagents: give each part that changes the repository its own task, and run the board
49  yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
50  only do the work; lookups and research get no task. `kb` commands can run in parallel.
51- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
52  question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
53  storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
54  the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
55  command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
56  prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
57  stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
58  the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
59  you to.
60- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
61  then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
62  findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
63  repository that has to change.
64- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
65  or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
66  services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
67  columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
68  picture changes (a service added, another storage), not after every change. With several repositories, this
69  one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
70  file, outside this section: add them as you learn them.
71- Also: `show ID` (one entry in full), `describe ID TEXT`, `rename ID TITLE`, `tag ID +TAG -TAG` (add or remove
72  tags), `link ID OTHER…` (an id here, or `REPO#ID` in another repository of the project); `-` reads text from
73  stdin. Never edit the files in `.brain/` by hand: `kb` keeps them consistent.";
74
75/// Makes `root` a repository of project `name` (else the one its card names, else one named after
76/// the folder), named `repo` in it (else as [`projects::repo_name`] picks): creates its `.brain/` and card,
77/// registers it in `central` (joining the project's other repositories if it has some), creates the project's
78/// shared card if it's new, and writes or refreshes the section in each agent file of `root` (creating
79/// `CLAUDE.md` if there's none). Returns what it did, one line each.
80///
81/// A repository of another project, still there, is moved to `name` as `kb project move` moves it: the links
82/// between it and its old project's repositories go, and so does that project's card if it was the last.
83pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
84    if let Some(to) = name
85        && let Ok(dir) = root.join(PROJECT_DIR).canonicalize()
86        && projects::load(central)?.iter().any(|r| projects::same_dir(&r.dir, &dir) && r.project != to)
87    {
88        let mut done = super::manage::move_repo(central, &dir, to)?;
89        // Moved already: a name it can't take is said beside the move, not instead of it.
90        if let Some(repo) = repo {
91            match super::manage::rename_repo(central, &dir, repo) {
92                Ok(lines) => done.extend(lines),
93                Err(e) => done.push(format!("kept the name it has: {e:#}")),
94            }
95        }
96        return Ok(done);
97    }
98    join(root, name, repo, central)
99}
100
101/// What [`init`] does for a folder that isn't a repository of another project: [`super::manage::move_repo`] runs
102/// it once the repository has left its old project.
103pub(crate) fn join(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
104    let mut done = Vec::new();
105    let dir = root.join(PROJECT_DIR);
106    // The registry holds a path a line; one with a line break would read as a second, made-up registration.
107    if root.to_string_lossy().contains(['\n', '\r']) {
108        bail!("{} has a line break in its path: kanbrain can't register it", root.display());
109    }
110    // Registered, its `.brain/` would be both the central brain and a project's, and taking it out with `--purge`
111    // would delete every project's registration and card.
112    if projects::same_dir(&dir, central) {
113        bail!("{} is the central brain, not a project's: run `kb init` in a project's folder", dir.display());
114    }
115    if dir.is_dir() {
116        done.push(format!("{} already exists", dir.display()));
117    } else {
118        Store::at(&dir)?;
119        done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
120        done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
121    }
122    // Registered by its absolute path, so the kanban app finds it from anywhere.
123    let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
124    let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
125    let card = projects::read_repo_card(&dir);
126    // Re-run without a name, it stays in the project the registry has it in, which a rename while its folder was
127    // away left its card behind on.
128    let registered = projects::load(central)?.into_iter().find(|r| projects::same_dir(&r.dir, &dir)).map(|r| r.project);
129    // A name a version before 0.4.0 registered, which `init --all` renames: said, rather than to pick another.
130    if name.is_none()
131        && let Some(old) = registered.as_deref()
132        && projects::check_name(old).is_err()
133    {
134        bail!(
135            "this repository is registered in project `{old}`, a name kanbrain no longer takes: `kb init --all` \
136             renames such projects, keeping their repositories and card"
137        );
138    }
139    let project = match (name, registered, &card, folder) {
140        (Some(name), ..) => name.to_string(),
141        (None, Some(registered), ..) => registered,
142        (None, None, Some(card), _) => card.project.clone(),
143        (None, None, None, Some(folder)) => folder,
144        (None, None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
145    };
146    projects::check_name(&project)?;
147    let named = projects::repo_name(central, &project, &dir, repo)?;
148    // The name goes on the card before registering, which tells repositories apart by it; only when it isn't
149    // the folder's, so a card stays as it was for the usual case.
150    let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
151    let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
152    let new_card =
153        projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
154    if card.as_ref() != Some(&new_card) {
155        projects::write_repo_card(&dir, &new_card)?;
156    }
157    projects::register(central, &project, &dir)?;
158    if projects::ensure_description(central, &project)? {
159        done.push(format!(
160            "created project `{project}`'s card in the central brain: describe the project with `kb project \
161             describe` or from the kanban app (or let the agent do it); all its repositories share it"
162        ));
163    }
164    let repos = projects::load(central)?;
165    let here =
166        repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
167    let others: Vec<String> = repos
168        .iter()
169        .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
170        .map(projects::Repo::name)
171        .collect();
172    if others.is_empty() {
173        done.push(format!(
174            "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
175        ));
176    } else {
177        done.push(format!(
178            "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
179            others.join(", ")
180        ));
181    }
182    // A folder moved or renamed by hand leaves its old place registered: said, since only the user knows if
183    // this is it.
184    for gone in repos.iter().filter(|r| r.project == project && !r.dir.is_dir()) {
185        let name = gone.name();
186        // Its name kept for it, this one took another (`repo_name`): once the old place is out, it can take that one
187        // back.
188        done.push(format!(
189            "`{project}/{name}`'s folder is gone ({}): if this is it, moved here, take the old place out with \
190             `kb --project {project}/{name} deinit`, then `kb project repo {name}` here",
191            gone.dir.display()
192        ));
193    }
194    let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
195    let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
196    done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
197    if !on_path(std::env::var_os("PATH"), "kb") {
198        done.push(
199            "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
200             (the installer's `--app both`)"
201                .into(),
202        );
203    }
204    Ok(done)
205}
206
207/// Renames each project an older version named as none can be now (see [`projects::check_name`]): one starting
208/// with `-`, which `--project` reads as an option, `Central` in any case, which reaches the central brain (both
209/// before 0.4.0), one with a lone `\r`, a line break to markdown (before 0.5.0), or one with `:` or `/` (a registry
210/// edited by hand). Without it, `kb init` in its repositories is refused.
211/// Returns what it did, one line each.
212fn rename_unreachable_projects(central: &Path) -> Result<Vec<String>> {
213    let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
214    names.sort_unstable();
215    names.dedup();
216    let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
217    let mut done = Vec::new();
218    for old in unreachable {
219        let kept: String = old.chars().map(|c| if matches!(c, ':' | '/' | '\n' | '\r') { '-' } else { c }).collect();
220        let base = match kept.trim().trim_start_matches('-').trim() {
221            "" => "project",
222            base => base,
223        };
224        // At most `base` is refused (`Central`; its `-project` forms never are) and every other project takes one: of
225        // these, one more than there are projects, one is free.
226        let new = (0..=names.len())
227            .map(|n| match n {
228                0 => base.to_string(),
229                1 => format!("{base}-project"),
230                n => format!("{base}-project-{n}"),
231            })
232            .find(|name| projects::check_name(name).is_ok() && !names.contains(name))
233            .expect("more names than projects");
234        // One that fails is said, as `init --all` says each repository it couldn't bring up to date.
235        match super::manage::rename_project(central, &old, &new) {
236            Ok(lines) => done.extend(lines),
237            Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
238        }
239        names.push(new);
240    }
241    Ok(done)
242}
243
244/// Brings every registered repository up to this version: its data format, migrated with the central brain's
245/// (see [`crate::migrate`]), and its agent instructions, the section in each of its agent files that has one
246/// and Claude Code's hooks where `CLAUDE.md` has it. A repository whose files have no section (taken out by
247/// hand) keeps them as they are, and one whose folder is gone is left alone. Returns what changed, one line
248/// each, naming the repository.
249pub fn init_all(central: &Path) -> Result<Vec<String>> {
250    // Migrated first: a brain a newer kanbrain wrote stops it before anything is written.
251    let mut done = migrate_all(central, &projects::load(central)?)?;
252    done.extend(rename_unreachable_projects(central)?);
253    let repos = projects::load(central)?;
254    let mut current = 0;
255    for repo in repos.iter().filter(|r| r.dir.is_dir()) {
256        // Named after a folder whose name links can't hold (`My App`), by a version before 0.4.0: renamed.
257        let name = repo.name();
258        if projects::check_repo_name(&name).is_err() {
259            let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
260            done.extend(super::manage::rename_repo(central, &repo.dir, &fixed)?);
261        }
262        // A card left on a project's old name, renamed while this folder was away.
263        if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
264            let old = card.project.clone();
265            projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
266            done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
267        }
268        let Some(root) = repo.dir.parent() else { continue };
269        let targets: Vec<&str> = AGENT_FILES
270            .into_iter()
271            .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
272            .collect();
273        if targets.is_empty() {
274            continue;
275        }
276        let changes: Vec<String> = write_agent_files(root, &targets)?
277            .into_iter()
278            .filter(|(changed, _)| *changed)
279            .map(|(_, line)| line)
280            .collect();
281        if changes.is_empty() {
282            current += 1;
283        }
284        let label = repo.label(&repos);
285        done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
286    }
287    // "More" only after the ones listed: alone, it reads as if some were left out.
288    match (current, done.is_empty()) {
289        (0, _) => {}
290        (_, true) => done.push(format!(
291            "{} already had this version's instructions",
292            counted(current, "repository", "repositories")
293        )),
294        (_, false) => done.push(format!("{current} more already had this version's instructions")),
295    }
296    Ok(done)
297}
298
299/// Brings the central brain and every registered repository's to this version's data format (see
300/// [`crate::migrate`]), as `kb update` runs it. A repository a newer kanbrain wrote is said and left alone; one
301/// that fails (a damaged `format` file, a step that can't write) is said too, rather than keep every other
302/// repository from being brought up to date.
303fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
304    let mut done = crate::migrate::migrate(&Store::at(central)?)?;
305    for repo in repos.iter().filter(|r| r.dir.is_dir()) {
306        match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
307            Ok(lines) => done.extend(lines),
308            Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
309                done.push(format!("{}: left alone: {e}", repo.label(repos)));
310            }
311            Err(e) => done.push(format!(
312                "{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
313                repo.label(repos)
314            )),
315        }
316    }
317    Ok(done)
318}
319
320/// Writes or updates the section in each of `targets`, agent files of `root`, and Claude Code's hooks when
321/// `CLAUDE.md` is one of them. Returns what it did, one line each, and whether that changed anything.
322fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
323    let mut done = Vec::new();
324    // Claude Code's hooks go with its file; agents that read only AGENTS.md have no use for them.
325    if targets.contains(&"CLAUDE.md") {
326        done.push(match claude::add_hooks(root) {
327            Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
328            Ok(_) => (
329                true,
330                format!(
331                    "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
332                     after compaction, reminds an agent with every prompt and at the end of its turn to \
333                     describe the project while it isn't, sends it to `kb` when it would write the brain's \
334                     files itself, and tells it of your comments on a task it's working on with its next tool",
335                    claude::SETTINGS
336                ),
337            ),
338            // The section still works without them, so a settings file it can't read doesn't stop init.
339            Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
340        });
341    }
342    for &name in targets {
343        let path = root.join(name);
344        // Only a missing file is empty: one it can't read (not UTF-8) would be written over with the section alone.
345        let before = match fs::read_to_string(&path) {
346            Ok(text) => text,
347            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
348            Err(e) => {
349                done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
350                continue;
351            }
352        };
353        let after = with_section(&before);
354        if after == before {
355            done.push((false, format!("{name} already tells agents about kanbrain")));
356            continue;
357        }
358        store::write_file(&path, &after)?;
359        let what = if before.is_empty() {
360            "created"
361        } else if before.contains(START) {
362            "updated"
363        } else {
364            "added to"
365        };
366        done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
367    }
368    Ok(done)
369}
370
371/// Whether a program named `name` is in one of the folders of `path`, a `PATH` value.
372fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
373    let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
374    path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
375}
376
377/// Takes the repository whose `.brain/` is `dir` out of the project registry, deleting the project's
378/// card if it was its last repository, and takes the section out of each agent file of the repository
379/// (deleting a file left empty, which `init` created). `.brain/` is left as it is: whether it goes is
380/// [`super::manage::remove_repo`]'s. Returns what it did, one line each.
381pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
382    let Some(repo) = projects::unregister(central, dir)? else {
383        bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
384    };
385    let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
386    let others: Vec<String> =
387        projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
388    if !others.is_empty() {
389        done.push(format!(
390            "project `{}` keeps its card for its other repositories: {}",
391            repo.project,
392            others.join(", ")
393        ));
394    } else if projects::remove_description(central, &repo.project)? {
395        done.push(format!("removed project `{}`'s card from the central brain", repo.project));
396    }
397    let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
398        done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
399        return Ok(done);
400    };
401    match claude::remove_hooks(root) {
402        Ok(claude::Change::Unchanged) => {}
403        Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
404        Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
405        Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
406    }
407    for name in AGENT_FILES {
408        let path = root.join(name);
409        let Ok(before) = fs::read_to_string(&path) else { continue };
410        let after = without_section(&before);
411        if after == before {
412            continue;
413        }
414        if after.trim().is_empty() {
415            fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
416            done.push(format!("removed {name}: it only held the \"Project memory\" section"));
417        } else {
418            store::write_file(&path, &after)?;
419            done.push(format!("removed the \"Project memory\" section from {name}"));
420        }
421    }
422    Ok(done)
423}
424
425/// Where the section is in `text`, with the line break after its end marker. It's the last start
426/// marker before the first end marker, so a stray unclosed start marker above it is never taken for
427/// the start and the text after it is kept.
428fn section(text: &str) -> Option<(usize, usize)> {
429    let end = text.find(END)? + END.len();
430    let start = text[..end].rfind(START)?;
431    Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
432}
433
434/// `text` with the section between the markers: replaced where it already is, else appended after
435/// a blank line.
436fn with_section(text: &str) -> String {
437    let block = format!("{START}\n{SECTION}\n{END}\n");
438    if let Some((start, end)) = section(text) {
439        return format!("{}{block}{}", &text[..start], &text[end..]);
440    }
441    match text.trim_end() {
442        "" => block,
443        kept => format!("{kept}\n\n{block}"),
444    }
445}
446
447/// `text` without the section, and without the blank line `with_section` put before it.
448fn without_section(text: &str) -> String {
449    let Some((start, end)) = section(text) else { return text.to_string() };
450    let before = text[..start].trim_end();
451    let after = text[end..].trim_start_matches('\n');
452    match (before, after) {
453        ("", after) => after.to_string(),
454        (before, "") => format!("{before}\n"),
455        (before, after) => format!("{before}\n\n{after}"),
456    }
457}
458
459#[cfg(test)]
460#[path = "../../tests/cli/init.rs"]
461mod tests;