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