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>> {
211 let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
212 names.sort_unstable();
213 names.dedup();
214 let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
215 let mut done = Vec::new();
216 for old in unreachable {
217 let kept: String = old.chars().map(|c| if matches!(c, ':' | '/' | '\n') { '-' } else { c }).collect();
218 let base = match kept.trim().trim_start_matches('-').trim() {
219 "" => "project",
220 base => base,
221 };
222 let new = (0..=names.len())
225 .map(|n| match n {
226 0 => base.to_string(),
227 1 => format!("{base}-project"),
228 n => format!("{base}-project-{n}"),
229 })
230 .find(|name| projects::check_name(name).is_ok() && !names.contains(name))
231 .expect("more names than projects");
232 match super::manage::rename_project(central, &old, &new) {
234 Ok(lines) => done.extend(lines),
235 Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
236 }
237 names.push(new);
238 }
239 Ok(done)
240}
241
242pub fn init_all(central: &Path) -> Result<Vec<String>> {
248 let mut done = migrate_all(central, &projects::load(central)?)?;
250 done.extend(rename_unreachable_projects(central)?);
251 let repos = projects::load(central)?;
252 let mut current = 0;
253 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
254 let name = repo.name();
256 if projects::check_repo_name(&name).is_err() {
257 let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
258 done.extend(super::manage::rename_repo(central, &repo.dir, &fixed)?);
259 }
260 if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
262 let old = card.project.clone();
263 projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
264 done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
265 }
266 let Some(root) = repo.dir.parent() else { continue };
267 let targets: Vec<&str> = AGENT_FILES
268 .into_iter()
269 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
270 .collect();
271 if targets.is_empty() {
272 continue;
273 }
274 let changes: Vec<String> = write_agent_files(root, &targets)?
275 .into_iter()
276 .filter(|(changed, _)| *changed)
277 .map(|(_, line)| line)
278 .collect();
279 if changes.is_empty() {
280 current += 1;
281 }
282 let label = repo.label(&repos);
283 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
284 }
285 match (current, done.is_empty()) {
287 (0, _) => {}
288 (_, true) => done.push(format!(
289 "{} already had this version's instructions",
290 counted(current, "repository", "repositories")
291 )),
292 (_, false) => done.push(format!("{current} more already had this version's instructions")),
293 }
294 Ok(done)
295}
296
297fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
302 let mut done = crate::migrate::migrate(&Store::at(central)?)?;
303 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
304 match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
305 Ok(lines) => done.extend(lines),
306 Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
307 done.push(format!("{}: left alone: {e}", repo.label(repos)));
308 }
309 Err(e) => done.push(format!(
310 "{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
311 repo.label(repos)
312 )),
313 }
314 }
315 Ok(done)
316}
317
318fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
321 let mut done = Vec::new();
322 if targets.contains(&"CLAUDE.md") {
324 done.push(match claude::add_hooks(root) {
325 Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
326 Ok(_) => (
327 true,
328 format!(
329 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
330 after compaction, reminds an agent with every prompt and at the end of its turn to \
331 describe the project while it isn't, and sends it to `kb` when it would write the brain's \
332 files itself",
333 claude::SETTINGS
334 ),
335 ),
336 Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
338 });
339 }
340 for &name in targets {
341 let path = root.join(name);
342 let before = match fs::read_to_string(&path) {
344 Ok(text) => text,
345 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
346 Err(e) => {
347 done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
348 continue;
349 }
350 };
351 let after = with_section(&before);
352 if after == before {
353 done.push((false, format!("{name} already tells agents about kanbrain")));
354 continue;
355 }
356 store::write_file(&path, &after)?;
357 let what = if before.is_empty() {
358 "created"
359 } else if before.contains(START) {
360 "updated"
361 } else {
362 "added to"
363 };
364 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
365 }
366 Ok(done)
367}
368
369fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
371 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
372 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
373}
374
375pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
380 let Some(repo) = projects::unregister(central, dir)? else {
381 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
382 };
383 let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
384 let others: Vec<String> =
385 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
386 if !others.is_empty() {
387 done.push(format!(
388 "project `{}` keeps its card for its other repositories: {}",
389 repo.project,
390 others.join(", ")
391 ));
392 } else if projects::remove_description(central, &repo.project)? {
393 done.push(format!("removed project `{}`'s card from the central brain", repo.project));
394 }
395 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
396 done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
397 return Ok(done);
398 };
399 match claude::remove_hooks(root) {
400 Ok(claude::Change::Unchanged) => {}
401 Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
402 Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
403 Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
404 }
405 for name in AGENT_FILES {
406 let path = root.join(name);
407 let Ok(before) = fs::read_to_string(&path) else { continue };
408 let after = without_section(&before);
409 if after == before {
410 continue;
411 }
412 if after.trim().is_empty() {
413 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
414 done.push(format!("removed {name}: it only held the \"Project memory\" section"));
415 } else {
416 store::write_file(&path, &after)?;
417 done.push(format!("removed the \"Project memory\" section from {name}"));
418 }
419 }
420 Ok(done)
421}
422
423fn section(text: &str) -> Option<(usize, usize)> {
427 let end = text.find(END)? + END.len();
428 let start = text[..end].rfind(START)?;
429 Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
430}
431
432fn with_section(text: &str) -> String {
435 let block = format!("{START}\n{SECTION}\n{END}\n");
436 if let Some((start, end)) = section(text) {
437 return format!("{}{block}{}", &text[..start], &text[end..]);
438 }
439 match text.trim_end() {
440 "" => block,
441 kept => format!("{kept}\n\n{block}"),
442 }
443}
444
445fn without_section(text: &str) -> String {
447 let Some((start, end)) = section(text) else { return text.to_string() };
448 let before = text[..start].trim_end();
449 let after = text[end..].trim_start_matches('\n');
450 match (before, after) {
451 ("", after) => after.to_string(),
452 (before, "") => format!("{before}\n"),
453 (before, after) => format!("{before}\n\n{after}"),
454 }
455}
456
457#[cfg(test)]
458#[path = "../../tests/cli/init.rs"]
459mod tests;