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- Splitting work between subagents: give each part that changes the repository its own task, and run the board
52 yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
53 only do the work; lookups and research get no task. `kb` commands can run in parallel.
54- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
55 question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
56 storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
57 the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
58 command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
59 prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
60 stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
61 the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
62 you to.
63- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
64 then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
65 findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
66 repository that has to change.
67- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
68 or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
69 services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
70 columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
71 picture changes (a service added, another storage), not after every change. With several repositories, this
72 one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
73 file, outside this section: add them as you learn them.
74- Also: `show ID` (one entry in full), `describe ID TEXT`, `rename ID TITLE`, `tag ID +TAG -TAG` (add or remove
75 tags), `link ID OTHER…` (an id here, or `REPO#ID` in another repository of the project); `-` reads text from
76 stdin. Never edit the files in `.brain/` by hand: `kb` keeps them consistent.";
77
78#[deprecated(since = "0.6.3", note = "use `init_steps`, which says what each line is")]
80pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
81 super::manage::lines(init_steps(root, name, repo, central))
82}
83
84pub fn init_steps(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<Step>> {
93 crate::migrate::refuse_too_new(&root.join(PROJECT_DIR))?;
95 if let Some(to) = name
96 && let Ok(dir) = projects::canonical(&root.join(PROJECT_DIR))
97 && projects::load(central)?.iter().any(|r| projects::same_dir(&r.dir, &dir) && r.project != to)
98 {
99 let mut done = super::manage::move_repo_steps(central, &dir, to)?;
100 if let Some(repo) = repo {
102 match super::manage::rename_repo_steps(central, &dir, repo) {
103 Ok(steps) => done.extend(steps),
104 Err(e) => done.push(Step::other(format!("kept the name it has: {e:#}"))),
105 }
106 }
107 return Ok(done);
108 }
109 join(root, name, repo, central)
110}
111
112pub(crate) fn join(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<Step>> {
115 let mut done: Vec<Step> = Vec::new();
116 let dir = root.join(PROJECT_DIR);
117 if root.to_string_lossy().contains(['\n', '\r']) {
119 bail!("{} has a line break in its path: kanbrain can't register it", root.display());
120 }
121 if fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()).to_str().is_none() {
124 bail!(
125 "{} has a folder name that isn't valid Unicode, which projects.md can't hold: kanbrain can't register it; \
126 rename that folder",
127 root.display()
128 );
129 }
130 if projects::same_dir(&dir, central) {
133 bail!("{} is the central brain, not a project's: run `kb init` in a project's folder", dir.display());
134 }
135 if dir.is_dir() {
136 done.push(Step::new(StepKind::AlreadyExisted, format!("{} already exists", dir.display())));
137 } else {
138 Store::at(&dir)?;
139 done.push(Step::other(format!("created {}: kanbrain uses it from here and every folder below", dir.display())));
140 done.push(Step::other(
141 "commit it to share tasks, notes and decisions with the project, or add it to .gitignore",
142 ));
143 }
144 let dir = projects::canonical(&dir).with_context(|| format!("reading {}", dir.display()))?;
146 let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
147 let card = projects::read_repo_card(&dir);
148 let registered = projects::load(central)?.into_iter().find(|r| projects::same_dir(&r.dir, &dir)).map(|r| r.project);
151 if name.is_none()
153 && let Some(old) = registered.as_deref()
154 && projects::check_name(old).is_err()
155 {
156 bail!(
157 "this repository is registered in project `{old}`, a name kanbrain no longer takes: `kb init --all` \
158 renames such projects, keeping their repositories and card"
159 );
160 }
161 let project = match (name, registered, &card, folder) {
162 (Some(name), ..) => name.to_string(),
163 (None, Some(registered), ..) => registered,
164 (None, None, Some(card), _) => card.project.clone(),
165 (None, None, None, Some(folder)) => folder,
166 (None, None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
167 };
168 projects::check_name(&project)?;
169 let named = projects::repo_name(central, &project, &dir, repo)?;
170 let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
173 let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
174 let new_card =
175 projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
176 if card.as_ref() != Some(&new_card) {
177 projects::write_repo_card(&dir, &new_card)?;
178 }
179 projects::register(central, &project, &dir)?;
180 if projects::ensure_description(central, &project)? {
181 done.push(Step::other(format!(
182 "created project `{project}`'s card in the central brain: describe the project with `kb project \
183 describe` or from the kanban app (or let the agent do it); all its repositories share it"
184 )));
185 }
186 let repos = projects::load(central)?;
187 let here =
188 repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
189 let others: Vec<String> = repos
190 .iter()
191 .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
192 .map(projects::Repo::name)
193 .collect();
194 if others.is_empty() {
195 done.push(Step::other(format!(
196 "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
197 )));
198 } else {
199 done.push(Step::other(format!(
200 "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
201 others.join(", ")
202 )));
203 }
204 for gone in repos.iter().filter(|r| r.project == project && !r.dir.is_dir()) {
207 let name = gone.name();
208 done.push(Step::other(format!(
211 "`{project}/{name}`'s folder is gone ({}): if this is it, moved here, take the old place out with \
212 `kb --project {project}/{name} deinit`, then `kb project repo {name}` here",
213 gone.dir.display()
214 )));
215 }
216 let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
217 let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
218 done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| Step::other(line)));
219 if !on_path(std::env::var_os("PATH"), "kb") {
220 done.push(Step::other(
221 "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
222 (the installer's `--app both`)",
223 ));
224 }
225 Ok(done)
226}
227
228fn rename_unreachable_projects(central: &Path) -> Result<Vec<String>> {
234 let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
235 names.sort_unstable();
236 names.dedup();
237 let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
238 let mut done = Vec::new();
239 for old in unreachable {
240 let kept: String = old.chars().map(|c| if matches!(c, ':' | '/' | '\n' | '\r') { '-' } else { c }).collect();
241 let base = match kept.trim().trim_start_matches('-').trim() {
242 "" => "project",
243 base => base,
244 };
245 let new = (0..=names.len())
248 .map(|n| match n {
249 0 => base.to_string(),
250 1 => format!("{base}-project"),
251 n => format!("{base}-project-{n}"),
252 })
253 .find(|name| projects::check_name(name).is_ok() && !names.contains(name))
254 .expect("more names than projects");
255 match super::manage::rename_project_steps(central, &old, &new) {
257 Ok(steps) => done.extend(steps.into_iter().map(|step| step.line)),
258 Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
259 }
260 names.push(new);
261 }
262 Ok(done)
263}
264
265pub fn init_all(central: &Path) -> Result<Vec<String>> {
271 let mut done = migrate_all(central, &projects::load(central)?)?;
273 done.extend(rename_unreachable_projects(central)?);
274 let repos = projects::load(central)?;
275 let mut current = 0;
276 for repo in repos.iter().filter(|r| r.dir.is_dir() && !crate::migrate::too_new(&r.dir)) {
278 let name = repo.name();
280 if projects::check_repo_name(&name).is_err() {
281 let left =
283 repos.iter().find(|r| r.project == repo.project && r.dir.is_dir() && crate::migrate::too_new(&r.dir));
284 if let Some(left) = left {
285 done.push(format!(
286 "{}: keeps the name `{name}`, which links can't hold, until `kb update`: `{}`'s links to it can't \
287 be rewritten",
288 repo.label(&repos),
289 left.name()
290 ));
291 } else {
292 let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
293 done.extend(
294 super::manage::rename_repo_steps(central, &repo.dir, &fixed)?.into_iter().map(|step| step.line),
295 );
296 }
297 }
298 if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
300 let old = card.project.clone();
301 projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
302 done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
303 }
304 let Some(root) = repo.dir.parent() else { continue };
305 let targets: Vec<&str> = AGENT_FILES
306 .into_iter()
307 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
308 .collect();
309 if targets.is_empty() {
310 continue;
311 }
312 let changes: Vec<String> = write_agent_files(root, &targets)?
313 .into_iter()
314 .filter(|(changed, _)| *changed)
315 .map(|(_, line)| line)
316 .collect();
317 if changes.is_empty() {
318 current += 1;
319 }
320 let label = repo.label(&repos);
321 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
322 }
323 match (current, done.is_empty()) {
325 (0, _) => {}
326 (_, true) => done.push(format!(
327 "{} already had this version's instructions",
328 counted(current, "repository", "repositories")
329 )),
330 (_, false) => done.push(format!("{current} more already had this version's instructions")),
331 }
332 Ok(done)
333}
334
335fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
340 let mut done = crate::migrate::migrate(&Store::at(central)?)?;
341 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
342 match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
343 Ok(lines) => done.extend(lines),
344 Err(e) if e.is::<crate::migrate::TooNew>() => {
345 done.push(format!("{}: left alone: {e}", repo.label(repos)));
346 }
347 Err(e) => done.push(format!(
348 "{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
349 repo.label(repos)
350 )),
351 }
352 }
353 Ok(done)
354}
355
356fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
359 let mut done = Vec::new();
360 if targets.contains(&"CLAUDE.md") {
362 match claude::add_hooks(root) {
363 Ok((change, edited)) => {
364 if change == claude::Change::Written {
365 done.push((
366 true,
367 format!(
368 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
369 after compaction, reminds an agent with every prompt and at the end of its turn to \
370 describe the project while it isn't, sends it to `kb` when it would write the brain's \
371 files itself, and tells it of your comments on a task it's working on with its next tool",
372 claude::SETTINGS
373 ),
374 ));
375 } else if edited.is_empty() {
376 done.push((false, format!("{} already has kanbrain's hooks", claude::SETTINGS)));
377 }
378 done.extend(edited.iter().map(|hook| {
379 let todo = format!(
380 "kanbrain added none beside it; if it's kanbrain's, replace it with `{0}` so kanbrain can \
381 update and remove it, and if it isn't, add one running `{0}` for {1} beside it",
382 hook.hook, hook.event
383 );
384 (false, looks_like_kanbrains(hook, &todo))
385 }));
386 }
387 Err(e) => done.push((false, format!("left out the Claude Code hooks: {e:#}"))),
389 }
390 }
391 for &name in targets {
392 let path = root.join(name);
393 let before = match fs::read_to_string(&path) {
395 Ok(text) => text,
396 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
397 Err(e) => {
398 done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
399 continue;
400 }
401 };
402 let after = with_section(&before);
403 if after == before {
404 done.push((false, format!("{name} already tells agents about kanbrain")));
405 continue;
406 }
407 store::write_file(&path, &after)?;
408 let what = if before.is_empty() {
409 "created"
410 } else if before.contains(START) {
411 "updated"
412 } else {
413 "added to"
414 };
415 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
416 }
417 Ok(done)
418}
419
420fn looks_like_kanbrains(hook: &claude::Edited, todo: &str) -> String {
423 let shown: String = hook
425 .command
426 .chars()
427 .map(|c| if c.is_control() { c.escape_default().to_string() } else { c.to_string() })
428 .collect();
429 format!(
430 "{}: a hook for {} runs `{shown}`, which looks like kanbrain's `{}`: {todo}",
431 claude::SETTINGS,
432 hook.event,
433 hook.hook
434 )
435}
436
437fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
439 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
440 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
441}
442
443pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<Step>> {
448 let Some(repo) = projects::unregister(central, dir)? else {
449 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
450 };
451 let mut done: Vec<Step> =
452 vec![Step::other(format!("unregistered {}: the kanban app no longer shows it", repo.dir.display()))];
453 let others: Vec<String> =
454 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
455 if !others.is_empty() {
456 let line =
457 format!("project `{}` keeps its card for its other repositories: {}", repo.project, others.join(", "));
458 done.push(Step::new(StepKind::KeptCard, line));
459 } else if projects::remove_description(central, &repo.project)? {
460 done.push(Step::other(format!("removed project `{}`'s card from the central brain", repo.project)));
461 }
462 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
463 done.push(Step::other(format!("{} is gone, so there was nothing else to undo", repo.dir.display())));
464 return Ok(done);
465 };
466 match claude::remove_hooks(root) {
467 Ok((change, edited)) => {
468 match change {
469 claude::Change::Unchanged => {}
470 claude::Change::Written => {
471 done.push(Step::other(format!("removed kanbrain's hooks from {}", claude::SETTINGS)));
472 }
473 claude::Change::Removed => {
474 done.push(Step::other(format!("removed {}: it only held the hooks", claude::SETTINGS)));
475 }
476 }
477 let todo = "kanbrain left it in place; if it's kanbrain's, remove it yourself";
478 done.extend(edited.iter().map(|hook| Step::other(looks_like_kanbrains(hook, todo))));
479 }
480 Err(e) => done.push(Step::other(format!("left the Claude Code hooks in place: {e:#}"))),
481 }
482 for name in AGENT_FILES {
483 let path = root.join(name);
484 let Ok(before) = fs::read_to_string(&path) else { continue };
485 let after = without_section(&before);
486 if after == before {
487 continue;
488 }
489 if after.trim().is_empty() {
490 store::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
491 done.push(Step::other(format!("removed {name}: it only held the \"Project memory\" section")));
492 } else {
493 store::write_file(&path, &after)?;
494 done.push(Step::other(format!("removed the \"Project memory\" section from {name}")));
495 }
496 }
497 Ok(done)
498}
499
500fn section(text: &str) -> Option<(usize, usize)> {
504 let end = text.find(END)? + END.len();
505 let start = text[..end].rfind(START)?;
506 let eol = ["\n\r\n", "\r\n", "\n"].into_iter().find(|eol| text[end..].starts_with(eol)).unwrap_or("");
509 Some((start, end + eol.len()))
510}
511
512fn line_break(text: &str) -> &'static str {
519 let outside = match section(text) {
520 Some((start, end)) => [&text[..start], &text[end..]].into_iter().find(|part| part.contains('\n')),
521 None => Some(text),
522 };
523 match outside.and_then(|part| part.find('\n').map(|at| &part[..at])) {
524 Some(line) if line.ends_with('\r') => "\r\n",
525 None if text.ends_with("\r\n") => "\r\n",
526 Some(_) | None => "\n",
527 }
528}
529
530fn with_section(text: &str) -> String {
533 let eol = line_break(text);
534 let block = format!("{START}\n{SECTION}\n{END}\n").replace('\n', eol);
535 if let Some((start, end)) = section(text) {
536 return format!("{}{block}{}", &text[..start], &text[end..]);
537 }
538 match text.trim_end() {
540 kept @ ("" | BOM) => format!("{kept}{block}"),
541 kept => format!("{kept}{eol}{eol}{block}"),
542 }
543}
544
545fn without_section(text: &str) -> String {
548 let Some((start, end)) = section(text) else { return text.to_string() };
549 let eol = line_break(text);
550 let before = text[..start].trim_end();
551 let after = text[end..].trim_start_matches(['\r', '\n']);
552 match (before, after) {
553 ("" | BOM, "") => String::new(),
554 ("" | BOM, after) => format!("{before}{after}"),
555 (before, "") => format!("{before}{eol}"),
556 (before, after) => format!("{before}{eol}{eol}{after}"),
557 }
558}
559
560#[cfg(test)]
561#[path = "../../tests/cli/init.rs"]
562mod tests;