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 = concat!(
31    "\
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 findings: `add --note --link ID --body - TITLE`;
61  a problem someone has to fix is a task instead, in the 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 -` (",
64    crate::projects::description_scope!(),
65    "). Rewrite it when the big picture changes (a service added, another storage), not after every change. With
66  several repositories, this one's role too: `project notes -`. How to build, test and run this repository, and its
67  code style, go in this file, outside this section: add them as you learn them.
68- Also: `show ID` (one entry in full), `describe ID TEXT`, `link ID OTHER…` (an id here, or `REPO#ID` in another
69  repository of the project); `-` reads text from stdin. Never edit the files in `.brain/` by hand: `kb` keeps
70  them consistent.",
71);
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;