1use super::claude;
11use crate::projects;
12use crate::store::{self, PROJECT_DIR, Store};
13use anyhow::{Context, Result, bail};
14use std::fs;
15use std::path::Path;
16
17const AGENT_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
19const DEFAULT_AGENT_FILE: &str = "CLAUDE.md";
21const START: &str = "<!-- kanbrain:start -->";
24const END: &str = "<!-- kanbrain:end -->";
26
27const SECTION: &str = concat!(
31 "\
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); `start` the next task when you switch; `done ID` as soon as one is finished.
45 Stopping before it is: `comment`, then `park ID`. If `start` answers `· parked #N` for a task you didn't start,
46 it was the user's: tell them.
47- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
48 question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
49 storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
50 the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
51 command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
52 prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
53 stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
54 the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
55 you to.
56- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
57 then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious findings: `add --note --link ID --body - TITLE`;
58 a problem someone has to fix is a task instead, in the repository that has to change.
59- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
60 or load the brain, describe it first, from the README and manifests: `project describe -` (",
61 crate::projects::description_scope!(),
62 "). Rewrite it when the big picture changes (a service added, another storage), not after every change. With
63 several repositories, this one's role too: `project notes -`. How to build, test and run this repository, and its
64 code style, go in this file, outside this section: add them as you learn them.
65- Also: `show ID` (one entry in full), `describe ID TEXT`, `link ID OTHER…` (an id here, or `REPO#ID` in another
66 repository of the project); `-` reads text from stdin. Never edit the files in `.brain/` by hand: `kb` keeps
67 them consistent.",
68);
69
70pub fn init(root: &Path, name: Option<&str>, central: &Path) -> Result<Vec<String>> {
76 let mut done = Vec::new();
77 let dir = root.join(PROJECT_DIR);
78 if dir.is_dir() {
79 done.push(format!("{} already exists", dir.display()));
80 } else {
81 Store::at(&dir)?;
82 done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
83 done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
84 }
85 let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
87 let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
88 let card = projects::read_repo_card(&dir);
89 let project = match (name, &card, folder) {
90 (Some(name), _, _) => name.to_string(),
91 (None, Some((card, _)), _) => card.clone(),
92 (None, None, Some(folder)) => folder,
93 (None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
94 };
95 projects::register(central, &project, &dir)?;
96 match &card {
97 Some((name, _)) if *name == project => {}
98 Some((_, notes)) => projects::write_repo_card(&dir, &project, notes)?,
99 None => projects::write_repo_card(&dir, &project, "")?,
100 }
101 if projects::ensure_description(central, &project)? {
102 done.push(format!(
103 "created project `{project}`'s card in the central brain: describe the project with `kb project \
104 describe` or from the kanban app (or let the agent do it); all its repositories share it"
105 ));
106 }
107 let repos = projects::load(central)?;
108 let here = repos.iter().find(|r| r.dir == dir).map_or_else(|| project.clone(), |r| r.label(&repos));
109 let others: Vec<String> =
110 repos.iter().filter(|r| r.project == project && r.dir != dir).map(projects::Repo::name).collect();
111 if others.is_empty() {
112 done.push(format!(
113 "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
114 ));
115 } else {
116 done.push(format!(
117 "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
118 others.join(", ")
119 ));
120 }
121 let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
122 let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
123 done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
124 if !on_path(std::env::var_os("PATH"), "kb") {
125 done.push(
126 "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
127 (the installer's `--app both`)"
128 .into(),
129 );
130 }
131 Ok(done)
132}
133
134pub fn init_all(central: &Path) -> Result<Vec<String>> {
139 let repos = projects::load(central)?;
140 let mut done = Vec::new();
141 let mut current = 0;
142 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
143 let Some(root) = repo.dir.parent() else { continue };
144 let targets: Vec<&str> = AGENT_FILES
145 .into_iter()
146 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
147 .collect();
148 if targets.is_empty() {
149 continue;
150 }
151 let changes: Vec<String> = write_agent_files(root, &targets)?
152 .into_iter()
153 .filter(|(changed, _)| *changed)
154 .map(|(_, line)| line)
155 .collect();
156 if changes.is_empty() {
157 current += 1;
158 }
159 let label = repo.label(&repos);
160 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
161 }
162 if current > 0 {
163 done.push(format!("{current} more already had this version's instructions"));
164 }
165 Ok(done)
166}
167
168fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
171 let mut done = Vec::new();
172 if targets.contains(&"CLAUDE.md") {
174 done.push(match claude::add_hooks(root) {
175 Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
176 Ok(_) => (
177 true,
178 format!(
179 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
180 after compaction, and an agent can't end its turn while the project isn't described",
181 claude::SETTINGS
182 ),
183 ),
184 Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
186 });
187 }
188 for &name in targets {
189 let path = root.join(name);
190 let before = fs::read_to_string(&path).unwrap_or_default();
191 let after = with_section(&before);
192 if after == before {
193 done.push((false, format!("{name} already tells agents about kanbrain")));
194 continue;
195 }
196 store::write_file(&path, &after)?;
197 let what = if before.is_empty() {
198 "created"
199 } else if before.contains(START) {
200 "updated"
201 } else {
202 "added to"
203 };
204 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
205 }
206 Ok(done)
207}
208
209fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
211 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
212 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
213}
214
215pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
220 let Some(repo) = projects::unregister(central, dir)? else {
221 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
222 };
223 let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
224 let others: Vec<String> =
225 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
226 if !others.is_empty() {
227 done.push(format!(
228 "project `{}` keeps its card for its other repositories: {}",
229 repo.project,
230 others.join(", ")
231 ));
232 } else if projects::remove_description(central, &repo.project)? {
233 done.push(format!("removed project `{}`'s card from the central brain", repo.project));
234 }
235 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
236 done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
237 return Ok(done);
238 };
239 match claude::remove_hooks(root) {
240 Ok(claude::Change::Unchanged) => {}
241 Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
242 Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
243 Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
244 }
245 for name in AGENT_FILES {
246 let path = root.join(name);
247 let Ok(before) = fs::read_to_string(&path) else { continue };
248 let after = without_section(&before);
249 if after == before {
250 continue;
251 }
252 if after.trim().is_empty() {
253 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
254 done.push(format!("removed {name}: it only held the \"Project memory\" section"));
255 } else {
256 store::write_file(&path, &after)?;
257 done.push(format!("removed the \"Project memory\" section from {name}"));
258 }
259 }
260 done.push(format!(
261 "kept {}: commands run here still use it; delete it to drop its tasks, notes and decisions",
262 repo.dir.display()
263 ));
264 Ok(done)
265}
266
267fn section(text: &str) -> Option<(usize, usize)> {
271 let end = text.find(END)? + END.len();
272 let start = text[..end].rfind(START)?;
273 Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
274}
275
276fn with_section(text: &str) -> String {
279 let block = format!("{START}\n{SECTION}\n{END}\n");
280 if let Some((start, end)) = section(text) {
281 return format!("{}{block}{}", &text[..start], &text[end..]);
282 }
283 match text.trim_end() {
284 "" => block,
285 kept => format!("{kept}\n\n{block}"),
286 }
287}
288
289fn without_section(text: &str) -> String {
291 let Some((start, end)) = section(text) else { return text.to_string() };
292 let before = text[..start].trim_end();
293 let after = text[end..].trim_start_matches('\n');
294 match (before, after) {
295 ("", after) => after.to_string(),
296 (before, "") => format!("{before}\n"),
297 (before, after) => format!("{before}\n\n{after}"),
298 }
299}
300
301#[cfg(test)]
302#[path = "../../tests/cli/init.rs"]
303mod tests;