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). A pull request, issue, CI run, commit or page named in a
47 comment or body goes with its link, `[PR #39](URL)`, not just `PR #39`: the user opens it from the board.
48- Splitting work between subagents: give each part that changes the repository its own task, and run the board
49 yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
50 only do the work; lookups and research get no task. `kb` commands can run in parallel.
51- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
52 question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
53 storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
54 the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
55 command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
56 prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
57 stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
58 the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
59 you to.
60- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
61 then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
62 findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
63 repository that has to change.
64- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
65 or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
66 services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
67 columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
68 picture changes (a service added, another storage), not after every change. With several repositories, this
69 one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
70 file, outside this section: add them as you learn them.
71- Also: `show ID` (one entry in full), `describe ID TEXT`, `rename ID TITLE`, `tag ID +TAG -TAG` (add or remove
72 tags), `link ID OTHER…` (an id here, or `REPO#ID` in another repository of the project); `-` reads text from
73 stdin. Never edit the files in `.brain/` by hand: `kb` keeps them consistent.";
74
75pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
84 if let Some(to) = name
85 && let Ok(dir) = root.join(PROJECT_DIR).canonicalize()
86 && projects::load(central)?.iter().any(|r| projects::same_dir(&r.dir, &dir) && r.project != to)
87 {
88 let mut done = super::manage::move_repo(central, &dir, to)?;
89 if let Some(repo) = repo {
91 match super::manage::rename_repo(central, &dir, repo) {
92 Ok(lines) => done.extend(lines),
93 Err(e) => done.push(format!("kept the name it has: {e:#}")),
94 }
95 }
96 return Ok(done);
97 }
98 join(root, name, repo, central)
99}
100
101pub(crate) fn join(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
104 let mut done = Vec::new();
105 let dir = root.join(PROJECT_DIR);
106 if root.to_string_lossy().contains(['\n', '\r']) {
108 bail!("{} has a line break in its path: kanbrain can't register it", root.display());
109 }
110 if projects::same_dir(&dir, central) {
113 bail!("{} is the central brain, not a project's: run `kb init` in a project's folder", dir.display());
114 }
115 if dir.is_dir() {
116 done.push(format!("{} already exists", dir.display()));
117 } else {
118 Store::at(&dir)?;
119 done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
120 done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
121 }
122 let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
124 let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
125 let card = projects::read_repo_card(&dir);
126 let registered = projects::load(central)?.into_iter().find(|r| projects::same_dir(&r.dir, &dir)).map(|r| r.project);
129 if name.is_none()
131 && let Some(old) = registered.as_deref()
132 && projects::check_name(old).is_err()
133 {
134 bail!(
135 "this repository is registered in project `{old}`, a name kanbrain no longer takes: `kb init --all` \
136 renames such projects, keeping their repositories and card"
137 );
138 }
139 let project = match (name, registered, &card, folder) {
140 (Some(name), ..) => name.to_string(),
141 (None, Some(registered), ..) => registered,
142 (None, None, Some(card), _) => card.project.clone(),
143 (None, None, None, Some(folder)) => folder,
144 (None, None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
145 };
146 projects::check_name(&project)?;
147 let named = projects::repo_name(central, &project, &dir, repo)?;
148 let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
151 let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
152 let new_card =
153 projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
154 if card.as_ref() != Some(&new_card) {
155 projects::write_repo_card(&dir, &new_card)?;
156 }
157 projects::register(central, &project, &dir)?;
158 if projects::ensure_description(central, &project)? {
159 done.push(format!(
160 "created project `{project}`'s card in the central brain: describe the project with `kb project \
161 describe` or from the kanban app (or let the agent do it); all its repositories share it"
162 ));
163 }
164 let repos = projects::load(central)?;
165 let here =
166 repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
167 let others: Vec<String> = repos
168 .iter()
169 .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
170 .map(projects::Repo::name)
171 .collect();
172 if others.is_empty() {
173 done.push(format!(
174 "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
175 ));
176 } else {
177 done.push(format!(
178 "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
179 others.join(", ")
180 ));
181 }
182 for gone in repos.iter().filter(|r| r.project == project && !r.dir.is_dir()) {
185 let name = gone.name();
186 done.push(format!(
189 "`{project}/{name}`'s folder is gone ({}): if this is it, moved here, take the old place out with \
190 `kb --project {project}/{name} deinit`, then `kb project repo {name}` here",
191 gone.dir.display()
192 ));
193 }
194 let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
195 let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
196 done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
197 if !on_path(std::env::var_os("PATH"), "kb") {
198 done.push(
199 "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
200 (the installer's `--app both`)"
201 .into(),
202 );
203 }
204 Ok(done)
205}
206
207fn rename_unreachable_projects(central: &Path) -> Result<Vec<String>> {
213 let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
214 names.sort_unstable();
215 names.dedup();
216 let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
217 let mut done = Vec::new();
218 for old in unreachable {
219 let kept: String = old.chars().map(|c| if matches!(c, ':' | '/' | '\n' | '\r') { '-' } else { c }).collect();
220 let base = match kept.trim().trim_start_matches('-').trim() {
221 "" => "project",
222 base => base,
223 };
224 let new = (0..=names.len())
227 .map(|n| match n {
228 0 => base.to_string(),
229 1 => format!("{base}-project"),
230 n => format!("{base}-project-{n}"),
231 })
232 .find(|name| projects::check_name(name).is_ok() && !names.contains(name))
233 .expect("more names than projects");
234 match super::manage::rename_project(central, &old, &new) {
236 Ok(lines) => done.extend(lines),
237 Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
238 }
239 names.push(new);
240 }
241 Ok(done)
242}
243
244pub fn init_all(central: &Path) -> Result<Vec<String>> {
250 let mut done = migrate_all(central, &projects::load(central)?)?;
252 done.extend(rename_unreachable_projects(central)?);
253 let repos = projects::load(central)?;
254 let mut current = 0;
255 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
256 let name = repo.name();
258 if projects::check_repo_name(&name).is_err() {
259 let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
260 done.extend(super::manage::rename_repo(central, &repo.dir, &fixed)?);
261 }
262 if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
264 let old = card.project.clone();
265 projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
266 done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
267 }
268 let Some(root) = repo.dir.parent() else { continue };
269 let targets: Vec<&str> = AGENT_FILES
270 .into_iter()
271 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
272 .collect();
273 if targets.is_empty() {
274 continue;
275 }
276 let changes: Vec<String> = write_agent_files(root, &targets)?
277 .into_iter()
278 .filter(|(changed, _)| *changed)
279 .map(|(_, line)| line)
280 .collect();
281 if changes.is_empty() {
282 current += 1;
283 }
284 let label = repo.label(&repos);
285 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
286 }
287 match (current, done.is_empty()) {
289 (0, _) => {}
290 (_, true) => done.push(format!(
291 "{} already had this version's instructions",
292 counted(current, "repository", "repositories")
293 )),
294 (_, false) => done.push(format!("{current} more already had this version's instructions")),
295 }
296 Ok(done)
297}
298
299fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
304 let mut done = crate::migrate::migrate(&Store::at(central)?)?;
305 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
306 match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
307 Ok(lines) => done.extend(lines),
308 Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
309 done.push(format!("{}: left alone: {e}", repo.label(repos)));
310 }
311 Err(e) => done.push(format!(
312 "{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
313 repo.label(repos)
314 )),
315 }
316 }
317 Ok(done)
318}
319
320fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
323 let mut done = Vec::new();
324 if targets.contains(&"CLAUDE.md") {
326 done.push(match claude::add_hooks(root) {
327 Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
328 Ok(_) => (
329 true,
330 format!(
331 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
332 after compaction, reminds an agent with every prompt and at the end of its turn to \
333 describe the project while it isn't, sends it to `kb` when it would write the brain's \
334 files itself, and tells it of your comments on a task it's working on with its next tool",
335 claude::SETTINGS
336 ),
337 ),
338 Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
340 });
341 }
342 for &name in targets {
343 let path = root.join(name);
344 let before = match fs::read_to_string(&path) {
346 Ok(text) => text,
347 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
348 Err(e) => {
349 done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
350 continue;
351 }
352 };
353 let after = with_section(&before);
354 if after == before {
355 done.push((false, format!("{name} already tells agents about kanbrain")));
356 continue;
357 }
358 store::write_file(&path, &after)?;
359 let what = if before.is_empty() {
360 "created"
361 } else if before.contains(START) {
362 "updated"
363 } else {
364 "added to"
365 };
366 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
367 }
368 Ok(done)
369}
370
371fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
373 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
374 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
375}
376
377pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
382 let Some(repo) = projects::unregister(central, dir)? else {
383 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
384 };
385 let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
386 let others: Vec<String> =
387 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
388 if !others.is_empty() {
389 done.push(format!(
390 "project `{}` keeps its card for its other repositories: {}",
391 repo.project,
392 others.join(", ")
393 ));
394 } else if projects::remove_description(central, &repo.project)? {
395 done.push(format!("removed project `{}`'s card from the central brain", repo.project));
396 }
397 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
398 done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
399 return Ok(done);
400 };
401 match claude::remove_hooks(root) {
402 Ok(claude::Change::Unchanged) => {}
403 Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
404 Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
405 Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
406 }
407 for name in AGENT_FILES {
408 let path = root.join(name);
409 let Ok(before) = fs::read_to_string(&path) else { continue };
410 let after = without_section(&before);
411 if after == before {
412 continue;
413 }
414 if after.trim().is_empty() {
415 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
416 done.push(format!("removed {name}: it only held the \"Project memory\" section"));
417 } else {
418 store::write_file(&path, &after)?;
419 done.push(format!("removed the \"Project memory\" section from {name}"));
420 }
421 }
422 Ok(done)
423}
424
425fn section(text: &str) -> Option<(usize, usize)> {
429 let end = text.find(END)? + END.len();
430 let start = text[..end].rfind(START)?;
431 Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
432}
433
434fn with_section(text: &str) -> String {
437 let block = format!("{START}\n{SECTION}\n{END}\n");
438 if let Some((start, end)) = section(text) {
439 return format!("{}{block}{}", &text[..start], &text[end..]);
440 }
441 match text.trim_end() {
442 "" => block,
443 kept => format!("{kept}\n\n{block}"),
444 }
445}
446
447fn without_section(text: &str) -> String {
449 let Some((start, end)) = section(text) else { return text.to_string() };
450 let before = text[..start].trim_end();
451 let after = text[end..].trim_start_matches('\n');
452 match (before, after) {
453 ("", after) => after.to_string(),
454 (before, "") => format!("{before}\n"),
455 (before, after) => format!("{before}\n\n{after}"),
456 }
457}
458
459#[cfg(test)]
460#[path = "../../tests/cli/init.rs"]
461mod tests;