1use super::claude;
11use crate::model::counted;
12use crate::projects;
13use crate::store::{self, PROJECT_DIR, Store};
14use anyhow::{Context, Result, bail};
15use std::fs;
16use std::path::Path;
17
18const AGENT_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
20const DEFAULT_AGENT_FILE: &str = "CLAUDE.md";
22const START: &str = "<!-- kanbrain:start -->";
25const END: &str = "<!-- kanbrain:end -->";
27
28const SECTION: &str = "\
32## Project memory: kanbrain
33
34This project's tasks, to-dos, decisions and notes live in `.brain/`, managed only with `kb`, kanbrain's CLI.
35The user follows them live on a kanban board: keep it showing what you're doing as you go, not at the end.
36
37- Start of session: `kb context`, unless its output is already above (a session hook adds it).
38- Work that changes the repository, or that the user should see, gets a task; questions and lookups don't. Find it
39 (`search WORDS`) or create it (`add TITLE`), then `start ID` (`add --start TITLE` does both). \"Todos\", a plan or
40 what's pending, in any language, are tasks here, one `add` each; your built-in to-do list isn't on the board, so
41 keep it for steps within the task you've started. A task with real work gets a body, `add --body - TITLE`:
42 **Goal** (what and why, a line or two) and **Done when** (what the user will check).
43- While working: `comment ID TEXT` when a step is done, when you find a cause, and before you stop (one or two
44 lines: what changed, what's next); `done ID` as soon as one is finished. Stopping before it is: `comment`, then
45 `park ID`. Doing isn't limited for you: several of your tasks can run at once. Never park, finish or move a task
46 you didn't start (the user's, or another session's).
47- Splitting work between subagents: give each part that changes the repository its own task, and run the board
48 yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
49 only do the work; lookups and research get no task. `kb` commands can run in parallel.
50- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
51 question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
52 storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
53 the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
54 command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
55 prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
56 stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
57 the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
58 you to.
59- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
60 then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
61 findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
62 repository that has to change.
63- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
64 or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
65 services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
66 columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
67 picture changes (a service added, another storage), not after every change. With several repositories, this
68 one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
69 file, outside this section: add them as you learn them.
70- Also: `show ID` (one entry in full), `describe ID TEXT`, `link ID OTHER…` (an id here, or `REPO#ID` in another
71 repository of the project); `-` reads text from stdin. Never edit the files in `.brain/` by hand: `kb` keeps
72 them consistent.";
73
74pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
83 if let Some(to) = name
84 && let Ok(dir) = root.join(PROJECT_DIR).canonicalize()
85 && projects::load(central)?.iter().any(|r| projects::same_dir(&r.dir, &dir) && r.project != to)
86 {
87 let mut done = super::manage::move_repo(central, &dir, to)?;
88 if let Some(repo) = repo {
90 match super::manage::rename_repo(central, &dir, repo) {
91 Ok(lines) => done.extend(lines),
92 Err(e) => done.push(format!("kept the name it has: {e:#}")),
93 }
94 }
95 return Ok(done);
96 }
97 join(root, name, repo, central)
98}
99
100pub(crate) fn join(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
103 let mut done = Vec::new();
104 let dir = root.join(PROJECT_DIR);
105 if root.to_string_lossy().contains(['\n', '\r']) {
107 bail!("{} has a line break in its path: kanbrain can't register it", root.display());
108 }
109 if projects::same_dir(&dir, central) {
112 bail!("{} is the central brain, not a project's: run `kb init` in a project's folder", dir.display());
113 }
114 if dir.is_dir() {
115 done.push(format!("{} already exists", dir.display()));
116 } else {
117 Store::at(&dir)?;
118 done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
119 done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
120 }
121 let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
123 let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
124 let card = projects::read_repo_card(&dir);
125 let registered = projects::load(central)?.into_iter().find(|r| projects::same_dir(&r.dir, &dir)).map(|r| r.project);
128 if name.is_none()
130 && let Some(old) = registered.as_deref()
131 && projects::check_name(old).is_err()
132 {
133 bail!(
134 "this repository is registered in project `{old}`, a name kanbrain no longer takes: `kb init --all` \
135 renames such projects, keeping their repositories and card"
136 );
137 }
138 let project = match (name, registered, &card, folder) {
139 (Some(name), ..) => name.to_string(),
140 (None, Some(registered), ..) => registered,
141 (None, None, Some(card), _) => card.project.clone(),
142 (None, None, None, Some(folder)) => folder,
143 (None, None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
144 };
145 projects::check_name(&project)?;
146 let named = projects::repo_name(central, &project, &dir, repo)?;
147 let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
150 let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
151 let new_card =
152 projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
153 if card.as_ref() != Some(&new_card) {
154 projects::write_repo_card(&dir, &new_card)?;
155 }
156 projects::register(central, &project, &dir)?;
157 if projects::ensure_description(central, &project)? {
158 done.push(format!(
159 "created project `{project}`'s card in the central brain: describe the project with `kb project \
160 describe` or from the kanban app (or let the agent do it); all its repositories share it"
161 ));
162 }
163 let repos = projects::load(central)?;
164 let here =
165 repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
166 let others: Vec<String> = repos
167 .iter()
168 .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
169 .map(projects::Repo::name)
170 .collect();
171 if others.is_empty() {
172 done.push(format!(
173 "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
174 ));
175 } else {
176 done.push(format!(
177 "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
178 others.join(", ")
179 ));
180 }
181 for gone in repos.iter().filter(|r| r.project == project && !r.dir.is_dir()) {
184 let name = gone.name();
185 done.push(format!(
188 "`{project}/{name}`'s folder is gone ({}): if this is it, moved here, take the old place out with \
189 `kb --project {project}/{name} deinit`, then `kb project repo {name}` here",
190 gone.dir.display()
191 ));
192 }
193 let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
194 let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
195 done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
196 if !on_path(std::env::var_os("PATH"), "kb") {
197 done.push(
198 "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
199 (the installer's `--app both`)"
200 .into(),
201 );
202 }
203 Ok(done)
204}
205
206fn rename_unreachable_projects(central: &Path) -> Result<Vec<String>> {
210 let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
211 names.sort_unstable();
212 names.dedup();
213 let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
214 let mut done = Vec::new();
215 for old in unreachable {
216 let base = match old.trim_start_matches('-').trim() {
217 "" => "project",
218 base => base,
219 };
220 let new = (0..names.len())
222 .map(|n| match n {
223 0 => base.to_string(),
224 1 => format!("{base}-project"),
225 n => format!("{base}-project-{n}"),
226 })
227 .find(|name| projects::check_name(name).is_ok() && !names.contains(name))
228 .expect("more names than projects");
229 match super::manage::rename_project(central, &old, &new) {
231 Ok(lines) => done.extend(lines),
232 Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
233 }
234 names.push(new);
235 }
236 Ok(done)
237}
238
239pub fn init_all(central: &Path) -> Result<Vec<String>> {
245 let mut done = migrate_all(central, &projects::load(central)?)?;
247 done.extend(rename_unreachable_projects(central)?);
248 let repos = projects::load(central)?;
249 let mut current = 0;
250 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
251 let name = repo.name();
253 if projects::check_repo_name(&name).is_err() {
254 let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
255 done.extend(super::manage::rename_repo(central, &repo.dir, &fixed)?);
256 }
257 if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
259 let old = card.project.clone();
260 projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
261 done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
262 }
263 let Some(root) = repo.dir.parent() else { continue };
264 let targets: Vec<&str> = AGENT_FILES
265 .into_iter()
266 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
267 .collect();
268 if targets.is_empty() {
269 continue;
270 }
271 let changes: Vec<String> = write_agent_files(root, &targets)?
272 .into_iter()
273 .filter(|(changed, _)| *changed)
274 .map(|(_, line)| line)
275 .collect();
276 if changes.is_empty() {
277 current += 1;
278 }
279 let label = repo.label(&repos);
280 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
281 }
282 match (current, done.is_empty()) {
284 (0, _) => {}
285 (_, true) => done.push(format!(
286 "{} already had this version's instructions",
287 counted(current, "repository", "repositories")
288 )),
289 (_, false) => done.push(format!("{current} more already had this version's instructions")),
290 }
291 Ok(done)
292}
293
294fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
299 let mut done = crate::migrate::migrate(&Store::at(central)?)?;
300 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
301 match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
302 Ok(lines) => done.extend(lines),
303 Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
304 done.push(format!("{}: left alone: {e}", repo.label(repos)));
305 }
306 Err(e) => done.push(format!(
307 "{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
308 repo.label(repos)
309 )),
310 }
311 }
312 Ok(done)
313}
314
315fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
318 let mut done = Vec::new();
319 if targets.contains(&"CLAUDE.md") {
321 done.push(match claude::add_hooks(root) {
322 Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
323 Ok(_) => (
324 true,
325 format!(
326 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
327 after compaction, and an agent is sent back once a turn to describe the project while it isn't",
328 claude::SETTINGS
329 ),
330 ),
331 Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
333 });
334 }
335 for &name in targets {
336 let path = root.join(name);
337 let before = match fs::read_to_string(&path) {
339 Ok(text) => text,
340 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
341 Err(e) => {
342 done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
343 continue;
344 }
345 };
346 let after = with_section(&before);
347 if after == before {
348 done.push((false, format!("{name} already tells agents about kanbrain")));
349 continue;
350 }
351 store::write_file(&path, &after)?;
352 let what = if before.is_empty() {
353 "created"
354 } else if before.contains(START) {
355 "updated"
356 } else {
357 "added to"
358 };
359 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
360 }
361 Ok(done)
362}
363
364fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
366 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
367 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
368}
369
370pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
375 let Some(repo) = projects::unregister(central, dir)? else {
376 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
377 };
378 let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
379 let others: Vec<String> =
380 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
381 if !others.is_empty() {
382 done.push(format!(
383 "project `{}` keeps its card for its other repositories: {}",
384 repo.project,
385 others.join(", ")
386 ));
387 } else if projects::remove_description(central, &repo.project)? {
388 done.push(format!("removed project `{}`'s card from the central brain", repo.project));
389 }
390 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
391 done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
392 return Ok(done);
393 };
394 match claude::remove_hooks(root) {
395 Ok(claude::Change::Unchanged) => {}
396 Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
397 Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
398 Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
399 }
400 for name in AGENT_FILES {
401 let path = root.join(name);
402 let Ok(before) = fs::read_to_string(&path) else { continue };
403 let after = without_section(&before);
404 if after == before {
405 continue;
406 }
407 if after.trim().is_empty() {
408 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
409 done.push(format!("removed {name}: it only held the \"Project memory\" section"));
410 } else {
411 store::write_file(&path, &after)?;
412 done.push(format!("removed the \"Project memory\" section from {name}"));
413 }
414 }
415 Ok(done)
416}
417
418fn section(text: &str) -> Option<(usize, usize)> {
422 let end = text.find(END)? + END.len();
423 let start = text[..end].rfind(START)?;
424 Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
425}
426
427fn with_section(text: &str) -> String {
430 let block = format!("{START}\n{SECTION}\n{END}\n");
431 if let Some((start, end)) = section(text) {
432 return format!("{}{block}{}", &text[..start], &text[end..]);
433 }
434 match text.trim_end() {
435 "" => block,
436 kept => format!("{kept}\n\n{block}"),
437 }
438}
439
440fn without_section(text: &str) -> String {
442 let Some((start, end)) = section(text) else { return text.to_string() };
443 let before = text[..start].trim_end();
444 let after = text[end..].trim_start_matches('\n');
445 match (before, after) {
446 ("", after) => after.to_string(),
447 (before, "") => format!("{before}\n"),
448 (before, after) => format!("{before}\n\n{after}"),
449 }
450}
451
452#[cfg(test)]
453#[path = "../../tests/cli/init.rs"]
454mod tests;