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