1use super::claude;
11use super::manage::{Step, StepKind};
12use crate::model::counted;
13use crate::projects;
14use crate::store::{self, PROJECT_DIR, Store};
15use anyhow::{Context, Result, bail};
16use std::fs;
17use std::path::Path;
18
19const AGENT_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
21const DEFAULT_AGENT_FILE: &str = "CLAUDE.md";
23const START: &str = "<!-- kanbrain:start -->";
26const END: &str = "<!-- kanbrain:end -->";
28const BOM: &str = "\u{FEFF}";
30
31const SECTION: &str = "\
35## Project memory: kanbrain
36
37This project's tasks, to-dos, decisions and notes live in `.brain/`, managed only with `kb`, kanbrain's CLI.
38The user follows them live on a kanban board: keep it showing what you're doing as you go, not at the end.
39
40- Start of session: `kb context`, unless its output is already above (a session hook adds it).
41- Work that changes the repository, or that the user should see, gets a task; questions and lookups don't. Find it
42 (`search WORDS`) or create it (`add TITLE`), then `start ID` (`add --start TITLE` does both). \"Todos\", a plan or
43 what's pending, in any language, are tasks here, one `add` each; your built-in to-do list isn't on the board, so
44 keep it for steps within the task you've started. A task with real work gets a body, `add --body - TITLE`:
45 **Goal** (what and why, a line or two) and **Done when** (what the user will check).
46- While working: `comment ID TEXT` when a step is done, when you find a cause, and before you stop (one or two
47 lines: what changed, what's next); `done ID` as soon as one is finished. Stopping before it is: `comment`, then
48 `park ID`. Doing isn't limited for you: several of your tasks can run at once. Never park, finish or move a task
49 you didn't start (the user's, or another session's). A pull request, issue, CI run, commit or page named in a
50 comment or body goes with its link, `[PR #39](URL)`, not just `PR #39`: the user opens it from the board.
51- Before ending a turn in which you changed the repository: that change is on a task you started (create it now if
52 you skipped it), the turn's work is commented there, and a finished task is `done`. Work that goes through a pull
53 request is finished once every check has passed and it's merged; until then its task stays in Doing. Nothing is
54 `done` without its evidence commented first: the tests or checks run and their result, or what shows it works (a
55 CI run's link, the output, a screenshot). What you couldn't check isn't done: `ask ID TEXT` the user to check it,
56 saying what's left and how.
57- Splitting work between subagents: give each part that changes the repository its own task, and run the board
58 yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
59 only do the work; lookups and research get no task. `kb` commands can run in parallel.
60- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
61 question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
62 storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
63 the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
64 command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
65 prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
66 stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
67 the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
68 you to.
69- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
70 then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
71 findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
72 repository that has to change.
73- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
74 or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
75 services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
76 columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
77 picture changes (a service added, another storage), not after every change. With several repositories, this
78 one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
79 file, outside this section: add them as you learn them.
80- Also: `show ID` (one entry in full), `describe ID TEXT`, `rename ID TITLE`, `tag ID +TAG -TAG` (add or remove
81 tags), `link ID OTHER…` (an id here, or `REPO#ID` in another repository of the project); `-` reads text from
82 stdin. Never edit the files in `.brain/` by hand: `kb` keeps them consistent.";
83
84#[deprecated(since = "0.6.3", note = "use `init_steps`, which says what each line is")]
86pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
87 super::manage::lines(init_steps(root, name, repo, central))
88}
89
90pub fn init_steps(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<Step>> {
99 crate::migrate::refuse_too_new(&root.join(PROJECT_DIR))?;
101 if let Some(to) = name
102 && let Ok(dir) = projects::canonical(&root.join(PROJECT_DIR))
103 && projects::load(central)?.iter().any(|r| projects::same_dir(&r.dir, &dir) && r.project != to)
104 {
105 let mut done = super::manage::move_repo_steps(central, &dir, to)?;
106 if let Some(repo) = repo {
108 match super::manage::rename_repo_steps(central, &dir, repo) {
109 Ok(steps) => done.extend(steps),
110 Err(e) => done.push(Step::other(format!("kept the name it has: {e:#}"))),
111 }
112 }
113 return Ok(done);
114 }
115 join(root, name, repo, central)
116}
117
118pub(crate) fn join(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<Step>> {
121 let mut done: Vec<Step> = Vec::new();
122 let dir = root.join(PROJECT_DIR);
123 if root.to_string_lossy().contains(['\n', '\r']) {
125 bail!("{} has a line break in its path: kanbrain can't register it", root.display());
126 }
127 if fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()).to_str().is_none() {
130 bail!(
131 "{} has a folder name that isn't valid Unicode, which projects.md can't hold: kanbrain can't register it; \
132 rename that folder",
133 root.display()
134 );
135 }
136 if projects::same_dir(&dir, central) {
139 bail!("{} is the central brain, not a project's: run `kb init` in a project's folder", dir.display());
140 }
141 if dir.is_dir() {
142 done.push(Step::new(StepKind::AlreadyExisted, format!("{} already exists", dir.display())));
143 } else {
144 Store::at(&dir)?;
145 done.push(Step::other(format!("created {}: kanbrain uses it from here and every folder below", dir.display())));
146 done.push(Step::other(
147 "commit it to share tasks, notes and decisions with the project, or add it to .gitignore",
148 ));
149 }
150 let dir = projects::canonical(&dir).with_context(|| format!("reading {}", dir.display()))?;
152 let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
153 let card = projects::read_repo_card(&dir);
154 let registered = projects::load(central)?.into_iter().find(|r| projects::same_dir(&r.dir, &dir)).map(|r| r.project);
157 if name.is_none()
159 && let Some(old) = registered.as_deref()
160 && projects::check_name(old).is_err()
161 {
162 bail!(
163 "this repository is registered in project `{old}`, a name kanbrain no longer takes: `kb init --all` \
164 renames such projects, keeping their repositories and card"
165 );
166 }
167 let project = match (name, registered, &card, folder) {
168 (Some(name), ..) => name.to_string(),
169 (None, Some(registered), ..) => registered,
170 (None, None, Some(card), _) => card.project.clone(),
171 (None, None, None, Some(folder)) => folder,
172 (None, None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
173 };
174 projects::check_name(&project)?;
175 let named = projects::repo_name(central, &project, &dir, repo)?;
176 let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
179 let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
180 let new_card =
181 projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
182 if card.as_ref() != Some(&new_card) {
183 projects::write_repo_card(&dir, &new_card)?;
184 }
185 projects::register(central, &project, &dir)?;
186 if projects::ensure_description(central, &project)? {
187 done.push(Step::other(format!(
188 "created project `{project}`'s card in the central brain: describe the project with `kb project \
189 describe` or from the kanban app (or let the agent do it); all its repositories share it"
190 )));
191 }
192 let repos = projects::load(central)?;
193 let here =
194 repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
195 let others: Vec<String> = repos
196 .iter()
197 .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
198 .map(projects::Repo::name)
199 .collect();
200 if others.is_empty() {
201 done.push(Step::other(format!(
202 "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
203 )));
204 } else {
205 done.push(Step::other(format!(
206 "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
207 others.join(", ")
208 )));
209 }
210 for gone in repos.iter().filter(|r| r.project == project && !r.dir.is_dir()) {
213 let name = gone.name();
214 done.push(Step::other(format!(
217 "`{project}/{name}`'s folder is gone ({}): if this is it, moved here, take the old place out with \
218 `kb --project {project}/{name} deinit`, then `kb project repo {name}` here",
219 gone.dir.display()
220 )));
221 }
222 let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
223 let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
224 done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| Step::other(line)));
225 if !on_path(std::env::var_os("PATH"), "kb") {
226 done.push(Step::other(
227 "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
228 (the installer's `--app both`)",
229 ));
230 }
231 Ok(done)
232}
233
234fn rename_unreachable_projects(central: &Path) -> Result<Vec<String>> {
240 let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
241 names.sort_unstable();
242 names.dedup();
243 let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
244 let mut done = Vec::new();
245 for old in unreachable {
246 let kept: String = old.chars().map(|c| if matches!(c, ':' | '/' | '\n' | '\r') { '-' } else { c }).collect();
247 let base = match kept.trim().trim_start_matches('-').trim() {
248 "" => "project",
249 base => base,
250 };
251 let new = (0..=names.len())
254 .map(|n| match n {
255 0 => base.to_string(),
256 1 => format!("{base}-project"),
257 n => format!("{base}-project-{n}"),
258 })
259 .find(|name| projects::check_name(name).is_ok() && !names.contains(name))
260 .expect("more names than projects");
261 match super::manage::rename_project_steps(central, &old, &new) {
263 Ok(steps) => done.extend(steps.into_iter().map(|step| step.line)),
264 Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
265 }
266 names.push(new);
267 }
268 Ok(done)
269}
270
271pub fn init_all(central: &Path) -> Result<Vec<String>> {
277 let mut done = migrate_all(central, &projects::load(central)?)?;
279 done.extend(rename_unreachable_projects(central)?);
280 let repos = projects::load(central)?;
281 let mut current = 0;
282 for repo in repos.iter().filter(|r| r.dir.is_dir() && !crate::migrate::too_new(&r.dir)) {
284 let name = repo.name();
286 if projects::check_repo_name(&name).is_err() {
287 let left =
289 repos.iter().find(|r| r.project == repo.project && r.dir.is_dir() && crate::migrate::too_new(&r.dir));
290 if let Some(left) = left {
291 done.push(format!(
292 "{}: keeps the name `{name}`, which links can't hold, until `kb update`: `{}`'s links to it can't \
293 be rewritten",
294 repo.label(&repos),
295 left.name()
296 ));
297 } else {
298 let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
299 done.extend(
300 super::manage::rename_repo_steps(central, &repo.dir, &fixed)?.into_iter().map(|step| step.line),
301 );
302 }
303 }
304 if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
306 let old = card.project.clone();
307 projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
308 done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
309 }
310 let Some(root) = repo.dir.parent() else { continue };
311 let targets: Vec<&str> = AGENT_FILES
312 .into_iter()
313 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
314 .collect();
315 if targets.is_empty() {
316 continue;
317 }
318 let changes: Vec<String> = write_agent_files(root, &targets)?
319 .into_iter()
320 .filter(|(changed, _)| *changed)
321 .map(|(_, line)| line)
322 .collect();
323 if changes.is_empty() {
324 current += 1;
325 }
326 let label = repo.label(&repos);
327 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
328 }
329 match (current, done.is_empty()) {
331 (0, _) => {}
332 (_, true) => done.push(format!(
333 "{} already had this version's instructions",
334 counted(current, "repository", "repositories")
335 )),
336 (_, false) => done.push(format!("{current} more already had this version's instructions")),
337 }
338 Ok(done)
339}
340
341fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
346 let mut done = crate::migrate::migrate(&Store::at(central)?)?;
347 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
348 match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
349 Ok(lines) => done.extend(lines),
350 Err(e) if e.is::<crate::migrate::TooNew>() => {
351 done.push(format!("{}: left alone: {e}", repo.label(repos)));
352 }
353 Err(e) => done.push(format!(
354 "{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
355 repo.label(repos)
356 )),
357 }
358 }
359 Ok(done)
360}
361
362fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
365 let mut done = Vec::new();
366 if targets.contains(&"CLAUDE.md") {
368 match claude::add_hooks(root) {
369 Ok((change, edited)) => {
370 if change == claude::Change::Written {
371 done.push((
372 true,
373 format!(
374 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
375 after compaction, reminds an agent with every prompt and at the end of its turn to \
376 describe the project while it isn't, sends it to `kb` when it would write the brain's \
377 files itself, and tells it of your comments on a task it's working on with its next tool",
378 claude::SETTINGS
379 ),
380 ));
381 } else if edited.is_empty() {
382 done.push((false, format!("{} already has kanbrain's hooks", claude::SETTINGS)));
383 }
384 done.extend(edited.iter().map(|hook| {
385 let todo = format!(
386 "kanbrain added none beside it; if it's kanbrain's, replace it with `{0}` so kanbrain can \
387 update and remove it, and if it isn't, add one running `{0}` for {1} beside it",
388 hook.hook, hook.event
389 );
390 (false, looks_like_kanbrains(hook, &todo))
391 }));
392 }
393 Err(e) => done.push((false, format!("left out the Claude Code hooks: {e:#}"))),
395 }
396 }
397 for &name in targets {
398 let path = root.join(name);
399 let before = match fs::read_to_string(&path) {
401 Ok(text) => text,
402 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
403 Err(e) => {
404 done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
405 continue;
406 }
407 };
408 let after = with_section(&before);
409 if after == before {
410 done.push((false, format!("{name} already tells agents about kanbrain")));
411 continue;
412 }
413 store::write_file(&path, &after)?;
414 let what = if before.is_empty() {
415 "created"
416 } else if before.contains(START) {
417 "updated"
418 } else {
419 "added to"
420 };
421 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
422 }
423 Ok(done)
424}
425
426fn looks_like_kanbrains(hook: &claude::Edited, todo: &str) -> String {
429 let shown: String = hook
431 .command
432 .chars()
433 .map(|c| if c.is_control() { c.escape_default().to_string() } else { c.to_string() })
434 .collect();
435 format!(
436 "{}: a hook for {} runs `{shown}`, which looks like kanbrain's `{}`: {todo}",
437 claude::SETTINGS,
438 hook.event,
439 hook.hook
440 )
441}
442
443fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
445 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
446 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
447}
448
449pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<Step>> {
454 let Some(repo) = projects::unregister(central, dir)? else {
455 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
456 };
457 let mut done: Vec<Step> =
458 vec![Step::other(format!("unregistered {}: the kanban app no longer shows it", repo.dir.display()))];
459 let others: Vec<String> =
460 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
461 if !others.is_empty() {
462 let line =
463 format!("project `{}` keeps its card for its other repositories: {}", repo.project, others.join(", "));
464 done.push(Step::new(StepKind::KeptCard, line));
465 } else if projects::remove_description(central, &repo.project)? {
466 done.push(Step::other(format!("removed project `{}`'s card from the central brain", repo.project)));
467 }
468 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
469 done.push(Step::other(format!("{} is gone, so there was nothing else to undo", repo.dir.display())));
470 return Ok(done);
471 };
472 match claude::remove_hooks(root) {
473 Ok((change, edited)) => {
474 match change {
475 claude::Change::Unchanged => {}
476 claude::Change::Written => {
477 done.push(Step::other(format!("removed kanbrain's hooks from {}", claude::SETTINGS)));
478 }
479 claude::Change::Removed => {
480 done.push(Step::other(format!("removed {}: it only held the hooks", claude::SETTINGS)));
481 }
482 }
483 let todo = "kanbrain left it in place; if it's kanbrain's, remove it yourself";
484 done.extend(edited.iter().map(|hook| Step::other(looks_like_kanbrains(hook, todo))));
485 }
486 Err(e) => done.push(Step::other(format!("left the Claude Code hooks in place: {e:#}"))),
487 }
488 for name in AGENT_FILES {
489 let path = root.join(name);
490 let Ok(before) = fs::read_to_string(&path) else { continue };
491 let after = without_section(&before);
492 if after == before {
493 continue;
494 }
495 if after.trim().is_empty() {
496 store::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
497 done.push(Step::other(format!("removed {name}: it only held the \"Project memory\" section")));
498 } else {
499 store::write_file(&path, &after)?;
500 done.push(Step::other(format!("removed the \"Project memory\" section from {name}")));
501 }
502 }
503 Ok(done)
504}
505
506fn section(text: &str) -> Option<(usize, usize)> {
510 let end = text.find(END)? + END.len();
511 let start = text[..end].rfind(START)?;
512 let eol = ["\n\r\n", "\r\n", "\n"].into_iter().find(|eol| text[end..].starts_with(eol)).unwrap_or("");
515 Some((start, end + eol.len()))
516}
517
518fn line_break(text: &str) -> &'static str {
525 let outside = match section(text) {
526 Some((start, end)) => [&text[..start], &text[end..]].into_iter().find(|part| part.contains('\n')),
527 None => Some(text),
528 };
529 match outside.and_then(|part| part.find('\n').map(|at| &part[..at])) {
530 Some(line) if line.ends_with('\r') => "\r\n",
531 None if text.ends_with("\r\n") => "\r\n",
532 Some(_) | None => "\n",
533 }
534}
535
536fn with_section(text: &str) -> String {
539 let eol = line_break(text);
540 let block = format!("{START}\n{SECTION}\n{END}\n").replace('\n', eol);
541 if let Some((start, end)) = section(text) {
542 return format!("{}{block}{}", &text[..start], &text[end..]);
543 }
544 match text.trim_end() {
546 kept @ ("" | BOM) => format!("{kept}{block}"),
547 kept => format!("{kept}{eol}{eol}{block}"),
548 }
549}
550
551fn without_section(text: &str) -> String {
554 let Some((start, end)) = section(text) else { return text.to_string() };
555 let eol = line_break(text);
556 let before = text[..start].trim_end();
557 let after = text[end..].trim_start_matches(['\r', '\n']);
558 match (before, after) {
559 ("" | BOM, "") => String::new(),
560 ("" | BOM, after) => format!("{before}{after}"),
561 (before, "") => format!("{before}{eol}"),
562 (before, after) => format!("{before}{eol}{eol}{after}"),
563 }
564}
565
566#[cfg(test)]
567#[path = "../../tests/cli/init.rs"]
568mod tests;