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 = "\
31## Project memory: kanbrain
32
33This project's tasks, to-dos, decisions and notes live in `.brain/`, managed only with `kb`, kanbrain's CLI.
34The user follows them live on a kanban board: keep it showing what you're doing as you go, not at the end.
35
36- Start of session: `kb context`, unless its output is already above (a session hook adds it).
37- Work that changes the repository, or that the user should see, gets a task; questions and lookups don't. Find it
38 (`search WORDS`) or create it (`add TITLE`), then `start ID` (`add --start TITLE` does both). \"Todos\", a plan or
39 what's pending, in any language, are tasks here, one `add` each; your built-in to-do list isn't on the board, so
40 keep it for steps within the task you've started. A task with real work gets a body, `add --body - TITLE`:
41 **Goal** (what and why, a line or two) and **Done when** (what the user will check).
42- While working: `comment ID TEXT` when a step is done, when you find a cause, and before you stop (one or two
43 lines: what changed, what's next); `done ID` as soon as one is finished. Stopping before it is: `comment`, then
44 `park ID`. Doing isn't limited for you: several of your tasks can run at once. Never park, finish or move a task
45 you didn't start (the user's, or another session's).
46- Splitting work between subagents: give each part that changes the repository its own task, and run the board
47 yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
48 only do the work; lookups and research get no task. `kb` commands can run in parallel.
49- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
50 question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
51 storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
52 the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
53 command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
54 prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
55 stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
56 the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
57 you to.
58- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
59 then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
60 findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
61 repository that has to change.
62- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
63 or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
64 services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
65 columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
66 picture changes (a service added, another storage), not after every change. With several repositories, this
67 one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
68 file, outside this section: add them as you learn them.
69- Also: `show ID` (one entry in full), `describe ID TEXT`, `link ID OTHER…` (an id here, or `REPO#ID` in another
70 repository of the project); `-` reads text from stdin. Never edit the files in `.brain/` by hand: `kb` keeps
71 them consistent.";
72
73pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
79 let mut done = Vec::new();
80 let dir = root.join(PROJECT_DIR);
81 if dir.is_dir() {
82 done.push(format!("{} already exists", dir.display()));
83 } else {
84 Store::at(&dir)?;
85 done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
86 done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
87 }
88 let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
90 let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
91 let card = projects::read_repo_card(&dir);
92 let project = match (name, &card, folder) {
93 (Some(name), _, _) => name.to_string(),
94 (None, Some(card), _) => card.project.clone(),
95 (None, None, Some(folder)) => folder,
96 (None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
97 };
98 projects::check_name(&project)?;
99 let named = projects::repo_name(central, &project, &dir, repo)?;
100 let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
103 let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
104 let new_card =
105 projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
106 if card.as_ref() != Some(&new_card) {
107 projects::write_repo_card(&dir, &new_card)?;
108 }
109 projects::register(central, &project, &dir)?;
110 if projects::ensure_description(central, &project)? {
111 done.push(format!(
112 "created project `{project}`'s card in the central brain: describe the project with `kb project \
113 describe` or from the kanban app (or let the agent do it); all its repositories share it"
114 ));
115 }
116 let repos = projects::load(central)?;
117 let here =
118 repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
119 let others: Vec<String> = repos
120 .iter()
121 .filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
122 .map(projects::Repo::name)
123 .collect();
124 if others.is_empty() {
125 done.push(format!(
126 "registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
127 ));
128 } else {
129 done.push(format!(
130 "registered as `{here}`, a repository of project `{project}` with {}: they share its card",
131 others.join(", ")
132 ));
133 }
134 let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
135 let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
136 done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
137 if !on_path(std::env::var_os("PATH"), "kb") {
138 done.push(
139 "`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
140 (the installer's `--app both`)"
141 .into(),
142 );
143 }
144 Ok(done)
145}
146
147pub fn init_all(central: &Path) -> Result<Vec<String>> {
153 let repos = projects::load(central)?;
154 let mut done = migrate_all(central, &repos)?;
155 let mut current = 0;
156 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
157 let Some(root) = repo.dir.parent() else { continue };
158 let targets: Vec<&str> = AGENT_FILES
159 .into_iter()
160 .filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
161 .collect();
162 if targets.is_empty() {
163 continue;
164 }
165 let changes: Vec<String> = write_agent_files(root, &targets)?
166 .into_iter()
167 .filter(|(changed, _)| *changed)
168 .map(|(_, line)| line)
169 .collect();
170 if changes.is_empty() {
171 current += 1;
172 }
173 let label = repo.label(&repos);
174 done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
175 }
176 if current > 0 {
177 done.push(format!("{current} more already had this version's instructions"));
178 }
179 Ok(done)
180}
181
182fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
185 let mut done = crate::migrate::migrate(&Store::at(central)?)?;
186 for repo in repos.iter().filter(|r| r.dir.is_dir()) {
187 match crate::migrate::migrate(&Store::at(&repo.dir)?) {
188 Ok(lines) => done.extend(lines),
189 Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
190 done.push(format!("{}: left alone: {e}", repo.label(repos)));
191 }
192 Err(e) => return Err(e),
193 }
194 }
195 Ok(done)
196}
197
198fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
201 let mut done = Vec::new();
202 if targets.contains(&"CLAUDE.md") {
204 done.push(match claude::add_hooks(root) {
205 Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
206 Ok(_) => (
207 true,
208 format!(
209 "added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
210 after compaction, and an agent can't end its turn while the project isn't described",
211 claude::SETTINGS
212 ),
213 ),
214 Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
216 });
217 }
218 for &name in targets {
219 let path = root.join(name);
220 let before = fs::read_to_string(&path).unwrap_or_default();
221 let after = with_section(&before);
222 if after == before {
223 done.push((false, format!("{name} already tells agents about kanbrain")));
224 continue;
225 }
226 store::write_file(&path, &after)?;
227 let what = if before.is_empty() {
228 "created"
229 } else if before.contains(START) {
230 "updated"
231 } else {
232 "added to"
233 };
234 done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
235 }
236 Ok(done)
237}
238
239fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
241 let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
242 path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
243}
244
245pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
250 let Some(repo) = projects::unregister(central, dir)? else {
251 bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
252 };
253 let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
254 let others: Vec<String> =
255 projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
256 if !others.is_empty() {
257 done.push(format!(
258 "project `{}` keeps its card for its other repositories: {}",
259 repo.project,
260 others.join(", ")
261 ));
262 } else if projects::remove_description(central, &repo.project)? {
263 done.push(format!("removed project `{}`'s card from the central brain", repo.project));
264 }
265 let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
266 done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
267 return Ok(done);
268 };
269 match claude::remove_hooks(root) {
270 Ok(claude::Change::Unchanged) => {}
271 Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
272 Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
273 Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
274 }
275 for name in AGENT_FILES {
276 let path = root.join(name);
277 let Ok(before) = fs::read_to_string(&path) else { continue };
278 let after = without_section(&before);
279 if after == before {
280 continue;
281 }
282 if after.trim().is_empty() {
283 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
284 done.push(format!("removed {name}: it only held the \"Project memory\" section"));
285 } else {
286 store::write_file(&path, &after)?;
287 done.push(format!("removed the \"Project memory\" section from {name}"));
288 }
289 }
290 Ok(done)
291}
292
293fn section(text: &str) -> Option<(usize, usize)> {
297 let end = text.find(END)? + END.len();
298 let start = text[..end].rfind(START)?;
299 Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
300}
301
302fn with_section(text: &str) -> String {
305 let block = format!("{START}\n{SECTION}\n{END}\n");
306 if let Some((start, end)) = section(text) {
307 return format!("{}{block}{}", &text[..start], &text[end..]);
308 }
309 match text.trim_end() {
310 "" => block,
311 kept => format!("{kept}\n\n{block}"),
312 }
313}
314
315fn without_section(text: &str) -> String {
317 let Some((start, end)) = section(text) else { return text.to_string() };
318 let before = text[..start].trim_end();
319 let after = text[end..].trim_start_matches('\n');
320 match (before, after) {
321 ("", after) => after.to_string(),
322 (before, "") => format!("{before}\n"),
323 (before, after) => format!("{before}\n\n{after}"),
324 }
325}
326
327#[cfg(test)]
328#[path = "../../tests/cli/init.rs"]
329mod tests;