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, and reminds an agent with every prompt and at the end of its turn to \
331 describe the project while it isn't",
332 claude::SETTINGS
333 ),
334 ),
335 Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
337 });
338 }
339 for &name in targets {
340 let path = root.join(name);
341 let before = match fs::read_to_string(&path) {
343 Ok(text) => text,
344 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
345 Err(e) => {
346 done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
347 continue;
348 }
349 };
350 let after = with_section(&before);
351 if after == before {
352 done.push((false, format!("{name} already tells agents about kanbrain")));
353 continue;
354 }
355 store::write_file(&path, &after)?;
356 let what = if before.is_empty() {
357 "created"
358 } else if before.contains(START) {
359 "updated"
360 } else {
361 "added to"
362 };
363 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
364 }
365 Ok(done)
366}
367
368fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
370 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
371 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
372}
373
374pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
379 let Some(repo) = projects::unregister(central, dir)? else {
380 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
381 };
382 let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
383 let others: Vec<String> =
384 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
385 if !others.is_empty() {
386 done.push(format!(
387 "project `{}` keeps its card for its other repositories: {}",
388 repo.project,
389 others.join(", ")
390 ));
391 } else if projects::remove_description(central, &repo.project)? {
392 done.push(format!("removed project `{}`'s card from the central brain", repo.project));
393 }
394 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
395 done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
396 return Ok(done);
397 };
398 match claude::remove_hooks(root) {
399 Ok(claude::Change::Unchanged) => {}
400 Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
401 Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
402 Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
403 }
404 for name in AGENT_FILES {
405 let path = root.join(name);
406 let Ok(before) = fs::read_to_string(&path) else { continue };
407 let after = without_section(&before);
408 if after == before {
409 continue;
410 }
411 if after.trim().is_empty() {
412 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
413 done.push(format!("removed {name}: it only held the \"Project memory\" section"));
414 } else {
415 store::write_file(&path, &after)?;
416 done.push(format!("removed the \"Project memory\" section from {name}"));
417 }
418 }
419 Ok(done)
420}
421
422fn section(text: &str) -> Option<(usize, usize)> {
426 let end = text.find(END)? + END.len();
427 let start = text[..end].rfind(START)?;
428 Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
429}
430
431fn with_section(text: &str) -> String {
434 let block = format!("{START}\n{SECTION}\n{END}\n");
435 if let Some((start, end)) = section(text) {
436 return format!("{}{block}{}", &text[..start], &text[end..]);
437 }
438 match text.trim_end() {
439 "" => block,
440 kept => format!("{kept}\n\n{block}"),
441 }
442}
443
444fn without_section(text: &str) -> String {
446 let Some((start, end)) = section(text) else { return text.to_string() };
447 let before = text[..start].trim_end();
448 let after = text[end..].trim_start_matches('\n');
449 match (before, after) {
450 ("", after) => after.to_string(),
451 (before, "") => format!("{before}\n"),
452 (before, after) => format!("{before}\n\n{after}"),
453 }
454}
455
456#[cfg(test)]
457#[path = "../../tests/cli/init.rs"]
458mod tests;