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