use super::claude;
use crate::model::counted;
use crate::projects;
use crate::store::{self, PROJECT_DIR, Store};
use anyhow::{Context, Result, bail};
use std::fs;
use std::path::Path;
const AGENT_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
const DEFAULT_AGENT_FILE: &str = "CLAUDE.md";
const START: &str = "<!-- kanbrain:start -->";
const END: &str = "<!-- kanbrain:end -->";
const SECTION: &str = "\
## Project memory: kanbrain
This project's tasks, to-dos, decisions and notes live in `.brain/`, managed only with `kb`, kanbrain's CLI.
The user follows them live on a kanban board: keep it showing what you're doing as you go, not at the end.
- Start of session: `kb context`, unless its output is already above (a session hook adds it).
- Work that changes the repository, or that the user should see, gets a task; questions and lookups don't. Find it
(`search WORDS`) or create it (`add TITLE`), then `start ID` (`add --start TITLE` does both). \"Todos\", a plan or
what's pending, in any language, are tasks here, one `add` each; your built-in to-do list isn't on the board, so
keep it for steps within the task you've started. A task with real work gets a body, `add --body - TITLE`:
**Goal** (what and why, a line or two) and **Done when** (what the user will check).
- While working: `comment ID TEXT` when a step is done, when you find a cause, and before you stop (one or two
lines: what changed, what's next); `done ID` as soon as one is finished. Stopping before it is: `comment`, then
`park ID`. Doing isn't limited for you: several of your tasks can run at once. Never park, finish or move a task
you didn't start (the user's, or another session's).
- Splitting work between subagents: give each part that changes the repository its own task, and run the board
yourself: `add --start` it before launching the subagent, then `comment` its result and `done` it. Subagents
only do the work; lookups and research get no task. `kb` commands can run in parallel.
- Blocked on the user (a requirement, access, a choice that's theirs): `ask ID TEXT` parks the task with the
question; don't guess. A choice you shouldn't make alone and weren't asked to (architecture, a dependency,
storage, a public interface): `propose ID --body - TITLE`, Context / Options / Recommendation on stdin; it parks
the task until the user accepts or rejects it. Then, unless the user said they're away, if your tool can run a
command in the background and wake you when it exits, run `kb wait ID` (the task) that way and end your turn: it
prints the answer, or that there's none yet (then stop, don't wait again). Otherwise go on with other work or
stop; answers come first in `context`, under Answered: `show ID`, then pick the task up. If the user answers in
the chat instead, `start` the task (that ends the wait). `accept`/`reject` a decision only when the user tells
you to.
- Choosing between approaches within your task: `search` for earlier decisions (only accepted ones are in force),
then `add --decision --link ID --body - TITLE` with Context / Decision / Consequences on stdin. Non-obvious
findings: `add --note --link ID --body - TITLE`; a problem someone has to fix is a task instead, in the
repository that has to change.
- `## Project` in `context` is the application's shared context. If it isn't described, or you're asked to set up
or load the brain, describe it first, from the README and manifests: `project describe -` (what the app is, its
services and how they connect, conventions they share: the big picture, in at most five lines, without tables,
columns, function signatures, files or dependencies, which the code already says). Rewrite it when the big
picture changes (a service added, another storage), not after every change. With several repositories, this
one's role too: `project notes -`. How to build, test and run this repository, and its code style, go in this
file, outside this section: add them as you learn them.
- Also: `show ID` (one entry in full), `describe ID TEXT`, `link ID OTHER…` (an id here, or `REPO#ID` in another
repository of the project); `-` reads text from stdin. Never edit the files in `.brain/` by hand: `kb` keeps
them consistent.";
pub fn init(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
if let Some(to) = name
&& let Ok(dir) = root.join(PROJECT_DIR).canonicalize()
&& projects::load(central)?.iter().any(|r| projects::same_dir(&r.dir, &dir) && r.project != to)
{
let mut done = super::manage::move_repo(central, &dir, to)?;
if let Some(repo) = repo {
match super::manage::rename_repo(central, &dir, repo) {
Ok(lines) => done.extend(lines),
Err(e) => done.push(format!("kept the name it has: {e:#}")),
}
}
return Ok(done);
}
join(root, name, repo, central)
}
pub(crate) fn join(root: &Path, name: Option<&str>, repo: Option<&str>, central: &Path) -> Result<Vec<String>> {
let mut done = Vec::new();
let dir = root.join(PROJECT_DIR);
if root.to_string_lossy().contains(['\n', '\r']) {
bail!("{} has a line break in its path: kanbrain can't register it", root.display());
}
if projects::same_dir(&dir, central) {
bail!("{} is the central brain, not a project's: run `kb init` in a project's folder", dir.display());
}
if dir.is_dir() {
done.push(format!("{} already exists", dir.display()));
} else {
Store::at(&dir)?;
done.push(format!("created {}: kanbrain uses it from here and every folder below", dir.display()));
done.push("commit it to share tasks, notes and decisions with the project, or add it to .gitignore".into());
}
let dir = dir.canonicalize().with_context(|| format!("reading {}", dir.display()))?;
let folder = root.canonicalize().ok().and_then(|r| Some(r.file_name()?.to_string_lossy().into_owned()));
let card = projects::read_repo_card(&dir);
let registered = projects::load(central)?.into_iter().find(|r| projects::same_dir(&r.dir, &dir)).map(|r| r.project);
if name.is_none()
&& let Some(old) = registered.as_deref()
&& projects::check_name(old).is_err()
{
bail!(
"this repository is registered in project `{old}`, a name kanbrain no longer takes: `kb init --all` \
renames such projects, keeping their repositories and card"
);
}
let project = match (name, registered, &card, folder) {
(Some(name), ..) => name.to_string(),
(None, Some(registered), ..) => registered,
(None, None, Some(card), _) => card.project.clone(),
(None, None, None, Some(folder)) => folder,
(None, None, None, None) => bail!("this folder has no name to give the project: `kb init NAME`"),
};
projects::check_name(&project)?;
let named = projects::repo_name(central, &project, &dir, repo)?;
let folder_name = projects::Repo { project: project.clone(), dir: dir.clone() }.folder();
let notes = card.as_ref().map(|card| card.notes.clone()).unwrap_or_default();
let new_card =
projects::RepoCard { project: project.clone(), repo: (named != folder_name).then_some(named), notes };
if card.as_ref() != Some(&new_card) {
projects::write_repo_card(&dir, &new_card)?;
}
projects::register(central, &project, &dir)?;
if projects::ensure_description(central, &project)? {
done.push(format!(
"created project `{project}`'s card in the central brain: describe the project with `kb project \
describe` or from the kanban app (or let the agent do it); all its repositories share it"
));
}
let repos = projects::load(central)?;
let here =
repos.iter().find(|r| projects::same_dir(&r.dir, &dir)).map_or_else(|| project.clone(), |r| r.label(&repos));
let others: Vec<String> = repos
.iter()
.filter(|r| r.project == project && !projects::same_dir(&r.dir, &dir))
.map(projects::Repo::name)
.collect();
if others.is_empty() {
done.push(format!(
"registered as project `{here}`: the kanban app shows it, and `--project {here}` reaches it"
));
} else {
done.push(format!(
"registered as `{here}`, a repository of project `{project}` with {}: they share its card",
others.join(", ")
));
}
for gone in repos.iter().filter(|r| r.project == project && !r.dir.is_dir()) {
let name = gone.name();
done.push(format!(
"`{project}/{name}`'s folder is gone ({}): if this is it, moved here, take the old place out with \
`kb --project {project}/{name} deinit`, then `kb project repo {name}` here",
gone.dir.display()
));
}
let existing: Vec<&str> = AGENT_FILES.into_iter().filter(|name| root.join(name).is_file()).collect();
let targets = if existing.is_empty() { vec![DEFAULT_AGENT_FILE] } else { existing };
done.extend(write_agent_files(root, &targets)?.into_iter().map(|(_, line)| line));
if !on_path(std::env::var_os("PATH"), "kb") {
done.push(
"`kb` isn't on your PATH, and agents and the hooks run it: install the command line too \
(the installer's `--app both`)"
.into(),
);
}
Ok(done)
}
fn rename_unreachable_projects(central: &Path) -> Result<Vec<String>> {
let mut names: Vec<String> = projects::load(central)?.into_iter().map(|r| r.project).collect();
names.sort_unstable();
names.dedup();
let unreachable: Vec<String> = names.iter().filter(|name| projects::check_name(name).is_err()).cloned().collect();
let mut done = Vec::new();
for old in unreachable {
let kept: String = old.chars().map(|c| if matches!(c, ':' | '/' | '\n') { '-' } else { c }).collect();
let base = match kept.trim().trim_start_matches('-').trim() {
"" => "project",
base => base,
};
let new = (0..=names.len())
.map(|n| match n {
0 => base.to_string(),
1 => format!("{base}-project"),
n => format!("{base}-project-{n}"),
})
.find(|name| projects::check_name(name).is_ok() && !names.contains(name))
.expect("more names than projects");
match super::manage::rename_project(central, &old, &new) {
Ok(lines) => done.extend(lines),
Err(e) => done.push(format!("couldn't rename project `{old}`: {e:#}")),
}
names.push(new);
}
Ok(done)
}
pub fn init_all(central: &Path) -> Result<Vec<String>> {
let mut done = migrate_all(central, &projects::load(central)?)?;
done.extend(rename_unreachable_projects(central)?);
let repos = projects::load(central)?;
let mut current = 0;
for repo in repos.iter().filter(|r| r.dir.is_dir()) {
let name = repo.name();
if projects::check_repo_name(&name).is_err() {
let fixed = projects::repo_name(central, &repo.project, &repo.dir, None)?;
done.extend(super::manage::rename_repo(central, &repo.dir, &fixed)?);
}
if let Some(card) = projects::read_repo_card(&repo.dir).filter(|card| card.project != repo.project) {
let old = card.project.clone();
projects::write_repo_card(&repo.dir, &projects::RepoCard { project: repo.project.clone(), ..card })?;
done.push(format!("{}: its card named project `{old}`, now `{}`: fixed", repo.label(&repos), repo.project));
}
let Some(root) = repo.dir.parent() else { continue };
let targets: Vec<&str> = AGENT_FILES
.into_iter()
.filter(|name| fs::read_to_string(root.join(name)).is_ok_and(|text| text.contains(START)))
.collect();
if targets.is_empty() {
continue;
}
let changes: Vec<String> = write_agent_files(root, &targets)?
.into_iter()
.filter(|(changed, _)| *changed)
.map(|(_, line)| line)
.collect();
if changes.is_empty() {
current += 1;
}
let label = repo.label(&repos);
done.extend(changes.into_iter().map(|line| format!("{label}: {line}")));
}
match (current, done.is_empty()) {
(0, _) => {}
(_, true) => done.push(format!(
"{} already had this version's instructions",
counted(current, "repository", "repositories")
)),
(_, false) => done.push(format!("{current} more already had this version's instructions")),
}
Ok(done)
}
fn migrate_all(central: &Path, repos: &[projects::Repo]) -> Result<Vec<String>> {
let mut done = crate::migrate::migrate(&Store::at(central)?)?;
for repo in repos.iter().filter(|r| r.dir.is_dir()) {
match Store::at(&repo.dir).and_then(|store| crate::migrate::migrate(&store)) {
Ok(lines) => done.extend(lines),
Err(e) if e.downcast_ref::<crate::migrate::TooNew>().is_some() => {
done.push(format!("{}: left alone: {e}", repo.label(repos)));
}
Err(e) => done.push(format!(
"{}: not brought up to date: {e:#}; fix it, then run `kb init --all` again",
repo.label(repos)
)),
}
}
Ok(done)
}
fn write_agent_files(root: &Path, targets: &[&str]) -> Result<Vec<(bool, String)>> {
let mut done = Vec::new();
if targets.contains(&"CLAUDE.md") {
done.push(match claude::add_hooks(root) {
Ok(claude::Change::Unchanged) => (false, format!("{} already has kanbrain's hooks", claude::SETTINGS)),
Ok(_) => (
true,
format!(
"added kanbrain's hooks to {}: Claude Code reads `kb context` when a session starts and \
after compaction, reminds an agent with every prompt and at the end of its turn to \
describe the project while it isn't, and sends it to `kb` when it would write the brain's \
files itself",
claude::SETTINGS
),
),
Err(e) => (false, format!("left out the Claude Code hooks: {e:#}")),
});
}
for &name in targets {
let path = root.join(name);
let before = match fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
done.push((false, format!("left {name} as it is: can't read it ({e}); add the section by hand")));
continue;
}
};
let after = with_section(&before);
if after == before {
done.push((false, format!("{name} already tells agents about kanbrain")));
continue;
}
store::write_file(&path, &after)?;
let what = if before.is_empty() {
"created"
} else if before.contains(START) {
"updated"
} else {
"added to"
};
done.push((true, format!("{what} {name}: the \"Project memory\" section tells agents how to use the brain")));
}
Ok(done)
}
fn on_path(path: Option<std::ffi::OsString>, name: &str) -> bool {
let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
path.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(&file).is_file()))
}
pub fn deinit(dir: &Path, central: &Path) -> Result<Vec<String>> {
let Some(repo) = projects::unregister(central, dir)? else {
bail!("{} isn't a registered project: there's nothing to deinit", dir.display());
};
let mut done = vec![format!("unregistered {}: the kanban app no longer shows it", repo.dir.display())];
let others: Vec<String> =
projects::load(central)?.iter().filter(|r| r.project == repo.project).map(projects::Repo::name).collect();
if !others.is_empty() {
done.push(format!(
"project `{}` keeps its card for its other repositories: {}",
repo.project,
others.join(", ")
));
} else if projects::remove_description(central, &repo.project)? {
done.push(format!("removed project `{}`'s card from the central brain", repo.project));
}
let Some(root) = repo.dir.parent().filter(|_| repo.dir.is_dir()) else {
done.push(format!("{} is gone, so there was nothing else to undo", repo.dir.display()));
return Ok(done);
};
match claude::remove_hooks(root) {
Ok(claude::Change::Unchanged) => {}
Ok(claude::Change::Written) => done.push(format!("removed kanbrain's hooks from {}", claude::SETTINGS)),
Ok(claude::Change::Removed) => done.push(format!("removed {}: it only held the hooks", claude::SETTINGS)),
Err(e) => done.push(format!("left the Claude Code hooks in place: {e:#}")),
}
for name in AGENT_FILES {
let path = root.join(name);
let Ok(before) = fs::read_to_string(&path) else { continue };
let after = without_section(&before);
if after == before {
continue;
}
if after.trim().is_empty() {
fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
done.push(format!("removed {name}: it only held the \"Project memory\" section"));
} else {
store::write_file(&path, &after)?;
done.push(format!("removed the \"Project memory\" section from {name}"));
}
}
Ok(done)
}
fn section(text: &str) -> Option<(usize, usize)> {
let end = text.find(END)? + END.len();
let start = text[..end].rfind(START)?;
Some((start, if text[end..].starts_with('\n') { end + 1 } else { end }))
}
fn with_section(text: &str) -> String {
let block = format!("{START}\n{SECTION}\n{END}\n");
if let Some((start, end)) = section(text) {
return format!("{}{block}{}", &text[..start], &text[end..]);
}
match text.trim_end() {
"" => block,
kept => format!("{kept}\n\n{block}"),
}
}
fn without_section(text: &str) -> String {
let Some((start, end)) = section(text) else { return text.to_string() };
let before = text[..start].trim_end();
let after = text[end..].trim_start_matches('\n');
match (before, after) {
("", after) => after.to_string(),
(before, "") => format!("{before}\n"),
(before, after) => format!("{before}\n\n{after}"),
}
}
#[cfg(test)]
#[path = "../../tests/cli/init.rs"]
mod tests;