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