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::projects;
12use crate::store::{self, PROJECT_DIR, Store};
13use anyhow::{Context, Result, bail};
14use std::fs;
15use std::path::Path;
16
17/// Instructions files agents read at the start of a session: Claude Code's, and the cross-agent one.
18const AGENT_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
19/// Created when the project has none of `AGENT_FILES`.
20const DEFAULT_AGENT_FILE: &str = "CLAUDE.md";
21/// Marks where the section starts and ends, so running `init` again updates it in place instead of
22/// adding another, and everything around it is left alone.
23const START: &str = "<!-- kanbrain:start -->";
24/// See [`START`].
25const END: &str = "<!-- kanbrain:end -->";
26
27/// The section. Read at the start of every session, so it's short, yet the whole CLI an agent needs is
28/// here. It has to win over the agent's own habits: Claude Code, for one, has a built-in to-do list,
29/// and works through a plan without a word to the board unless told the board is watched live.
30const SECTION: &str = "\
31## Project memory: kanbrain
32
33This project's tasks, to-dos, decisions and notes live in `.brain/`, managed only with `kb`, kanbrain's CLI.
34The user follows them live on a kanban board: keep it showing what you're doing as you go, not at the end.
35
36- Start of session: `kb context`, unless its output is already above (a session hook adds it).
37- Work that changes the repository, or that the user should see, gets a task; questions and lookups don't. Find it
38  (`search WORDS`) or create it (`add TITLE`), then `start ID` (`add --start TITLE` does both). \"Todos\", a plan or
39  what's pending, in any language, are tasks here, one `add` each; your built-in to-do list isn't on the board, so
40  keep it for steps within the task you've started. A task with real work gets a body, `add --body - TITLE`:
41  **Goal** (what and why, a line or two) and **Done when** (what the user will check).
42- While working: `comment ID TEXT` when a step is done, when you find a cause, and before you stop (one or two
43  lines: what changed, what's next); `done ID` as soon as one is finished. Stopping before it is: `comment`, then
44  `park ID`. Doing isn't limited for you: several of your tasks can run at once. Never park, finish or move a task
45  you didn't start (the user's, or another session's).
46- Splitting work between subagents: give each part that changes the repository its own task, and run the board
47  yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
48  only do the work; lookups and research get no task. `kb` commands can run in parallel.
49- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
50  question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
51  storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
52  the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
53  command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
54  prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
55  stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
56  the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
57  you to.
58- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
59  then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
60  findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
61  repository that has to change.
62- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
63  or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
64  services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
65  columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
66  picture changes (a service added, another storage), not after every change. With several repositories, this
67  one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
68  file, outside this section: add them as you learn them.
69- Also: `show ID` (one entry in full), `describe ID TEXT`, `link ID OTHER…` (an id here, or `REPO#ID` in another
70  repository of the project); `-` reads text from stdin. Never edit the files in `.brain/` by hand: `kb` keeps
71  them consistent.";
72
73/// Makes `root` a repository of project `name` (else the one its card names, else one named after
74/// the folder), named `repo` in it (else as [`projects::repo_name`] picks): creates its `.brain/` and card,
75/// registers it in `central` (joining the project's other repositories if it has some), creates the project's
76/// shared card if it's new, and writes or refreshes the section in each agent file of `root` (creating
77/// `CLAUDE.md` if there's none). Returns what it did, one line each.
78pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
79    let mut done = Vec::new();
80    let dir = root.join(PROJECT_DIR);
81    if dir.is_dir() {
82        done.push(format!("{} already exists", dir.display()));
83    } else {
84        Store::at(&dir)?;
85        done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
86        done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
87    }
88    // Registered by its absolute path, so the kanban app finds it from anywhere.
89    let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
90    let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
91    let card = projects::read_repo_card(&dir);
92    let project = match (name, &card, folder) {
93        (Some(name), _, _) => name.to_string(),
94        (None, Some(card), _) => card.project.clone(),
95        (None, None, Some(folder)) => folder,
96        (None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
97    };
98    projects::check_name(&project)?;
99    let named = projects::repo_name(central, &project, &dir, repo)?;
100    // The name goes on the card before registering, which tells repositories apart by it; only when it isn't
101    // the folder's, so a card stays as it was for the usual case.
102    let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
103    let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
104    let new_card =
105        projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
106    if card.as_ref() != Some(&new_card) {
107        projects::write_repo_card(&dir, &new_card)?;
108    }
109    projects::register(central, &project, &dir)?;
110    if projects::ensure_description(central, &project)? {
111        done.push(format!(
112            "created project `{project}`'s card in the central brain: describe the project with `kb project \
113             describe` or from the kanban app (or let the agent do it); all its repositories share it"
114        ));
115    }
116    let repos = projects::load(central)?;
117    let here =
118        repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
119    let others: Vec<String> = repos
120        .iter()
121        .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
122        .map(projects::Repo::name)
123        .collect();
124    if others.is_empty() {
125        done.push(format!(
126            "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
127        ));
128    } else {
129        done.push(format!(
130            "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
131            others.join(", ")
132        ));
133    }
134    let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
135    let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
136    done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
137    if !on_path(std::env::var_os("PATH"), "kb") {
138        done.push(
139            "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
140             (the installer's `--app both`)"
141                .into(),
142        );
143    }
144    Ok(done)
145}
146
147/// Brings every registered repository up to this version: its data format, migrated with the central brain's
148/// (see [`crate::migrate`]), and its agent instructions, the section in each of its agent files that has one
149/// and Claude Code's hooks where `CLAUDE.md` has it. A repository whose files have no section (taken out by
150/// hand) keeps them as they are, and one whose folder is gone is left alone. Returns what changed, one line
151/// each, naming the repository.
152pub fn init_all(central: &Path) -> Result<Vec<String>> {
153    let repos = projects::load(central)?;
154    let mut done = migrate_all(central, &repos)?;
155    let mut current = 0;
156    for repo in repos.iter().filter(|r| r.dir.is_dir()) {
157        let Some(root) = repo.dir.parent() else { continue };
158        let targets: Vec<&str> = AGENT_FILES
159            .into_iter()
160            .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
161            .collect();
162        if targets.is_empty() {
163            continue;
164        }
165        let changes: Vec<String> = write_agent_files(root, &targets)?
166            .into_iter()
167            .filter(|(changed, _)| *changed)
168            .map(|(_, line)| line)
169            .collect();
170        if changes.is_empty() {
171            current += 1;
172        }
173        let label = repo.label(&repos);
174        done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
175    }
176    if current > 0 {
177        done.push(format!("{current} more already had this version's instructions"));
178    }
179    Ok(done)
180}
181
182/// Brings the central brain and every registered repository's to this version's data format (see
183/// [`crate::migrate`]), as `kb update` runs it. A repository a newer kanbrain wrote is said and left alone.
184fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
185    let mut done = crate::migrate::migrate(&Store::at(central)?)?;
186    for repo in repos.iter().filter(|r| r.dir.is_dir()) {
187        match crate::migrate::migrate(&Store::at(&repo.dir)?) {
188            Ok(lines) => done.extend(lines),
189            Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
190                done.push(format!("{}: left alone: {e}", repo.label(repos)));
191            }
192            Err(e) => return Err(e),
193        }
194    }
195    Ok(done)
196}
197
198/// Writes or updates the section in each of `targets`, agent files of `root`, and Claude Code's hooks when
199/// `CLAUDE.md` is one of them. Returns what it did, one line each, and whether that changed anything.
200fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
201    let mut done = Vec::new();
202    // Claude Code's hooks go with its file; agents that read only AGENTS.md have no use for them.
203    if targets.contains(&"CLAUDE.md") {
204        done.push(match claude::add_hooks(root) {
205            Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
206            Ok(_) => (
207                true,
208                format!(
209                    "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
210                     after compaction, and an agent can't end its turn while the project isn't described",
211                    claude::SETTINGS
212                ),
213            ),
214            // The section still works without them, so a settings file it can't read doesn't stop init.
215            Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
216        });
217    }
218    for &name in targets {
219        let path = root.join(name);
220        let before = fs::read_to_string(&path).unwrap_or_default();
221        let after = with_section(&before);
222        if after == before {
223            done.push((false, format!("{name} already tells agents about kanbrain")));
224            continue;
225        }
226        store::write_file(&path, &after)?;
227        let what = if before.is_empty() {
228            "created"
229        } else if before.contains(START) {
230            "updated"
231        } else {
232            "added to"
233        };
234        done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
235    }
236    Ok(done)
237}
238
239/// Whether a program named `name` is in one of the folders of `path`, a `PATH` value.
240fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
241    let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
242    path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
243}
244
245/// Takes the repository whose `.brain/` is `dir` out of the project registry, deleting the project's
246/// card if it was its last repository, and takes the section out of each agent file of the repository
247/// (deleting a file left empty, which `init` created). `.brain/` is left as it is: whether it goes is
248/// [`super::manage::remove_repo`]'s. Returns what it did, one line each.
249pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
250    let Some(repo) = projects::unregister(central, dir)? else {
251        bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
252    };
253    let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
254    let others: Vec<String> =
255        projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
256    if !others.is_empty() {
257        done.push(format!(
258            "project `{}` keeps its card for its other repositories: {}",
259            repo.project,
260            others.join(", ")
261        ));
262    } else if projects::remove_description(central, &repo.project)? {
263        done.push(format!("removed project `{}`'s card from the central brain", repo.project));
264    }
265    let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
266        done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
267        return Ok(done);
268    };
269    match claude::remove_hooks(root) {
270        Ok(claude::Change::Unchanged) => {}
271        Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
272        Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
273        Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
274    }
275    for name in AGENT_FILES {
276        let path = root.join(name);
277        let Ok(before) = fs::read_to_string(&path) else { continue };
278        let after = without_section(&before);
279        if after == before {
280            continue;
281        }
282        if after.trim().is_empty() {
283            fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
284            done.push(format!("removed {name}: it only held the \"Project memory\" section"));
285        } else {
286            store::write_file(&path, &after)?;
287            done.push(format!("removed the \"Project memory\" section from {name}"));
288        }
289    }
290    Ok(done)
291}
292
293/// Where the section is in `text`, with the line break after its end marker. It's the last start
294/// marker before the first end marker, so a stray unclosed start marker above it is never taken for
295/// the start and the text after it is kept.
296fn section(text: &str) -> Option<(usize, usize)> {
297    let end = text.find(END)? + END.len();
298    let start = text[..end].rfind(START)?;
299    Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
300}
301
302/// `text` with the section between the markers: replaced where it already is, else appended after
303/// a blank line.
304fn with_section(text: &str) -> String {
305    let block = format!("{START}\n{SECTION}\n{END}\n");
306    if let Some((start, end)) = section(text) {
307        return format!("{}{block}{}", &text[..start], &text[end..]);
308    }
309    match text.trim_end() {
310        "" => block,
311        kept => format!("{kept}\n\n{block}"),
312    }
313}
314
315/// `text` without the section, and without the blank line `with_section` put before it.
316fn without_section(text: &str) -> String {
317    let Some((start, end)) = section(text) else { return text.to_string() };
318    let before = text[..start].trim_end();
319    let after = text[end..].trim_start_matches('\n');
320    match (before, after) {
321        ("", after) => after.to_string(),
322        (before, "") => format!("{before}\n"),
323        (before, after) => format!("{before}\n\n{after}"),
324    }
325}
326
327#[cfg(test)]
328#[path = "../../tests/cli/init.rs"]
329mod tests;