kanbo-cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,9 +35,11 @@ let drizzle_orm_postgres_js_migrator = require("drizzle-orm/postgres-js/migrator
35
35
  let postgres = require("postgres");
36
36
  postgres = __toESM(postgres, 1);
37
37
  let node_url = require("node:url");
38
+ let _modelcontextprotocol_sdk_client_index_js = require("@modelcontextprotocol/sdk/client/index.js");
39
+ let _modelcontextprotocol_sdk_client_stdio_js = require("@modelcontextprotocol/sdk/client/stdio.js");
40
+ let node_child_process = require("node:child_process");
38
41
  let _modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js");
39
42
  let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
40
- let node_child_process = require("node:child_process");
41
43
  let node_http = require("node:http");
42
44
  /**
43
45
  * Every exit code this tool promises, and what it means — the capabilities
@@ -1924,15 +1926,12 @@ async function approve(store, issueId, input, actor, scope) {
1924
1926
  * leaves even says "returned by you".
1925
1927
  */
1926
1928
  async function returnCard(store, issueId, input, actor, scope) {
1927
- if (actor.kind !== "user") throw new require_migrations_path.BoardError("board_return_requires_user", {
1928
- issueId,
1929
- actorKind: actor.kind
1930
- });
1931
1929
  return await runBoardWrite(store, async ({ tx }) => {
1930
+ await ensureDefaultColumns(tx, (await requireCard$1(tx, issueId)).workspaceId, { tx });
1931
+ const target = await assertReturnable(tx, issueId, input, actor);
1932
1932
  const card = await requireCard$1(tx, issueId);
1933
- const columns = await ensureDefaultColumns(tx, card.workspaceId, { tx });
1934
1933
  const updates = {
1935
- statusId: (input.toStatusName ? await requireColumn(tx, card.workspaceId, input.toStatusName) : previousColumn(columns, card)).id,
1934
+ statusId: target.id,
1936
1935
  waitingFor: null,
1937
1936
  updatedAt: currentUnixSeconds()
1938
1937
  };
@@ -1953,6 +1952,27 @@ async function returnCard(store, issueId, input, actor, scope) {
1953
1952
  };
1954
1953
  }, scope);
1955
1954
  }
1955
+ /**
1956
+ * Every reason `returnCard` would refuse, asked on reads alone, and the column
1957
+ * the card would go back to when none applies.
1958
+ *
1959
+ * `returnCard` asks exactly this inside its write. A caller that has to undo
1960
+ * something of its own before the return — stop listening for the approval the
1961
+ * card is waiting on, say — asks it first, so a return the board refuses leaves
1962
+ * that untouched. The refusals are the same `BoardError` codes: an actor who is
1963
+ * not a person (`board_return_requires_user`), a card that is not there
1964
+ * (`issue_not_found`), a named column the workspace does not hold
1965
+ * (`issue_status_not_found`), and a card already in the first column with no
1966
+ * column named (`board_return_no_previous_column`).
1967
+ */
1968
+ async function assertReturnable(store, issueId, input, actor) {
1969
+ if (actor.kind !== "user") throw new require_migrations_path.BoardError("board_return_requires_user", {
1970
+ issueId,
1971
+ actorKind: actor.kind
1972
+ });
1973
+ const card = await requireCard$1(store, issueId);
1974
+ return input.toStatusName ? await requireColumn(store, card.workspaceId, input.toStatusName) : previousColumn(await listColumns(store, card.workspaceId), card);
1975
+ }
1956
1976
  /** Write a comment on the card, as the actor unless the caller names another author. */
1957
1977
  async function addComment(store, input, actor, scope) {
1958
1978
  return await runBoardWrite(store, async ({ tx }) => {
@@ -2830,6 +2850,7 @@ function createBoardOps(store) {
2830
2850
  waitApproval: async (issueId, input, actor, scope) => await waitApproval(store, issueId, input, actor, scope),
2831
2851
  approve: async (issueId, input, actor, scope) => await approve(store, issueId, input, actor, scope),
2832
2852
  returnCard: async (issueId, input, actor, scope) => await returnCard(store, issueId, input, actor, scope),
2853
+ assertReturnable: async (issueId, input, actor) => await assertReturnable(store, issueId, input, actor),
2833
2854
  readApproval: async (issueId) => await readApproval(store, issueId),
2834
2855
  linkPullRequest: async (issueId, urlOrRef, actor, scope) => await linkPullRequest(store, issueId, urlOrRef, actor, scope),
2835
2856
  unlinkPullRequest: async (issueId, linkId, actor, scope) => await unlinkPullRequest(store, issueId, linkId, actor, scope),
@@ -4324,77 +4345,103 @@ function readRequestedRules(options) {
4324
4345
  return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
4325
4346
  }
4326
4347
  //#endregion
4327
- //#region src/default-board-file-path.ts
4348
+ //#region src/package-version.ts
4349
+ (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
4328
4350
  /**
4329
- * Where a board file of a project's own goes when nobody names a path —
4330
- * `kanbo init --file` writes it here, and an app that attaches a project's
4331
- * board file can import the same constant rather than spelling it again.
4351
+ * This build's own version, one source for the CLI's `--version`, the MCP
4352
+ * server's reported version and the capabilities manifest's
4353
+ * `package.version`. A build carries it as a literal; running from source
4354
+ * (tests, `tsx`) reads the package's own `package.json` instead.
4332
4355
  */
4333
- const DEFAULT_BOARD_FILE_PATH = (0, node_path.join)(".kanbo", "board.db");
4356
+ const KANBO_PACKAGE_VERSION = "0.2.0";
4334
4357
  //#endregion
4335
- //#region src/cli/commands/init.ts
4358
+ //#region src/cli/failure.ts
4336
4359
  /**
4337
- * `kanbo init` — tying a project folder to a board, and telling the agents
4338
- * that work in it what the board expects.
4360
+ * What a failed command says, and what it leaves with.
4339
4361
  *
4340
- * Three things come out of it, and each is optional except the first: the
4341
- * binding, so no later command has to be told which workspace this is; a block
4342
- * in the project's agent instructions, so an agent reads the board's rules
4343
- * without being handed them; and a registration of the board's MCP server with
4344
- * whichever tools the person actually uses. Nothing is written to a file the
4345
- * person did not ask for, and nothing is written twice.
4362
+ * One place, because a failure is output as much as a result is: the exit code
4363
+ * a caller may branch on, the sentence a person reads, and the chain underneath
4364
+ * it that says what actually went wrong.
4346
4365
  */
4347
- /** The markers that make the instruction block something this tool can find again. */
4348
- const INSTRUCTION_START = "<!-- KANBO_START -->";
4349
- const INSTRUCTION_END = "<!-- KANBO_END -->";
4350
- /** The files an agent reads its standing instructions from. */
4351
- const INSTRUCTION_FILES = {
4352
- claude: "CLAUDE.md",
4353
- agents: "AGENTS.md"
4354
- };
4355
- /** The tools that can be told about the board's MCP server. */
4356
- const MCP_CLIENTS = [
4357
- "claude",
4358
- "codex",
4359
- "cursor"
4360
- ];
4361
- /** The name the board's MCP server is registered under, in every tool. */
4362
- const MCP_SERVER_NAME = "kanbo";
4366
+ /** How far down a chain of causes is still worth reading. */
4367
+ const CAUSE_DEPTH_LIMIT = 5;
4363
4368
  /**
4364
- * What a person is told when an external board is named and nothing says which
4365
- * workspace it is about. There is no table to look the answer up in, and a card
4366
- * numbered under a guess carries that guess for the rest of its life.
4369
+ * What to say about a failure, and what to leave with.
4370
+ *
4371
+ * A `CliError` already carries both. A `BoardError` is a rule of the board the
4372
+ * caller broke: its code is the honest name of what happened, and its details
4373
+ * say which value did it, so both are printed rather than translated into a
4374
+ * sentence that could drift away from the rule. The one exception is a card
4375
+ * held at a column's door: its details are a list, and a person reads it as
4376
+ * one line per rule the card does not meet yet (`describeColumnRefusal`).
4377
+ *
4378
+ * Whatever it is, its causes are printed under it. A driver's failure arrives
4379
+ * wrapped — `Failed query: create schema …` with the connection refused
4380
+ * underneath — and the wrapper alone tells a person nothing they can act on.
4381
+ * Every line goes through the masking on its way out, because a message this
4382
+ * tool did not write is free to carry the whole connection string in it.
4367
4383
  */
4368
- const EXTERNAL_WORKSPACE_MESSAGE = "Could not tell which workspace this project is, and an external board holds no workspaces table to look it up in. Run this again with --workspace <id> --identifier <KEY>, or with --db (or KANBO_DB_PATH) naming a host database that knows this project.";
4369
- /** What a person is told when a board file of the project's own and an external one are both named at once. */
4370
- const FILE_AND_DATABASE_URL_MESSAGE = "One board at a time: pass --file for a board file of this project's own or --database-url for an external board, not both.";
4384
+ function describeFailure(error) {
4385
+ const { message, exitCode } = readFailure(error);
4386
+ return {
4387
+ message: maskDatabaseUrls([message, ...describeCauses(error, 1)].join("\n")),
4388
+ exitCode
4389
+ };
4390
+ }
4391
+ function readFailure(error) {
4392
+ if (error instanceof CliError) return {
4393
+ message: error.message,
4394
+ exitCode: error.exitCode
4395
+ };
4396
+ if (error instanceof require_migrations_path.BoardError && error.code === "board_column_rules_unmet") return {
4397
+ message: describeColumnRefusal(error.details),
4398
+ exitCode: 1
4399
+ };
4400
+ if (error instanceof require_migrations_path.BoardError) {
4401
+ const details = error.details ? ` ${JSON.stringify(error.details)}` : "";
4402
+ return {
4403
+ message: `${error.code}${details}`,
4404
+ exitCode: 1
4405
+ };
4406
+ }
4407
+ return {
4408
+ message: error instanceof Error ? error.message : String(error),
4409
+ exitCode: 1
4410
+ };
4411
+ }
4371
4412
  /**
4372
- * What a person is told when their agents are about to reach the board as its
4373
- * owner. Not a refusal: the board works this way from the first minute, and the
4374
- * rule an agent is missing is one the database keeps, not this command.
4413
+ * The chain under a failure, one indented line per link.
4414
+ *
4415
+ * Bounded, because `cause` is an ordinary property and nothing stops it
4416
+ * pointing back at the error it came from; a chain longer than this has stopped
4417
+ * explaining anything anyway.
4375
4418
  */
4376
- const AGENT_URL_MISSING_WARNING = "kanbo: agents in this project will reach the board with the connection string you gave, which owns it. Until you run \"kanbo roles apply\" and record the agent role's own string with --agent-url, an agent here can approve its own work.";
4419
+ function describeCauses(error, depth) {
4420
+ const cause = error instanceof Error ? error.cause : void 0;
4421
+ if (cause === void 0 || cause === null || depth > CAUSE_DEPTH_LIMIT) return [];
4422
+ const message = cause instanceof Error ? cause.message : String(cause);
4423
+ return [`${" ".repeat(depth)}Caused by: ${message}`, ...describeCauses(cause, depth + 1)];
4424
+ }
4425
+ //#endregion
4426
+ //#region src/cli/setup/instructions.ts
4377
4427
  /**
4378
- * The Codex table this command writes, in every spelling TOML allows for the
4379
- * key: bare, double-quoted and single-quoted all name the same server, and a
4380
- * check that only knew the bare one would write a second table beside a
4381
- * registration the person already has.
4428
+ * The instruction block kanbo writes into an agent's standing instructions,
4429
+ * and the three things done to it: written, found again, taken out.
4430
+ *
4431
+ * Two versions of the block exist. The project block says "this project is on
4432
+ * a board"; the global one, for a person's user-level file, says "a project
4433
+ * with `.kanbo/` is on a board" and binds nothing — one user file serves every
4434
+ * project, and most of them have no board.
4382
4435
  */
4383
- const CODEX_TABLE_PATTERN = new RegExp(String.raw`^\s*\[\s*mcp_servers\s*\.\s*(?:${MCP_SERVER_NAME}|"${MCP_SERVER_NAME}"|'${MCP_SERVER_NAME}')\s*\]`, "m");
4436
+ /** The markers that make the block something this tool can find again. */
4437
+ const INSTRUCTION_START = "<!-- KANBO_START -->";
4438
+ const INSTRUCTION_END = "<!-- KANBO_END -->";
4384
4439
  /**
4385
- * What an agent working in this project is told about the board.
4386
- *
4387
- * It says what to run and when, and nothing about how to do the work: the
4388
- * method is the person's own instructions to their agent, and the board only
4389
- * insists on the part that keeps the card honest. The rules are the sentences
4440
+ * What to run and when, and nothing about how to do the work: the method is
4441
+ * the person's own instructions to their agent. The rules are the sentences
4390
4442
  * `ops/agent-rules.ts` holds for every surface, with the command beside each.
4391
4443
  */
4392
- const INSTRUCTION_BLOCK = [
4393
- INSTRUCTION_START,
4394
- "## Kanbo board",
4395
- "",
4396
- "Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
4397
- "",
4444
+ const RULE_LINES = [
4398
4445
  "- Take a card from `kanbo ready`.",
4399
4446
  `- ${MOVE_CARD_RULE} (\`kanbo card move <id> <column>\`)`,
4400
4447
  `- ${STATUS_LINE_RULE} (\`kanbo card status-line <id> --text "..."\`)`,
@@ -4403,114 +4450,1067 @@ const INSTRUCTION_BLOCK = [
4403
4450
  `- ${WAIT_FOR_PERSON_RULE} (\`kanbo card wait-approval <id>\`)`,
4404
4451
  `- ${PERSON_ONLY_RULE}`,
4405
4452
  `- ${OWN_SESSION_RULE} (\`kanbo run start <id> --agent <name> --session <ref>\`)`,
4406
- "- Run `kanbo capabilities` for the full list of tools, commands and rules.",
4453
+ "- Run `kanbo capabilities` for the full list of tools, commands and rules."
4454
+ ];
4455
+ /** The block `kanbo init --instructions …` writes into a project's own file. */
4456
+ const INSTRUCTION_BLOCK = [
4457
+ INSTRUCTION_START,
4458
+ "## Kanbo board",
4459
+ "",
4460
+ "Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
4461
+ "",
4462
+ ...RULE_LINES,
4463
+ INSTRUCTION_END
4464
+ ].join("\n");
4465
+ /** The block `kanbo init --global` writes into a person's user-level file. */
4466
+ const GLOBAL_INSTRUCTION_BLOCK = [
4467
+ INSTRUCTION_START,
4468
+ "## Kanbo boards",
4469
+ "",
4470
+ "A project with a `.kanbo/` directory tracks its work on a kanbo board. In such a project, before starting a task run `kanbo prime` (or call the `kanbo_prime` tool) — it prints the board's columns and what each one means. A project without `.kanbo/` has no board: ignore this section there, and do not create one — a person sets a board up with `kanbo init`.",
4471
+ "",
4472
+ ...RULE_LINES,
4407
4473
  INSTRUCTION_END
4408
4474
  ].join("\n");
4409
- function registerInitCommand(program) {
4410
- program.command("init").description("bind this project to a board, and tell its agents about it").option("--db <path>", "host database file to read this project's workspace from").option("--database-url <url>", "external Postgres board to bind this project to").option("--file [path]", `use a board file of this project's own instead of a host database or an external board (default ${DEFAULT_BOARD_FILE_PATH})`).option("--workspace <nameOrId>", "workspace this project is").option("--json <fields>", "print only these comma-separated fields, as JSON").option("--format <format>", "output format: json or pretty").option("--board <id>", "the board inside the workspace, when it has more than one").option("--identifier <key>", "what this workspace's card keys start with, for an external board").option("--agent-url <url>", "the connection string agents get, when it is not the one above").option("--instructions <file>", "where to write the instruction block: claude, agents or none", parseInstructions).option("--mcp <clients>", `register the board's MCP server with ${MCP_CLIENTS.join(", ")}`, parseMcpClients).option("--yes", "take the defaults instead of asking").action(async (options) => {
4411
- const projectDir = process.cwd();
4412
- if (options.file !== void 0) {
4413
- await initOwnFile(projectDir, options);
4414
- return;
4415
- }
4416
- const target = resolveInitTarget(projectDir, options);
4417
- if (target.kind === "postgres") {
4418
- await initExternalBoard(projectDir, target, options);
4419
- return;
4420
- }
4421
- await runBoardCommand(options, "read", async (session) => await initProject(projectDir, target, session.workspace, options));
4422
- });
4423
- }
4424
4475
  /**
4425
- * Which board `init` is binding this project to.
4476
+ * The block in that file, once.
4426
4477
  *
4427
- * `--db` means something else here than it does in every other command: it
4428
- * names the host database this one reads the workspace off, not the
4429
- * board being bound. So the external board is asked for on its own — the flag,
4430
- * the environment, then the binding this project already has — and only when
4431
- * there is none is the board file itself the board. Always the person's
4432
- * connection string: binding a project is not something an agent does.
4478
+ * A file that already carries the markers has its block replaced rather than
4479
+ * appended to, so a current block changes nothing at all and an outdated one is
4480
+ * brought forward. Everything outside the markers is the person's own writing
4481
+ * and is never touched.
4433
4482
  */
4434
- function resolveInitTarget(projectDir, options) {
4435
- const url = resolveDatabaseUrl({
4436
- explicitUrl: options.databaseUrl,
4437
- cwd: projectDir,
4438
- asAgent: false
4439
- });
4440
- return url ? {
4441
- kind: "postgres",
4442
- url
4443
- } : resolveDbTarget({
4444
- explicitPath: options.db,
4445
- cwd: projectDir
4446
- });
4483
+ function planInstructionBlock(path, block) {
4484
+ const existing = readText(path);
4485
+ const next = withBlock(existing, block);
4486
+ return {
4487
+ path,
4488
+ next: existing === next ? null : next
4489
+ };
4447
4490
  }
4448
4491
  /**
4449
- * Bind a project to an external board.
4450
- *
4451
- * The board is never opened. `init` is what a person runs *before*
4452
- * `kanbo migrate`, and a database that holds no board yet is not a reason to
4453
- * refuse to write down where it will be — nor could it answer the one question
4454
- * that matters here, since it has no `workspaces` table to be asked.
4492
+ * The file without the block, or no change when it carries none. The file
4493
+ * itself stays even when nothing is left in it: kanbo does not record which
4494
+ * files it created, and a person's file is not this tool's to delete.
4455
4495
  */
4456
- async function initExternalBoard(projectDir, target, options) {
4457
- readFormat(options);
4458
- printResult(await initProject(projectDir, target, await resolveExternalWorkspace(projectDir, options), options), options);
4496
+ function planInstructionBlockRemoval(path) {
4497
+ const existing = readText(path);
4498
+ const bounds = existing === null ? null : findBlock(existing);
4499
+ if (existing === null || bounds === null) return {
4500
+ path,
4501
+ next: null
4502
+ };
4503
+ const joined = [existing.slice(0, bounds.start).replace(/\s*$/, ""), existing.slice(bounds.end).trim()].filter((part) => part.length > 0).join("\n\n");
4504
+ return {
4505
+ path,
4506
+ next: joined ? `${joined}\n` : ""
4507
+ };
4459
4508
  }
4509
+ /** The block a file carries, markers included, or `null` when it carries none. */
4510
+ function readInstructionBlock(path) {
4511
+ const existing = readText(path);
4512
+ const bounds = existing === null ? null : findBlock(existing);
4513
+ return existing === null || bounds === null ? null : existing.slice(bounds.start, bounds.end);
4514
+ }
4515
+ function withBlock(existing, block) {
4516
+ if (existing === null || !existing.trim()) return `${block}\n`;
4517
+ const bounds = findBlock(existing);
4518
+ if (bounds) return existing.slice(0, bounds.start) + block + existing.slice(bounds.end);
4519
+ return `${existing.replace(/\s*$/, "")}\n\n${block}\n`;
4520
+ }
4521
+ function findBlock(text) {
4522
+ const start = text.indexOf(INSTRUCTION_START);
4523
+ const end = text.indexOf(INSTRUCTION_END);
4524
+ return start >= 0 && end > start ? {
4525
+ start,
4526
+ end: end + 18
4527
+ } : null;
4528
+ }
4529
+ function readText(path) {
4530
+ return (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : null;
4531
+ }
4532
+ //#endregion
4533
+ //#region src/cli/setup/paths.ts
4460
4534
  /**
4461
- * Create and use a board file of this project's own — `.kanbo/board.db` by
4462
- * default — with no host app and no Postgres anywhere near it.
4463
- *
4464
- * The file is migrated with this package's own chain right here, because
4465
- * nothing else ever will: a host app migrates its own database and an
4466
- * external database is migrated by `kanbo migrate`, but a file of this
4467
- * package's own has no other owner to do it later — `kanbo migrate` runs the
4468
- * same chain again afterwards, and idempotently, for the day it falls behind a
4469
- * newer build (ruling 5-5). The marker written right after is what every later
4470
- * command reads to know not to look for a host `workspaces` table in it
4471
- * (ruling 5-4).
4535
+ * Where each agent tool keeps the files kanbo writes into, for one project and
4536
+ * for the person's whole machine.
4472
4537
  *
4473
- * The workspace is settled before a single byte is written, and nothing here
4474
- * opens a host database at all (ruling 5-9): this is the one command that has
4475
- * to work on a machine with nothing else installed, and the answer it needs —
4476
- * an id and a card-key stem — is either typed, already in the binding, or the
4477
- * folder's own name. Asking a host database first would turn `--workspace <id>`
4478
- * into a refusal on every machine that has one, and leave a board file behind
4479
- * with no binding beside it.
4538
+ * Resolved on every call rather than at import, so `HOME` and `CODEX_HOME` set
4539
+ * by a test (or a person) are the ones obeyed. Each path was checked against
4540
+ * the tool's own documentation; `docs/agents.md` lists the pages.
4480
4541
  */
4481
- async function initOwnFile(projectDir, options) {
4482
- readFormat(options);
4483
- if (options.databaseUrl) throw new CliError(1, FILE_AND_DATABASE_URL_MESSAGE);
4484
- const relativePath = typeof options.file === "string" && options.file.trim() ? options.file.trim() : DEFAULT_BOARD_FILE_PATH;
4485
- const absolutePath = (0, node_path.resolve)(projectDir, relativePath);
4486
- const workspace = resolveOwnFileWorkspace(projectDir, options);
4487
- await createOwnBoardFile(absolutePath);
4488
- ignoreBoardFile(projectDir, absolutePath);
4489
- printResult(await initProject(projectDir, {
4490
- kind: "sqlite",
4491
- path: absolutePath,
4492
- owner: "kanbo"
4493
- }, workspace, options), options);
4542
+ /** The tools whose user-level instruction file `kanbo init --global` can write. */
4543
+ const GLOBAL_INSTRUCTION_CLIENTS = [
4544
+ "claude",
4545
+ "codex",
4546
+ "gemini"
4547
+ ];
4548
+ /** The files a project's agents read their standing instructions from. */
4549
+ const PROJECT_INSTRUCTION_FILES = {
4550
+ claude: "CLAUDE.md",
4551
+ agents: "AGENTS.md"
4552
+ };
4553
+ /** The tools that can be told about the board's MCP server. */
4554
+ const MCP_CLIENTS = [
4555
+ "claude",
4556
+ "codex",
4557
+ "cursor"
4558
+ ];
4559
+ /** Where Codex keeps its global files: `$CODEX_HOME`, else `~/.codex`. */
4560
+ function codexHome() {
4561
+ return process.env.CODEX_HOME?.trim() || (0, node_path.join)((0, node_os.homedir)(), ".codex");
4562
+ }
4563
+ /** The user-level instruction file of each tool. */
4564
+ function globalInstructionPath(client) {
4565
+ switch (client) {
4566
+ case "claude": return (0, node_path.join)((0, node_os.homedir)(), ".claude", "CLAUDE.md");
4567
+ case "codex": return (0, node_path.join)(codexHome(), "AGENTS.md");
4568
+ case "gemini": return (0, node_path.join)((0, node_os.homedir)(), ".gemini", "GEMINI.md");
4569
+ }
4494
4570
  }
4495
4571
  /**
4496
- * Put a migrated, marked board at this path — or leave the folder exactly as it
4497
- * was found (ruling 5-9).
4498
- *
4499
- * A new board is built beside its own path and moved onto it at the end, so a
4500
- * migration that fails half-way leaves no half-built file for the next command
4501
- * to open and believe in. `rename` within one directory is the filesystem's own
4502
- * atomic swap; a temporary name elsewhere would be a copy across devices and
4503
- * not atomic at all. A file that is already there is a different question — it
4504
- * may hold this project's cards — so it is migrated in place, idempotently,
4505
- * which is what makes a second `kanbo init --file` a no-op.
4506
- *
4507
- * A file that is there and is *not* this package's is refused, for the reason
4508
- * any careful host refuses it: migrating a board into somebody's own
4509
- * database leaves it readable but contaminated, and only a different path
4510
- * helps.
4572
+ * The user-level MCP configuration of each tool. Claude Code's is
4573
+ * `~/.claude.json`, its own state file: kanbo reads it but never writes it —
4574
+ * `claude mcp add` and `claude mcp remove` do.
4511
4575
  */
4512
- async function createOwnBoardFile(absolutePath) {
4513
- (0, node_fs.mkdirSync)((0, node_path.dirname)(absolutePath), { recursive: true });
4576
+ function globalMcpConfigPath(client) {
4577
+ switch (client) {
4578
+ case "claude": return (0, node_path.join)((0, node_os.homedir)(), ".claude.json");
4579
+ case "codex": return (0, node_path.join)(codexHome(), "config.toml");
4580
+ case "cursor": return (0, node_path.join)((0, node_os.homedir)(), ".cursor", "mcp.json");
4581
+ }
4582
+ }
4583
+ /** The project-level MCP configuration of each tool. */
4584
+ function projectMcpConfigPath(projectDir, client) {
4585
+ switch (client) {
4586
+ case "claude": return (0, node_path.join)(projectDir, ".mcp.json");
4587
+ case "codex": return (0, node_path.join)(projectDir, ".codex", "config.toml");
4588
+ case "cursor": return (0, node_path.join)(projectDir, ".cursor", "mcp.json");
4589
+ }
4590
+ }
4591
+ /**
4592
+ * Every executable of that name on `PATH`, in the order a shell would find
4593
+ * them. A path with a separator in it is looked at directly, as a shell does.
4594
+ */
4595
+ function findAllOnPath(command) {
4596
+ if ((0, node_path.isAbsolute)(command) || command.includes("/")) return isExecutableFile(command) ? [command] : [];
4597
+ const extensions = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
4598
+ const found = [];
4599
+ for (const directory of (process.env.PATH ?? "").split(node_path.delimiter).filter(Boolean)) for (const extension of extensions) {
4600
+ const candidate = (0, node_path.join)(directory, command + extension);
4601
+ if (isExecutableFile(candidate) && !found.includes(candidate)) found.push(candidate);
4602
+ }
4603
+ return found;
4604
+ }
4605
+ /** The executable a shell would start for that command, or `null` when there is none. */
4606
+ function findOnPath(command) {
4607
+ return findAllOnPath(command)[0] ?? null;
4608
+ }
4609
+ function isExecutableFile(path) {
4610
+ try {
4611
+ if (!(0, node_fs.statSync)(path).isFile()) return false;
4612
+ (0, node_fs.accessSync)(path, node_fs.constants.X_OK);
4613
+ return true;
4614
+ } catch {
4615
+ return false;
4616
+ }
4617
+ }
4618
+ //#endregion
4619
+ //#region src/cli/setup/mcp-config.ts
4620
+ /**
4621
+ * The board's MCP server entry, in the two formats the tools keep it in: the
4622
+ * `mcpServers` JSON map Claude Code (`.mcp.json`) and Cursor (`mcp.json`) read,
4623
+ * and the `[mcp_servers.<name>]` TOML table Codex reads (`config.toml`). Each
4624
+ * is planned before anything is written, found again by the doctor, and taken
4625
+ * out by `kanbo uninstall` without touching any other server.
4626
+ */
4627
+ /** The name the board's MCP server is registered under, in every tool. */
4628
+ const MCP_SERVER_NAME = "kanbo";
4629
+ /** How every registration kanbo writes starts the server. */
4630
+ const MCP_COMMAND = "kanbo";
4631
+ const MCP_ARGS = ["mcp"];
4632
+ /** The line that registers the server with Claude Code for every project of this user. */
4633
+ const CLAUDE_USER_ADD = [
4634
+ "claude",
4635
+ "mcp",
4636
+ "add",
4637
+ "--scope",
4638
+ "user",
4639
+ MCP_SERVER_NAME,
4640
+ "--",
4641
+ MCP_COMMAND,
4642
+ ...MCP_ARGS
4643
+ ];
4644
+ /** The line that takes that registration out again. */
4645
+ const CLAUDE_USER_REMOVE = [
4646
+ "claude",
4647
+ "mcp",
4648
+ "remove",
4649
+ MCP_SERVER_NAME,
4650
+ "--scope",
4651
+ "user"
4652
+ ];
4653
+ /**
4654
+ * A Codex table header naming this server or one of its subtables
4655
+ * (`[mcp_servers.kanbo.env]`), in every spelling TOML allows for the key: bare,
4656
+ * double-quoted and single-quoted all name the same server, and a check that
4657
+ * only knew the bare one would write a second table beside a registration the
4658
+ * person already has.
4659
+ */
4660
+ const CODEX_HEADER_PATTERN = new RegExp(String.raw`^\s*\[\s*mcp_servers\s*\.\s*(?:${MCP_SERVER_NAME}|"${MCP_SERVER_NAME}"|'${MCP_SERVER_NAME}')\s*(\.[^\]]*)?\]`);
4661
+ /** Any table header, which is where a table's keys end. */
4662
+ const TOML_HEADER_PATTERN = /^\s*\[/;
4663
+ /** A `command = "..."` line, either string spelling. */
4664
+ const TOML_COMMAND_PATTERN = /^\s*command\s*=\s*(?:"([^"]*)"|'([^']*)')/m;
4665
+ /**
4666
+ * The `mcpServers` map, merged rather than replaced: the file may already
4667
+ * describe servers that have nothing to do with this board. A `kanbo` entry
4668
+ * that is already there belongs to the person — it may point at a different
4669
+ * binary or carry arguments of its own — and is left as it is.
4670
+ */
4671
+ function planJsonMcpServer(path) {
4672
+ const existing = readJsonConfig(path);
4673
+ if (existing.mcpServers && "kanbo" in existing.mcpServers) return {
4674
+ path,
4675
+ next: null
4676
+ };
4677
+ const next = {
4678
+ ...existing,
4679
+ mcpServers: {
4680
+ ...existing.mcpServers,
4681
+ [MCP_SERVER_NAME]: {
4682
+ type: "stdio",
4683
+ command: MCP_COMMAND,
4684
+ args: [...MCP_ARGS]
4685
+ }
4686
+ }
4687
+ };
4688
+ return {
4689
+ path,
4690
+ next: `${JSON.stringify(next, null, 2)}\n`
4691
+ };
4692
+ }
4693
+ /**
4694
+ * The same file without the `kanbo` entry. Everything else stays, the
4695
+ * `mcpServers` object included when nothing else is left in it: kanbo does not
4696
+ * record which files it created, so it leaves the structure rather than guess.
4697
+ * A file it cannot read as a JSON object is not touched at all.
4698
+ */
4699
+ function planJsonMcpServerRemoval(path) {
4700
+ const existing = readJsonIfObject(path);
4701
+ if (!existing || !isPlainObject(existing.mcpServers) || !("kanbo" in existing.mcpServers)) return {
4702
+ path,
4703
+ next: null
4704
+ };
4705
+ const { [MCP_SERVER_NAME]: _removed, ...others } = existing.mcpServers;
4706
+ return {
4707
+ path,
4708
+ next: `${JSON.stringify({
4709
+ ...existing,
4710
+ mcpServers: others
4711
+ }, null, 2)}\n`
4712
+ };
4713
+ }
4714
+ /**
4715
+ * The command a JSON registration starts the server with: `undefined` when the
4716
+ * file registers no `kanbo` server, `null` when it does but names no command
4717
+ * (a URL server, say).
4718
+ */
4719
+ function readJsonMcpCommand(path) {
4720
+ const servers = readJsonIfObject(path)?.mcpServers;
4721
+ if (!isPlainObject(servers) || !isPlainObject(servers["kanbo"])) return;
4722
+ const command = servers[MCP_SERVER_NAME].command;
4723
+ return typeof command === "string" && command.trim() ? command.trim() : null;
4724
+ }
4725
+ /** Codex keeps its servers in TOML tables, so the table is appended rather than merged. */
4726
+ function planCodexMcpServer(path) {
4727
+ const existing = (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : "";
4728
+ if (findCodexSections(existing).length > 0) return {
4729
+ path,
4730
+ next: null
4731
+ };
4732
+ const table = `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "${MCP_COMMAND}"\nargs = ${JSON.stringify(MCP_ARGS)}\n`;
4733
+ return {
4734
+ path,
4735
+ next: existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n${table}` : table
4736
+ };
4737
+ }
4738
+ /**
4739
+ * The same file without the `kanbo` table and its subtables. A table runs from
4740
+ * its header to the next header, so every other table — and every line before
4741
+ * the first — is kept byte for byte.
4742
+ */
4743
+ function planCodexMcpServerRemoval(path) {
4744
+ if (!(0, node_fs.existsSync)(path)) return {
4745
+ path,
4746
+ next: null
4747
+ };
4748
+ const existing = (0, node_fs.readFileSync)(path, "utf8");
4749
+ const sections = findCodexSections(existing);
4750
+ if (sections.length === 0) return {
4751
+ path,
4752
+ next: null
4753
+ };
4754
+ const lines = existing.split("\n");
4755
+ const removed = new Set(sections.flatMap(({ start, end }) => range(start, end)));
4756
+ const next = lines.filter((_, index) => !removed.has(index)).join("\n").replace(/^\n+/, "");
4757
+ return {
4758
+ path,
4759
+ next: next.trim() ? `${next.replace(/\s*$/, "")}\n` : ""
4760
+ };
4761
+ }
4762
+ /** The command the Codex table starts the server with, with the same `undefined`/`null` meaning as the JSON one. */
4763
+ function readCodexMcpCommand(path) {
4764
+ if (!(0, node_fs.existsSync)(path)) return;
4765
+ const text = (0, node_fs.readFileSync)(path, "utf8");
4766
+ const main = findCodexSections(text).find((section) => section.main);
4767
+ if (!main) return;
4768
+ const body = text.split("\n").slice(main.start, main.end).join("\n");
4769
+ const match = TOML_COMMAND_PATTERN.exec(body);
4770
+ return match ? match[1] ?? match[2] ?? null : null;
4771
+ }
4772
+ /** Is the server registered with Claude Code for every project of this user (`~/.claude.json`, read only)? */
4773
+ function readClaudeUserMcpCommand() {
4774
+ return readJsonMcpCommand(globalMcpConfigPath("claude"));
4775
+ }
4776
+ /**
4777
+ * Run `claude mcp …` when Claude Code is here to run it, and hand the line to
4778
+ * the person when it is not — or when it failed, with the first line it said.
4779
+ * `~/.claude.json` is Claude Code's own state file and is never edited by hand.
4780
+ */
4781
+ function runClaudeMcp(argv) {
4782
+ const command = argv.join(" ");
4783
+ const executable = findOnPath(argv[0]);
4784
+ if (!executable) return {
4785
+ state: "manual",
4786
+ command,
4787
+ reason: `${argv[0]} is not on PATH`
4788
+ };
4789
+ const result = (0, node_child_process.spawnSync)(executable, argv.slice(1), {
4790
+ encoding: "utf8",
4791
+ timeout: 6e4
4792
+ });
4793
+ if (result.status === 0) return {
4794
+ state: "ran",
4795
+ command
4796
+ };
4797
+ const said = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim().split("\n")[0];
4798
+ return {
4799
+ state: "manual",
4800
+ command,
4801
+ reason: result.error ? result.error.message : `${argv[0]} exited ${result.status ?? "on a signal"}${said ? `: ${said}` : ""}`
4802
+ };
4803
+ }
4804
+ /** The line ranges (`end` exclusive) of the `kanbo` table and its subtables. */
4805
+ function findCodexSections(text) {
4806
+ const lines = text.split("\n");
4807
+ const sections = [];
4808
+ for (let index = 0; index < lines.length; index += 1) {
4809
+ const match = CODEX_HEADER_PATTERN.exec(lines[index]);
4810
+ if (!match) continue;
4811
+ let end = index + 1;
4812
+ while (end < lines.length && !TOML_HEADER_PATTERN.test(lines[end])) end += 1;
4813
+ sections.push({
4814
+ start: index,
4815
+ end,
4816
+ main: match[1] === void 0
4817
+ });
4818
+ index = end - 1;
4819
+ }
4820
+ return sections;
4821
+ }
4822
+ function range(start, end) {
4823
+ return Array.from({ length: end - start }, (_, offset) => start + offset);
4824
+ }
4825
+ /**
4826
+ * The tool's configuration file as an object to merge into — or a refusal.
4827
+ *
4828
+ * A file that is valid JSON but not an object (a list, a string, `null`) is
4829
+ * something this command has no idea how to merge into, and spreading it would
4830
+ * quietly throw the person's file away. The file is left exactly as it is and
4831
+ * they are told which one to look at.
4832
+ */
4833
+ function readJsonConfig(path) {
4834
+ if (!(0, node_fs.existsSync)(path)) return {};
4835
+ let parsed;
4836
+ try {
4837
+ parsed = JSON.parse((0, node_fs.readFileSync)(path, "utf8"));
4838
+ } catch {
4839
+ throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo init again.`);
4840
+ }
4841
+ if (!isPlainObject(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo init again.`);
4842
+ if (parsed.mcpServers !== void 0 && !isPlainObject(parsed.mcpServers)) throw new CliError(1, `${path} has an "mcpServers" that is not an object. Fix it, then run kanbo init again.`);
4843
+ return parsed;
4844
+ }
4845
+ /** The file as a JSON object, or `null` when it is missing or anything else — for reading and removal, which never refuse. */
4846
+ function readJsonIfObject(path) {
4847
+ if (!(0, node_fs.existsSync)(path)) return null;
4848
+ try {
4849
+ const parsed = JSON.parse((0, node_fs.readFileSync)(path, "utf8"));
4850
+ return isPlainObject(parsed) ? parsed : null;
4851
+ } catch {
4852
+ return null;
4853
+ }
4854
+ }
4855
+ function isPlainObject(value) {
4856
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4857
+ }
4858
+ //#endregion
4859
+ //#region src/cli/doctor.ts
4860
+ /** How long an external board may take to answer before the doctor gives up on it. */
4861
+ const POSTGRES_TIMEOUT_MS = 5e3;
4862
+ async function collectDoctorFindings(input) {
4863
+ const findings = [{
4864
+ check: "version",
4865
+ status: "ok",
4866
+ detail: `kanbo ${KANBO_PACKAGE_VERSION}${input.self ? ` (${input.self})` : ""}`
4867
+ }, checkPath(input.self)];
4868
+ const project = findKanboProject(input.cwd);
4869
+ findings.push(checkProject(project));
4870
+ const target = project?.binding ? resolveTarget(project.root) : null;
4871
+ if (project?.binding && !project.binding.databaseUrl) findings.push(await checkSqlite());
4872
+ if (project?.binding && findings.every((finding) => finding.check !== "sqlite" || finding.status === "ok")) findings.push(...await checkBoard(target));
4873
+ findings.push(...checkInstructions(project?.root ?? null));
4874
+ const registrations = findRegistrations(project?.root ?? null);
4875
+ findings.push(...checkRegistrations(registrations));
4876
+ if (project?.binding && !(target instanceof Error)) findings.push(await checkHandshake(registrations, project.root, input.handshakeTimeoutMs));
4877
+ findings.push(checkActor());
4878
+ return findings;
4879
+ }
4880
+ /** Is the `kanbo` a client would start this one? */
4881
+ function checkPath(self) {
4882
+ const all = findAllOnPath(MCP_COMMAND);
4883
+ if (all.length === 0) return {
4884
+ check: "path",
4885
+ status: "warn",
4886
+ detail: "No kanbo on PATH: a shell or MCP client that runs `kanbo` will not find it.",
4887
+ fix: "Install kanbo globally (see docs/installation.md), or put the directory that holds it on PATH."
4888
+ };
4889
+ const first = all[0];
4890
+ if (self && realPath(first) !== realPath(self)) return {
4891
+ check: "path",
4892
+ status: "warn",
4893
+ detail: `${first} comes first on PATH and is not this kanbo (${self}): agents and MCP clients start that one.`,
4894
+ fix: "Remove the other install, or put this one first on PATH."
4895
+ };
4896
+ return {
4897
+ check: "path",
4898
+ status: "ok",
4899
+ detail: `kanbo on PATH: ${first}`
4900
+ };
4901
+ }
4902
+ /** The nearest folder at or above `cwd` with a `.kanbo/` directory, and its binding. */
4903
+ function findKanboProject(cwd) {
4904
+ let directory = (0, node_path.resolve)(cwd);
4905
+ while (true) {
4906
+ const kanboDirectory = (0, node_path.join)(directory, (0, node_path.dirname)(BINDING_FILE_PATH));
4907
+ if ((0, node_fs.existsSync)(kanboDirectory) && (0, node_fs.statSync)(kanboDirectory).isDirectory()) return {
4908
+ root: directory,
4909
+ binding: readBinding((0, node_path.join)(directory, BINDING_FILE_PATH))
4910
+ };
4911
+ const parent = (0, node_path.dirname)(directory);
4912
+ if (parent === directory) return null;
4913
+ directory = parent;
4914
+ }
4915
+ }
4916
+ function checkProject(project) {
4917
+ if (!project) return {
4918
+ check: "binding",
4919
+ status: "warn",
4920
+ detail: "Not inside a kanbo project (no .kanbo/ here or above); board checks are skipped.",
4921
+ fix: "Run kanbo init --file in a project to give it a board."
4922
+ };
4923
+ const path = (0, node_path.join)(project.root, BINDING_FILE_PATH);
4924
+ if (!project.binding) return {
4925
+ check: "binding",
4926
+ status: "fail",
4927
+ detail: (0, node_fs.existsSync)(path) ? `${path} is not a binding this build can read.` : `${(0, node_path.join)(project.root, (0, node_path.dirname)(BINDING_FILE_PATH))} has no ${BINDING_FILE_PATH.split(/[\\/]/).pop()}: nothing says which board this project is on.`,
4928
+ fix: `Run kanbo init (with --file, or the --database-url you used) in ${project.root} to bind it again.`
4929
+ };
4930
+ return {
4931
+ check: "binding",
4932
+ status: "ok",
4933
+ detail: `${path}: workspace ${project.binding.workspaceId}`
4934
+ };
4935
+ }
4936
+ function resolveTarget(root) {
4937
+ try {
4938
+ return resolveDbTarget({ cwd: root });
4939
+ } catch (error) {
4940
+ return error instanceof Error ? error : new Error(String(error));
4941
+ }
4942
+ }
4943
+ /** The native module a board file needs, loaded on a database of its own in memory. */
4944
+ async function checkSqlite() {
4945
+ try {
4946
+ (await require_migrate.openBoardDatabase(":memory:")).close();
4947
+ return {
4948
+ check: "sqlite",
4949
+ status: "ok",
4950
+ detail: "better-sqlite3 loads."
4951
+ };
4952
+ } catch (error) {
4953
+ return {
4954
+ check: "sqlite",
4955
+ status: "fail",
4956
+ detail: `better-sqlite3 cannot be loaded: ${describeFailure(error).message}`,
4957
+ fix: "Install better-sqlite3 next to kanbo (npm install -g better-sqlite3 for a global kanbo)."
4958
+ };
4959
+ }
4960
+ }
4961
+ /** The board itself: it opens, and its schema is this build's. */
4962
+ async function checkBoard(target) {
4963
+ if (target === null) return [];
4964
+ if (target instanceof Error) return [{
4965
+ check: "board",
4966
+ status: "fail",
4967
+ detail: describeFailure(target).message,
4968
+ fix: "Run kanbo init in the project to bind it to a board."
4969
+ }];
4970
+ if (target.kind === "postgres") return [await checkPostgresBoard(target.url)];
4971
+ const board = await require_migrate.openBoardDatabase(target.path);
4972
+ try {
4973
+ require_migrate.assertBoardSchema(board.database);
4974
+ return [{
4975
+ check: "board",
4976
+ status: "ok",
4977
+ detail: `${target.path} opens; its schema is current.`
4978
+ }];
4979
+ } catch (error) {
4980
+ const outdated = error instanceof require_migrations_path.BoardError && error.code === "board_schema_outdated";
4981
+ const ownFile = target.owner === "kanbo";
4982
+ return [{
4983
+ check: "board",
4984
+ status: "fail",
4985
+ detail: outdated ? `${target.path}: ${ownFile ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE}` : `${target.path}: ${describeFailure(error).message}`,
4986
+ fix: ownFile ? "Run kanbo migrate." : "Start the app this database belongs to, so it applies its migrations."
4987
+ }];
4988
+ } finally {
4989
+ board.close();
4990
+ }
4991
+ }
4992
+ async function checkPostgresBoard(url) {
4993
+ const where = maskDatabaseUrl(url);
4994
+ let timer;
4995
+ const timeout = new Promise((_, reject) => {
4996
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no answer within ${POSTGRES_TIMEOUT_MS / 1e3}s`)), POSTGRES_TIMEOUT_MS);
4997
+ });
4998
+ try {
4999
+ const board = await Promise.race([openPostgresBoard(url), timeout]);
5000
+ try {
5001
+ await Promise.race([assertInstalledBoard(board.database), timeout]);
5002
+ } finally {
5003
+ board.close();
5004
+ }
5005
+ return {
5006
+ check: "board",
5007
+ status: "ok",
5008
+ detail: `${where} answers; its schema is current.`
5009
+ };
5010
+ } catch (error) {
5011
+ return {
5012
+ check: "board",
5013
+ status: "fail",
5014
+ detail: `${where}: ${describeFailure(error).message}`,
5015
+ fix: "Check the connection string, then run kanbo migrate."
5016
+ };
5017
+ } finally {
5018
+ clearTimeout(timer);
5019
+ }
5020
+ }
5021
+ /**
5022
+ * Every instruction block kanbo could have written, compared with the one this
5023
+ * build writes. Only files that carry a block are reported: a file without one
5024
+ * is not a fault, but having no block anywhere is worth a warning.
5025
+ */
5026
+ function checkInstructions(root) {
5027
+ const findings = [];
5028
+ if (root) for (const [target, name] of Object.entries(PROJECT_INSTRUCTION_FILES)) {
5029
+ const path = (0, node_path.join)(root, name);
5030
+ const block = readInstructionBlock(path);
5031
+ if (block !== null) findings.push(blockFinding(path, block === INSTRUCTION_BLOCK, `cd ${root} && kanbo init --instructions ${target} --yes`));
5032
+ }
5033
+ for (const client of GLOBAL_INSTRUCTION_CLIENTS) {
5034
+ const path = globalInstructionPath(client);
5035
+ const block = readInstructionBlock(path);
5036
+ if (block !== null) findings.push(blockFinding(path, block === GLOBAL_INSTRUCTION_BLOCK, `kanbo init --global --instructions ${client}`));
5037
+ }
5038
+ if (findings.length === 0) findings.push({
5039
+ check: "instructions",
5040
+ status: "warn",
5041
+ detail: "No kanbo instruction block in this project's or your own agent files: agents learn the rules only from the MCP server.",
5042
+ fix: "Run kanbo init --instructions claude in the project, or kanbo init --global."
5043
+ });
5044
+ return findings;
5045
+ }
5046
+ function blockFinding(path, current, fix) {
5047
+ return current ? {
5048
+ check: "instructions",
5049
+ status: "ok",
5050
+ detail: `${path}: block is current.`
5051
+ } : {
5052
+ check: "instructions",
5053
+ status: "fail",
5054
+ detail: `${path}: block differs from the one this kanbo writes (stale).`,
5055
+ fix: `Run ${fix} to rewrite it.`
5056
+ };
5057
+ }
5058
+ /** Every `kanbo` registration in the project's and the person's own tool configuration. */
5059
+ function findRegistrations(root) {
5060
+ const found = [];
5061
+ for (const client of MCP_CLIENTS) {
5062
+ const paths = [...root ? [projectMcpConfigPath(root, client)] : [], globalMcpConfigPath(client)];
5063
+ for (const path of paths) {
5064
+ const command = client === "codex" ? readCodexMcpCommand(path) : readJsonMcpCommand(path);
5065
+ if (command !== void 0) found.push({
5066
+ client,
5067
+ path,
5068
+ command
5069
+ });
5070
+ }
5071
+ }
5072
+ return found;
5073
+ }
5074
+ function checkRegistrations(registrations) {
5075
+ if (registrations.length === 0) return [{
5076
+ check: "mcp",
5077
+ status: "warn",
5078
+ detail: "No MCP client has the kanbo server registered.",
5079
+ fix: "Run kanbo init --mcp claude,codex,cursor in the project, or kanbo init --global."
5080
+ }];
5081
+ return registrations.map(({ client, path, command }) => {
5082
+ const check = `mcp:${client}`;
5083
+ if (command === null) return {
5084
+ check,
5085
+ status: "warn",
5086
+ detail: `${path}: the kanbo entry names no command.`,
5087
+ fix: `Set "command" to kanbo in ${path}.`
5088
+ };
5089
+ const resolved = findOnPath(command);
5090
+ return resolved ? {
5091
+ check,
5092
+ status: "ok",
5093
+ detail: `${path}: starts ${command} (${resolved}).`
5094
+ } : {
5095
+ check,
5096
+ status: "fail",
5097
+ detail: `${path}: starts ${command}, which is not on PATH — the client cannot start the server.`,
5098
+ fix: `Install kanbo globally, or change the command in ${path} to a path that exists.`
5099
+ };
5100
+ });
5101
+ }
5102
+ /**
5103
+ * Start the server a client would start, from the project folder, and ask it
5104
+ * `initialize`. It has to call itself `kanbo` and send its instructions.
5105
+ */
5106
+ async function checkHandshake(registrations, root, timeoutMs) {
5107
+ const command = registrations.map((entry) => entry.command && findOnPath(entry.command)).find(Boolean) ?? findOnPath("kanbo");
5108
+ if (!command) return {
5109
+ check: "mcp:handshake",
5110
+ status: "warn",
5111
+ detail: "No kanbo to start, so the MCP server was not tried.",
5112
+ fix: "Put kanbo on PATH."
5113
+ };
5114
+ const transport = new _modelcontextprotocol_sdk_client_stdio_js.StdioClientTransport({
5115
+ command,
5116
+ args: ["mcp"],
5117
+ cwd: root,
5118
+ env: stringEnvironment(),
5119
+ stderr: "pipe"
5120
+ });
5121
+ let stderr = "";
5122
+ transport.stderr?.on("data", (chunk) => {
5123
+ stderr += chunk.toString();
5124
+ });
5125
+ const client = new _modelcontextprotocol_sdk_client_index_js.Client({
5126
+ name: "kanbo-doctor",
5127
+ version: KANBO_PACKAGE_VERSION
5128
+ });
5129
+ try {
5130
+ await client.connect(transport, { timeout: timeoutMs });
5131
+ const name = client.getServerVersion()?.name;
5132
+ const instructions = client.getInstructions();
5133
+ if (name !== "kanbo" || !instructions?.trim()) return {
5134
+ check: "mcp:handshake",
5135
+ status: "fail",
5136
+ detail: `${command} mcp answered as ${name ?? "nothing"}${instructions?.trim() ? "" : ", without instructions"}.`,
5137
+ fix: "Put a current kanbo first on PATH."
5138
+ };
5139
+ return {
5140
+ check: "mcp:handshake",
5141
+ status: "ok",
5142
+ detail: `${command} mcp answers initialize as kanbo, with instructions.`
5143
+ };
5144
+ } catch (error) {
5145
+ return {
5146
+ check: "mcp:handshake",
5147
+ status: "fail",
5148
+ detail: `${command} mcp did not answer initialize: ${stderr.trim().split("\n")[0] || describeFailure(error).message}`,
5149
+ fix: "Run kanbo mcp in the project yourself to see why it does not start."
5150
+ };
5151
+ } finally {
5152
+ await client.close().catch(() => {});
5153
+ }
5154
+ }
5155
+ function checkActor() {
5156
+ if (isAgentShell()) return {
5157
+ check: "actor",
5158
+ status: "warn",
5159
+ detail: "This shell says it belongs to an agent (KANBO_ACTOR_KIND=agent): approve and return refuse here.",
5160
+ fix: "If this is your own terminal, remove KANBO_ACTOR_KIND from your shell profile."
5161
+ };
5162
+ return {
5163
+ check: "actor",
5164
+ status: "ok",
5165
+ detail: "This shell is a person's."
5166
+ };
5167
+ }
5168
+ /** The environment as the SDK wants it: every value a string, so `KANBO_*` reaches the server as it would from a client. */
5169
+ function stringEnvironment() {
5170
+ return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== void 0));
5171
+ }
5172
+ function realPath(path) {
5173
+ try {
5174
+ return (0, node_fs.realpathSync)(path);
5175
+ } catch {
5176
+ return path;
5177
+ }
5178
+ }
5179
+ //#endregion
5180
+ //#region src/cli/commands/doctor.ts
5181
+ /** How long `kanbo mcp` may take to answer `initialize`. */
5182
+ const HANDSHAKE_TIMEOUT_MS = 1e4;
5183
+ const MARKS = {
5184
+ ok: "ok ",
5185
+ warn: "warn",
5186
+ fail: "FAIL"
5187
+ };
5188
+ function registerDoctorCommand(program) {
5189
+ program.command("doctor").description("check this install, this project's binding and board, instruction blocks and MCP registrations").option("--json", "print the findings as JSON").action(async (options) => {
5190
+ const findings = await collectDoctorFindings({
5191
+ cwd: process.cwd(),
5192
+ self: process.argv[1] ?? null,
5193
+ handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS
5194
+ });
5195
+ if (options.json) console.log(JSON.stringify({
5196
+ version: KANBO_PACKAGE_VERSION,
5197
+ findings
5198
+ }, null, 2));
5199
+ else for (const finding of findings) {
5200
+ console.log(`${MARKS[finding.status]} ${finding.check.padEnd(14)} ${finding.detail}`);
5201
+ if (finding.fix) console.log(`${" ".repeat(21)}fix: ${finding.fix}`);
5202
+ }
5203
+ const failed = findings.filter((finding) => finding.status === "fail").length;
5204
+ if (failed > 0) throw new CliError(1, `kanbo doctor: ${failed} check${failed === 1 ? "" : "s"} failed.`);
5205
+ });
5206
+ }
5207
+ //#endregion
5208
+ //#region src/default-board-file-path.ts
5209
+ /**
5210
+ * Where a board file of a project's own goes when nobody names a path —
5211
+ * `kanbo init --file` writes it here, and an app that attaches a project's
5212
+ * board file can import the same constant rather than spelling it again.
5213
+ */
5214
+ const DEFAULT_BOARD_FILE_PATH = (0, node_path.join)(".kanbo", "board.db");
5215
+ //#endregion
5216
+ //#region src/cli/setup/confirm.ts
5217
+ /**
5218
+ * Show a plan of file changes and ask before carrying it out.
5219
+ *
5220
+ * `--yes` is the answer given in advance. A shell with nobody at its terminal
5221
+ * cannot be asked, so it is refused rather than taken as a yes: these commands
5222
+ * write into a person's own configuration, which is the kind of thing a person
5223
+ * says yes to. When the caller asked for machine output, stdout belongs to the
5224
+ * result and the plan goes to stderr.
5225
+ */
5226
+ async function confirmPlan(input) {
5227
+ (input.machineOutput ? console.error : console.log)(input.preview);
5228
+ if (input.yes) return true;
5229
+ if (!isInteractive()) throw new CliError(1, `Nothing was changed. Run ${input.command} again with --yes to do this without being asked.`);
5230
+ return await (await import("@clack/prompts")).confirm({ message: input.question }) === true;
5231
+ }
5232
+ /** Is there a person on the other end of this shell to ask? */
5233
+ function isInteractive() {
5234
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
5235
+ }
5236
+ //#endregion
5237
+ //#region src/cli/setup/file-change.ts
5238
+ /** Write the change, creating the directory it lives in, and say what happened. */
5239
+ function applyFileChange(change) {
5240
+ if (change.next === null) return {
5241
+ path: change.path,
5242
+ state: "unchanged"
5243
+ };
5244
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(change.path), { recursive: true });
5245
+ (0, node_fs.writeFileSync)(change.path, change.next);
5246
+ return {
5247
+ path: change.path,
5248
+ state: "written"
5249
+ };
5250
+ }
5251
+ //#endregion
5252
+ //#region src/cli/commands/init-global.ts
5253
+ /**
5254
+ * `kanbo init --global` — this person's own agent tools, told about kanbo once
5255
+ * for every project.
5256
+ *
5257
+ * It binds no board and creates none: the block it writes says "a project with
5258
+ * `.kanbo/` is on a board", and a project becomes one with a plain `kanbo init`.
5259
+ * The MCP registrations are the same `kanbo mcp` every project registration
5260
+ * starts, which resolves the board from the folder the client starts it in.
5261
+ *
5262
+ * Every change is worked out first and shown; nothing is written until the
5263
+ * person says yes (or said `--yes`), and a file that already says it is
5264
+ * reported `unchanged`.
5265
+ */
5266
+ /** Written when `--instructions` is not given. Gemini is asked for by name. */
5267
+ const DEFAULT_INSTRUCTION_CLIENTS = ["claude", "codex"];
5268
+ /** The flags that are about binding a project, which `--global` does not do. */
5269
+ const PROJECT_ONLY_FLAGS = [
5270
+ ["file", "--file"],
5271
+ ["db", "--db"],
5272
+ ["databaseUrl", "--database-url"],
5273
+ ["workspace", "--workspace"],
5274
+ ["board", "--board"],
5275
+ ["identifier", "--identifier"],
5276
+ ["agentUrl", "--agent-url"]
5277
+ ];
5278
+ async function initGlobal(options) {
5279
+ const format = readFormat(options);
5280
+ const given = PROJECT_ONLY_FLAGS.filter(([key]) => options[key] !== void 0).map(([, flag]) => flag);
5281
+ if (given.length > 0) throw new CliError(1, `--global binds no board; ${given.join(", ")} ${given.length === 1 ? "is" : "are"} for a project. Run kanbo init in the project for that.`);
5282
+ const instructionClients = parseGlobalInstructions(options.instructions);
5283
+ const mcpClients = options.mcp ?? [...MCP_CLIENTS];
5284
+ const instructionChanges = instructionClients.map((client) => ({
5285
+ client,
5286
+ change: planInstructionBlock(globalInstructionPath(client), GLOBAL_INSTRUCTION_BLOCK)
5287
+ }));
5288
+ const fileMcpChanges = mcpClients.filter((client) => client !== "claude").map((client) => ({
5289
+ client,
5290
+ change: planGlobalMcpFile(client)
5291
+ }));
5292
+ const claudeNeeded = mcpClients.includes("claude") && readClaudeUserMcpCommand() === void 0;
5293
+ const pending = [...instructionChanges, ...fileMcpChanges].filter(({ change }) => change.next !== null);
5294
+ let confirmed = true;
5295
+ if (pending.length > 0 || claudeNeeded) confirmed = await confirmPlan({
5296
+ preview: describePlan$1(pending.map(({ change }) => change), claudeNeeded),
5297
+ question: "Make these changes?",
5298
+ yes: options.yes,
5299
+ machineOutput: Boolean(options.json || format),
5300
+ command: "kanbo init --global"
5301
+ });
5302
+ if (!confirmed) throw new CliError(1, "Nothing was changed.");
5303
+ printResult(describeResult(instructionChanges.map(({ client, change }) => ({
5304
+ client,
5305
+ ...applyFileChange(change)
5306
+ })), mcpClients.map((client) => {
5307
+ if (client === "claude") return registerWithClaude(claudeNeeded);
5308
+ return {
5309
+ client,
5310
+ ...applyFileChange(fileMcpChanges.find((entry) => entry.client === client).change)
5311
+ };
5312
+ })), options);
5313
+ }
5314
+ function planGlobalMcpFile(client) {
5315
+ const path = globalMcpConfigPath(client);
5316
+ return client === "codex" ? planCodexMcpServer(path) : planJsonMcpServer(path);
5317
+ }
5318
+ /** Claude Code's user-scope registration goes through its own command, never an edit of `~/.claude.json`. */
5319
+ function registerWithClaude(needed) {
5320
+ const path = globalMcpConfigPath("claude");
5321
+ if (!needed) return {
5322
+ client: "claude",
5323
+ path,
5324
+ state: "unchanged"
5325
+ };
5326
+ const outcome = runClaudeMcp(CLAUDE_USER_ADD);
5327
+ return outcome.state === "ran" ? {
5328
+ client: "claude",
5329
+ path,
5330
+ state: "written",
5331
+ command: outcome.command
5332
+ } : {
5333
+ client: "claude",
5334
+ path,
5335
+ state: "manual",
5336
+ command: outcome.command,
5337
+ reason: outcome.reason
5338
+ };
5339
+ }
5340
+ function describePlan$1(changes, claudeNeeded) {
5341
+ const lines = ["kanbo init --global will:"];
5342
+ for (const change of changes) lines.push(` write ${change.path}`);
5343
+ if (claudeNeeded) lines.push(` run ${CLAUDE_USER_ADD.join(" ")}`);
5344
+ lines.push("No board is created. Narrow this with --instructions and --mcp.");
5345
+ return lines.join("\n");
5346
+ }
5347
+ function describeResult(instructions, mcp) {
5348
+ const lines = [];
5349
+ for (const outcome of instructions) lines.push(`Instructions: ${outcome.path} (${outcome.state})`);
5350
+ for (const outcome of mcp) if (outcome.state === "manual") lines.push(`MCP: ${outcome.client} — run this yourself: ${outcome.command} (${outcome.reason})`);
5351
+ else lines.push(`MCP: ${outcome.path} (${outcome.state})`);
5352
+ lines.push("A project uses the board once it has run kanbo init. Check everything with kanbo doctor.");
5353
+ return {
5354
+ value: {
5355
+ scope: "global",
5356
+ instructions,
5357
+ mcp
5358
+ },
5359
+ text: lines.join("\n"),
5360
+ fields: [
5361
+ "scope",
5362
+ "instructions",
5363
+ "mcp"
5364
+ ]
5365
+ };
5366
+ }
5367
+ function parseGlobalInstructions(value) {
5368
+ if (value === void 0) return DEFAULT_INSTRUCTION_CLIENTS;
5369
+ const names = value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
5370
+ if (names.length === 1 && names[0] === "none") return [];
5371
+ return names.map((name) => {
5372
+ const client = GLOBAL_INSTRUCTION_CLIENTS.find((candidate) => candidate === name);
5373
+ if (!client) throw new CliError(1, `Unknown instructions target "${name}" for --global. Use one or more of ${GLOBAL_INSTRUCTION_CLIENTS.join(", ")}, or none.`);
5374
+ return client;
5375
+ });
5376
+ }
5377
+ //#endregion
5378
+ //#region src/cli/commands/init.ts
5379
+ /**
5380
+ * `kanbo init` — tying a project folder to a board, and telling the agents
5381
+ * that work in it what the board expects.
5382
+ *
5383
+ * Three things come out of it, and each is optional except the first: the
5384
+ * binding, so no later command has to be told which workspace this is; a block
5385
+ * in the project's agent instructions, so an agent reads the board's rules
5386
+ * without being handed them; and a registration of the board's MCP server with
5387
+ * whichever tools the person actually uses. Nothing is written to a file the
5388
+ * person did not ask for, and nothing is written twice.
5389
+ */
5390
+ /**
5391
+ * What a person is told when an external board is named and nothing says which
5392
+ * workspace it is about. There is no table to look the answer up in, and a card
5393
+ * numbered under a guess carries that guess for the rest of its life.
5394
+ */
5395
+ const EXTERNAL_WORKSPACE_MESSAGE = "Could not tell which workspace this project is, and an external board holds no workspaces table to look it up in. Run this again with --workspace <id> --identifier <KEY>, or with --db (or KANBO_DB_PATH) naming a host database that knows this project.";
5396
+ /** What a person is told when a board file of the project's own and an external one are both named at once. */
5397
+ const FILE_AND_DATABASE_URL_MESSAGE = "One board at a time: pass --file for a board file of this project's own or --database-url for an external board, not both.";
5398
+ /**
5399
+ * What a person is told when their agents are about to reach the board as its
5400
+ * owner. Not a refusal: the board works this way from the first minute, and the
5401
+ * rule an agent is missing is one the database keeps, not this command.
5402
+ */
5403
+ const AGENT_URL_MISSING_WARNING = "kanbo: agents in this project will reach the board with the connection string you gave, which owns it. Until you run \"kanbo roles apply\" and record the agent role's own string with --agent-url, an agent here can approve its own work.";
5404
+ function registerInitCommand(program) {
5405
+ program.command("init").description("bind this project to a board, and tell its agents about it").option("--db <path>", "host database file to read this project's workspace from").option("--database-url <url>", "external Postgres board to bind this project to").option("--file [path]", `use a board file of this project's own instead of a host database or an external board (default ${DEFAULT_BOARD_FILE_PATH})`).option("--workspace <nameOrId>", "workspace this project is").option("--json <fields>", "print only these comma-separated fields, as JSON").option("--format <format>", "output format: json or pretty").option("--board <id>", "the board inside the workspace, when it has more than one").option("--identifier <key>", "what this workspace's card keys start with, for an external board").option("--agent-url <url>", "the connection string agents get, when it is not the one above").option("--instructions <file>", "where to write the instruction block: claude, agents or none; with --global, a comma-separated list of claude, codex, gemini, or none").option("--mcp <clients>", `register the board's MCP server with ${MCP_CLIENTS.join(", ")}`, parseMcpClients).option("--global", "set up your own agent tools for every project instead of binding this one; binds no board").option("--yes", "take the defaults instead of asking").action(async (options) => {
5406
+ if (options.global) {
5407
+ await initGlobal(options);
5408
+ return;
5409
+ }
5410
+ if (options.instructions !== void 0) parseInstructions(options.instructions);
5411
+ const projectDir = process.cwd();
5412
+ if (options.file !== void 0) {
5413
+ await initOwnFile(projectDir, options);
5414
+ return;
5415
+ }
5416
+ const target = resolveInitTarget(projectDir, options);
5417
+ if (target.kind === "postgres") {
5418
+ await initExternalBoard(projectDir, target, options);
5419
+ return;
5420
+ }
5421
+ await runBoardCommand(options, "read", async (session) => await initProject(projectDir, target, session.workspace, options));
5422
+ });
5423
+ }
5424
+ /**
5425
+ * Which board `init` is binding this project to.
5426
+ *
5427
+ * `--db` means something else here than it does in every other command: it
5428
+ * names the host database this one reads the workspace off, not the
5429
+ * board being bound. So the external board is asked for on its own — the flag,
5430
+ * the environment, then the binding this project already has — and only when
5431
+ * there is none is the board file itself the board. Always the person's
5432
+ * connection string: binding a project is not something an agent does.
5433
+ */
5434
+ function resolveInitTarget(projectDir, options) {
5435
+ const url = resolveDatabaseUrl({
5436
+ explicitUrl: options.databaseUrl,
5437
+ cwd: projectDir,
5438
+ asAgent: false
5439
+ });
5440
+ return url ? {
5441
+ kind: "postgres",
5442
+ url
5443
+ } : resolveDbTarget({
5444
+ explicitPath: options.db,
5445
+ cwd: projectDir
5446
+ });
5447
+ }
5448
+ /**
5449
+ * Bind a project to an external board.
5450
+ *
5451
+ * The board is never opened. `init` is what a person runs *before*
5452
+ * `kanbo migrate`, and a database that holds no board yet is not a reason to
5453
+ * refuse to write down where it will be — nor could it answer the one question
5454
+ * that matters here, since it has no `workspaces` table to be asked.
5455
+ */
5456
+ async function initExternalBoard(projectDir, target, options) {
5457
+ readFormat(options);
5458
+ printResult(await initProject(projectDir, target, await resolveExternalWorkspace(projectDir, options), options), options);
5459
+ }
5460
+ /**
5461
+ * Create and use a board file of this project's own — `.kanbo/board.db` by
5462
+ * default — with no host app and no Postgres anywhere near it.
5463
+ *
5464
+ * The file is migrated with this package's own chain right here, because
5465
+ * nothing else ever will: a host app migrates its own database and an
5466
+ * external database is migrated by `kanbo migrate`, but a file of this
5467
+ * package's own has no other owner to do it later — `kanbo migrate` runs the
5468
+ * same chain again afterwards, and idempotently, for the day it falls behind a
5469
+ * newer build (ruling 5-5). The marker written right after is what every later
5470
+ * command reads to know not to look for a host `workspaces` table in it
5471
+ * (ruling 5-4).
5472
+ *
5473
+ * The workspace is settled before a single byte is written, and nothing here
5474
+ * opens a host database at all (ruling 5-9): this is the one command that has
5475
+ * to work on a machine with nothing else installed, and the answer it needs —
5476
+ * an id and a card-key stem — is either typed, already in the binding, or the
5477
+ * folder's own name. Asking a host database first would turn `--workspace <id>`
5478
+ * into a refusal on every machine that has one, and leave a board file behind
5479
+ * with no binding beside it.
5480
+ */
5481
+ async function initOwnFile(projectDir, options) {
5482
+ readFormat(options);
5483
+ if (options.databaseUrl) throw new CliError(1, FILE_AND_DATABASE_URL_MESSAGE);
5484
+ const relativePath = typeof options.file === "string" && options.file.trim() ? options.file.trim() : DEFAULT_BOARD_FILE_PATH;
5485
+ const absolutePath = (0, node_path.resolve)(projectDir, relativePath);
5486
+ const workspace = resolveOwnFileWorkspace(projectDir, options);
5487
+ await createOwnBoardFile(absolutePath);
5488
+ ignoreBoardFile(projectDir, absolutePath);
5489
+ printResult(await initProject(projectDir, {
5490
+ kind: "sqlite",
5491
+ path: absolutePath,
5492
+ owner: "kanbo"
5493
+ }, workspace, options), options);
5494
+ }
5495
+ /**
5496
+ * Put a migrated, marked board at this path — or leave the folder exactly as it
5497
+ * was found (ruling 5-9).
5498
+ *
5499
+ * A new board is built beside its own path and moved onto it at the end, so a
5500
+ * migration that fails half-way leaves no half-built file for the next command
5501
+ * to open and believe in. `rename` within one directory is the filesystem's own
5502
+ * atomic swap; a temporary name elsewhere would be a copy across devices and
5503
+ * not atomic at all. A file that is already there is a different question — it
5504
+ * may hold this project's cards — so it is migrated in place, idempotently,
5505
+ * which is what makes a second `kanbo init --file` a no-op.
5506
+ *
5507
+ * A file that is there and is *not* this package's is refused, for the reason
5508
+ * any careful host refuses it: migrating a board into somebody's own
5509
+ * database leaves it readable but contaminated, and only a different path
5510
+ * helps.
5511
+ */
5512
+ async function createOwnBoardFile(absolutePath) {
5513
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(absolutePath), { recursive: true });
4514
5514
  if ((0, node_fs.existsSync)(absolutePath)) {
4515
5515
  assertOwnBoardFile(absolutePath);
4516
5516
  await migrateBoardFileAt(absolutePath);
@@ -4740,9 +5740,9 @@ function warnAboutOwnerAccess(target, options) {
4740
5740
  * instructions is the kind of thing a person says yes to.
4741
5741
  */
4742
5742
  async function writeInstructions(projectDir, options) {
4743
- const target = options.instructions ?? await askInstructionTarget(options);
5743
+ const target = options.instructions === void 0 ? await askInstructionTarget(options) : parseInstructions(options.instructions);
4744
5744
  if (target === "none") return null;
4745
- return writeInstructionBlock((0, node_path.join)(projectDir, INSTRUCTION_FILES[target]));
5745
+ return applyFileChange(planInstructionBlock((0, node_path.join)(projectDir, PROJECT_INSTRUCTION_FILES[target]), INSTRUCTION_BLOCK));
4746
5746
  }
4747
5747
  async function askInstructionTarget(options) {
4748
5748
  showBlock(options);
@@ -4776,35 +5776,6 @@ function showBlock(options) {
4776
5776
  if (!options.json && !options.format) console.log(`${INSTRUCTION_BLOCK}\n`);
4777
5777
  }
4778
5778
  /**
4779
- * The block, in that file, once.
4780
- *
4781
- * A file that already carries the markers has its block replaced rather than
4782
- * appended to, so running `init` again on a project whose block is current
4783
- * changes nothing at all, and running it after an upgrade brings the block
4784
- * forward. Everything outside the markers is the person's own writing and is
4785
- * never touched.
4786
- */
4787
- function writeInstructionBlock(path) {
4788
- const existing = (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : null;
4789
- const next = buildInstructionFile(existing);
4790
- if (existing === next) return {
4791
- path,
4792
- state: "unchanged"
4793
- };
4794
- (0, node_fs.writeFileSync)(path, next);
4795
- return {
4796
- path,
4797
- state: "written"
4798
- };
4799
- }
4800
- function buildInstructionFile(existing) {
4801
- if (existing === null) return `${INSTRUCTION_BLOCK}\n`;
4802
- const start = existing.indexOf(INSTRUCTION_START);
4803
- const end = existing.indexOf(INSTRUCTION_END);
4804
- if (start >= 0 && end > start) return existing.slice(0, start) + INSTRUCTION_BLOCK + existing.slice(end + 18);
4805
- return `${existing.replace(/\s*$/, "")}\n\n${INSTRUCTION_BLOCK}\n`;
4806
- }
4807
- /**
4808
5779
  * Tell the tools the person uses about the board's MCP server.
4809
5780
  *
4810
5781
  * Only the ones they named, and only when the tool does not already know: a
@@ -4839,81 +5810,8 @@ async function askMcpClients(options) {
4839
5810
  return prompts.isCancel(answer) ? [] : answer;
4840
5811
  }
4841
5812
  function registerMcpServer(projectDir, client) {
4842
- if (client === "codex") return writeCodexMcpServer((0, node_path.join)(projectDir, ".codex", "config.toml"));
4843
- return writeJsonMcpServer((0, node_path.join)(projectDir, ...client === "claude" ? [".mcp.json"] : [".cursor", "mcp.json"]));
4844
- }
4845
- /**
4846
- * The `mcpServers` map Claude Code and Cursor both read, merged rather than
4847
- * replaced: the file is the project's, and it may already describe servers that
4848
- * have nothing to do with this board.
4849
- */
4850
- function writeJsonMcpServer(path) {
4851
- const existing = readJsonFile(path);
4852
- if (existing.mcpServers && MCP_SERVER_NAME in existing.mcpServers) return {
4853
- path,
4854
- state: "unchanged"
4855
- };
4856
- const next = {
4857
- ...existing,
4858
- mcpServers: {
4859
- ...existing.mcpServers,
4860
- [MCP_SERVER_NAME]: {
4861
- type: "stdio",
4862
- command: "kanbo",
4863
- args: ["mcp"]
4864
- }
4865
- }
4866
- };
4867
- (0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
4868
- (0, node_fs.writeFileSync)(path, `${JSON.stringify(next, null, 2)}\n`);
4869
- return {
4870
- path,
4871
- state: "written"
4872
- };
4873
- }
4874
- /**
4875
- * The tool's configuration file as an object to merge into — or a refusal.
4876
- *
4877
- * A file that is valid JSON but not an object (a list, a string, `null`) is
4878
- * something this command has no idea how to merge into, and spreading it would
4879
- * quietly throw the person's file away. The file is left exactly as it is and
4880
- * they are told which one to look at.
4881
- */
4882
- function readJsonFile(path) {
4883
- if (!(0, node_fs.existsSync)(path)) return {};
4884
- const parsed = parseJsonFile(path);
4885
- if (!isPlainObject(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo init again.`);
4886
- if (parsed.mcpServers !== void 0 && !isPlainObject(parsed.mcpServers)) throw new CliError(1, `${path} has an "mcpServers" that is not an object. Fix it, then run kanbo init again.`);
4887
- return parsed;
4888
- }
4889
- function parseJsonFile(path) {
4890
- try {
4891
- return JSON.parse((0, node_fs.readFileSync)(path, "utf8"));
4892
- } catch {
4893
- throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo init again.`);
4894
- }
4895
- }
4896
- function isPlainObject(value) {
4897
- return typeof value === "object" && value !== null && !Array.isArray(value);
4898
- }
4899
- /** Codex keeps its servers in TOML tables, so the table is appended rather than merged. */
4900
- function writeCodexMcpServer(path) {
4901
- const existing = (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : "";
4902
- const table = `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "kanbo"\nargs = ["mcp"]\n`;
4903
- if (CODEX_TABLE_PATTERN.test(existing)) return {
4904
- path,
4905
- state: "unchanged"
4906
- };
4907
- (0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
4908
- (0, node_fs.writeFileSync)(path, existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n${table}` : table);
4909
- return {
4910
- path,
4911
- state: "written"
4912
- };
4913
- }
4914
- /** Is there a person on the other end of this shell to ask? */
4915
- function isInteractive() {
4916
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
5813
+ const path = projectMcpConfigPath(projectDir, client);
5814
+ return applyFileChange(client === "codex" ? planCodexMcpServer(path) : planJsonMcpServer(path));
4917
5815
  }
4918
5816
  function describeInit(workspace, target, bindingPath, instructions, mcp) {
4919
5817
  const lines = [`Bound this project to ${workspace.name} — ${bindingPath}`];
@@ -5186,16 +6084,6 @@ async function createDbTransport(input) {
5186
6084
  };
5187
6085
  }
5188
6086
  //#endregion
5189
- //#region src/package-version.ts
5190
- (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
5191
- /**
5192
- * This build's own version, one source for the CLI's `--version`, the MCP
5193
- * server's reported version and the capabilities manifest's
5194
- * `package.version`. A build carries it as a literal; running from source
5195
- * (tests, `tsx`) reads the package's own `package.json` instead.
5196
- */
5197
- const KANBO_PACKAGE_VERSION = "0.1.2";
5198
- //#endregion
5199
6087
  //#region src/mcp/resources.ts
5200
6088
  /** The URI a client reads the manifest as JSON from. */
5201
6089
  const KANBO_CAPABILITIES_RESOURCE_URI = "kanbo://capabilities";
@@ -5648,74 +6536,6 @@ function createKanboMcpServer(transport, options) {
5648
6536
  return server;
5649
6537
  }
5650
6538
  //#endregion
5651
- //#region src/cli/failure.ts
5652
- /**
5653
- * What a failed command says, and what it leaves with.
5654
- *
5655
- * One place, because a failure is output as much as a result is: the exit code
5656
- * a caller may branch on, the sentence a person reads, and the chain underneath
5657
- * it that says what actually went wrong.
5658
- */
5659
- /** How far down a chain of causes is still worth reading. */
5660
- const CAUSE_DEPTH_LIMIT = 5;
5661
- /**
5662
- * What to say about a failure, and what to leave with.
5663
- *
5664
- * A `CliError` already carries both. A `BoardError` is a rule of the board the
5665
- * caller broke: its code is the honest name of what happened, and its details
5666
- * say which value did it, so both are printed rather than translated into a
5667
- * sentence that could drift away from the rule. The one exception is a card
5668
- * held at a column's door: its details are a list, and a person reads it as
5669
- * one line per rule the card does not meet yet (`describeColumnRefusal`).
5670
- *
5671
- * Whatever it is, its causes are printed under it. A driver's failure arrives
5672
- * wrapped — `Failed query: create schema …` with the connection refused
5673
- * underneath — and the wrapper alone tells a person nothing they can act on.
5674
- * Every line goes through the masking on its way out, because a message this
5675
- * tool did not write is free to carry the whole connection string in it.
5676
- */
5677
- function describeFailure(error) {
5678
- const { message, exitCode } = readFailure(error);
5679
- return {
5680
- message: maskDatabaseUrls([message, ...describeCauses(error, 1)].join("\n")),
5681
- exitCode
5682
- };
5683
- }
5684
- function readFailure(error) {
5685
- if (error instanceof CliError) return {
5686
- message: error.message,
5687
- exitCode: error.exitCode
5688
- };
5689
- if (error instanceof require_migrations_path.BoardError && error.code === "board_column_rules_unmet") return {
5690
- message: describeColumnRefusal(error.details),
5691
- exitCode: 1
5692
- };
5693
- if (error instanceof require_migrations_path.BoardError) {
5694
- const details = error.details ? ` ${JSON.stringify(error.details)}` : "";
5695
- return {
5696
- message: `${error.code}${details}`,
5697
- exitCode: 1
5698
- };
5699
- }
5700
- return {
5701
- message: error instanceof Error ? error.message : String(error),
5702
- exitCode: 1
5703
- };
5704
- }
5705
- /**
5706
- * The chain under a failure, one indented line per link.
5707
- *
5708
- * Bounded, because `cause` is an ordinary property and nothing stops it
5709
- * pointing back at the error it came from; a chain longer than this has stopped
5710
- * explaining anything anyway.
5711
- */
5712
- function describeCauses(error, depth) {
5713
- const cause = error instanceof Error ? error.cause : void 0;
5714
- if (cause === void 0 || cause === null || depth > CAUSE_DEPTH_LIMIT) return [];
5715
- const message = cause instanceof Error ? cause.message : String(cause);
5716
- return [`${" ".repeat(depth)}Caused by: ${message}`, ...describeCauses(cause, depth + 1)];
5717
- }
5718
- //#endregion
5719
6539
  //#region src/cli/commands/mcp.ts
5720
6540
  function registerMcpCommand(program) {
5721
6541
  program.command("mcp").description("serve this board to an MCP client over stdio").option("--db <path>", "board database file to open").option("--database-url <url>", "external Postgres board to serve instead of a board file").option("--workspace <nameOrId>", "workspace the board tools are about").action(async (options) => {
@@ -6219,21 +7039,6 @@ function sendBoardPage(response, pathname, assets) {
6219
7039
  * reason turned around: the answer will be yes once the card meets them, and
6220
7040
  * `details.unmet` says what is missing.
6221
7041
  *
6222
- * The five failures about where a board is kept read the same rule. A database
6223
- * that holds no board this build can speak for is 409 — the setting is there,
6224
- * the board behind it is not yet, and `board-storage prepare` is what settles
6225
- * that — and one nobody could reach at all is 503, because nothing about the
6226
- * request was wrong. A workspace naming no board at all is 404: the caller
6227
- * asked about a setting that is not there. A board still holding cards when
6228
- * somebody asks to point the workspace elsewhere is 409, because the answer
6229
- * would have been yes on an empty board and `board-storage migrate` is what
6230
- * makes it one. An operation naming cards from two different boards is 400: no
6231
- * transaction reaches both, so the caller asked for something that cannot be
6232
- * done. A copy that would land on a card number or a column name the target
6233
- * already holds under a different id is 409 for the same reason the others
6234
- * are: the answer would have been yes on a target that did not hold that
6235
- * board, and `details.conflicts` names every value that clashed.
6236
- *
6237
7042
  * A failure may also be renamed here: the package names its codes after the
6238
7043
  * board, while every error the `/issues` routes answer with is named after the
6239
7044
  * issue, and a client matching on `issue_*` should not have to learn a second
@@ -6255,10 +7060,6 @@ const BOARD_ERROR_RESPONSES = {
6255
7060
  message: "The card does not meet what that column asks for yet",
6256
7061
  code: "issue_column_rules_unmet"
6257
7062
  },
6258
- board_copy_conflict: {
6259
- status: 409,
6260
- message: "That board already holds cards or columns of this workspace under the same numbers or names; nothing was copied"
6261
- },
6262
7063
  board_entry_rule_invalid: {
6263
7064
  status: 400,
6264
7065
  message: "Unknown column entry rule"
@@ -6267,10 +7068,6 @@ const BOARD_ERROR_RESPONSES = {
6267
7068
  status: 400,
6268
7069
  message: "Execution mode cannot change once the card has been launched"
6269
7070
  },
6270
- board_mixed_workspaces: {
6271
- status: 400,
6272
- message: "Those cards are not all on the same board"
6273
- },
6274
7071
  board_pull_request_invalid: {
6275
7072
  status: 400,
6276
7073
  message: "Not a GitHub pull request: give its URL or owner/repo#number"
@@ -6330,22 +7127,6 @@ const BOARD_ERROR_RESPONSES = {
6330
7127
  status: 400,
6331
7128
  message: "A sprint cannot start after it is due"
6332
7129
  },
6333
- board_storage_not_configured: {
6334
- status: 404,
6335
- message: "This workspace names no board storage"
6336
- },
6337
- board_storage_not_empty: {
6338
- status: 409,
6339
- message: "That board still holds cards of this workspace; move them with migrate before changing where the board is kept"
6340
- },
6341
- board_storage_not_prepared: {
6342
- status: 409,
6343
- message: "That board has not been prepared yet"
6344
- },
6345
- board_storage_unavailable: {
6346
- status: 503,
6347
- message: "That board could not be reached"
6348
- },
6349
7130
  issue_comment_delete_requires_user: {
6350
7131
  status: 403,
6351
7132
  message: "Only a person can delete a comment the board wrote"
@@ -7807,6 +8588,143 @@ function registerSprintCommands(program) {
7807
8588
  });
7808
8589
  }
7809
8590
  //#endregion
8591
+ //#region src/cli/commands/uninstall.ts
8592
+ function registerUninstallCommand(program) {
8593
+ program.command("uninstall").description("remove what kanbo init wrote: instruction blocks and kanbo MCP entries; boards stay unless --purge").option("--project", "only this project's files").option("--global", "only your own user-level files").option("--purge", "also remove this project's .kanbo/binding.json, and its board file after a confirmation naming it").option("--yes", "do it without asking (never deletes a board file)").option("--json", "print what was done as JSON").action(async (options) => {
8594
+ await uninstall(options);
8595
+ });
8596
+ }
8597
+ async function uninstall(options) {
8598
+ const both = !options.project && !options.global;
8599
+ const withProject = both || Boolean(options.project);
8600
+ const withGlobal = both || Boolean(options.global);
8601
+ if (options.purge && !withProject) throw new CliError(1, "--purge removes a project's binding and board; run it without --global, in the project.");
8602
+ const projectRoot = withProject ? findKanboProject(process.cwd())?.root ?? process.cwd() : null;
8603
+ const edits = [...projectRoot ? planProjectEdits(projectRoot) : [], ...withGlobal ? planGlobalEdits() : []].filter((edit) => edit.change.next !== null);
8604
+ const claudeUser = withGlobal && readClaudeUserMcpCommand() !== void 0;
8605
+ const bindingPath = projectRoot && options.purge ? (0, node_path.join)(projectRoot, BINDING_FILE_PATH) : null;
8606
+ const purgeBinding = bindingPath !== null && (0, node_fs.existsSync)(bindingPath);
8607
+ const boardFile = projectRoot && options.purge ? findOwnBoardFile(projectRoot) : null;
8608
+ const actions = [];
8609
+ if (edits.length > 0 || claudeUser || purgeBinding || boardFile) {
8610
+ if (!await confirmPlan({
8611
+ preview: describePlan(edits, claudeUser, purgeBinding ? bindingPath : null, boardFile),
8612
+ question: "Remove these?",
8613
+ yes: options.yes,
8614
+ machineOutput: Boolean(options.json),
8615
+ command: "kanbo uninstall"
8616
+ })) throw new CliError(1, "Nothing was changed.");
8617
+ for (const edit of edits) {
8618
+ applyFileChange(edit.change);
8619
+ actions.push({
8620
+ path: edit.change.path,
8621
+ action: "edited",
8622
+ note: edit.what
8623
+ });
8624
+ }
8625
+ if (claudeUser) {
8626
+ const outcome = runClaudeMcp(CLAUDE_USER_REMOVE);
8627
+ actions.push({
8628
+ path: globalMcpConfigPath("claude"),
8629
+ action: outcome.state,
8630
+ note: outcome.state === "ran" ? outcome.command : `run this yourself: ${outcome.command} (${outcome.reason})`
8631
+ });
8632
+ }
8633
+ if (purgeBinding) {
8634
+ (0, node_fs.rmSync)(bindingPath);
8635
+ actions.push({
8636
+ path: bindingPath,
8637
+ action: "deleted",
8638
+ note: "project binding"
8639
+ });
8640
+ }
8641
+ if (boardFile) actions.push(await purgeBoardFile(boardFile));
8642
+ }
8643
+ printActions(actions, options);
8644
+ }
8645
+ function planProjectEdits(root) {
8646
+ return [...Object.values(PROJECT_INSTRUCTION_FILES).map((name) => ({
8647
+ change: planInstructionBlockRemoval((0, node_path.join)(root, name)),
8648
+ what: "kanbo instruction block"
8649
+ })), ...[
8650
+ "claude",
8651
+ "codex",
8652
+ "cursor"
8653
+ ].map((client) => ({
8654
+ change: planMcpRemoval(projectMcpConfigPath(root, client), client),
8655
+ what: "kanbo MCP server"
8656
+ }))];
8657
+ }
8658
+ function planGlobalEdits() {
8659
+ return [...GLOBAL_INSTRUCTION_CLIENTS.map((client) => ({
8660
+ change: planInstructionBlockRemoval(globalInstructionPath(client)),
8661
+ what: "kanbo instruction block"
8662
+ })), ...["codex", "cursor"].map((client) => ({
8663
+ change: planMcpRemoval(globalMcpConfigPath(client), client),
8664
+ what: "kanbo MCP server"
8665
+ }))];
8666
+ }
8667
+ function planMcpRemoval(path, client) {
8668
+ return client === "codex" ? planCodexMcpServerRemoval(path) : planJsonMcpServerRemoval(path);
8669
+ }
8670
+ /**
8671
+ * The board file `kanbo init --file` made for this project — the only board
8672
+ * `--purge` may offer to delete. A host app's database or an external board is
8673
+ * not kanbo's to delete, and a binding that names none offers nothing.
8674
+ */
8675
+ function findOwnBoardFile(root) {
8676
+ const binding = findKanboProject(root)?.binding;
8677
+ if (!binding?.dbPath) return null;
8678
+ const path = (0, node_path.resolve)(root, binding.dbPath);
8679
+ return (0, node_fs.existsSync)(path) ? path : null;
8680
+ }
8681
+ /** Delete the board file only on a yes to a question that names it; `--yes` is not that answer. */
8682
+ async function purgeBoardFile(path) {
8683
+ if (!isInteractive()) return {
8684
+ path,
8685
+ action: "kept",
8686
+ note: "board file: deleting it needs a confirmation in a terminal; delete it yourself if you mean to"
8687
+ };
8688
+ if (await (await import("@clack/prompts")).confirm({
8689
+ message: `Delete the board file ${path} and every card in it? This cannot be undone.`,
8690
+ initialValue: false
8691
+ }) !== true) return {
8692
+ path,
8693
+ action: "kept",
8694
+ note: "board file"
8695
+ };
8696
+ for (const file of [
8697
+ path,
8698
+ `${path}-wal`,
8699
+ `${path}-shm`
8700
+ ]) (0, node_fs.rmSync)(file, { force: true });
8701
+ return {
8702
+ path,
8703
+ action: "deleted",
8704
+ note: "board file"
8705
+ };
8706
+ }
8707
+ function describePlan(edits, claudeUser, bindingPath, boardFile) {
8708
+ const lines = ["kanbo uninstall will:"];
8709
+ for (const edit of edits) lines.push(` edit ${edit.change.path} (remove the ${edit.what})`);
8710
+ if (claudeUser) lines.push(` run ${CLAUDE_USER_REMOVE.join(" ")}`);
8711
+ if (bindingPath) lines.push(` delete ${bindingPath}`);
8712
+ if (boardFile) lines.push(` ask whether to delete the board file ${boardFile} (only in a terminal; --yes does not answer this)`);
8713
+ lines.push("Files stay even when nothing is left in them. Boards stay unless you confirm deleting one.");
8714
+ return lines.join("\n");
8715
+ }
8716
+ function printActions(actions, options) {
8717
+ if (options.json) {
8718
+ console.log(JSON.stringify({ actions }, null, 2));
8719
+ return;
8720
+ }
8721
+ if (actions.length === 0) {
8722
+ console.log("Nothing to remove: no kanbo block or MCP entry in the files kanbo writes.");
8723
+ return;
8724
+ }
8725
+ for (const action of actions) console.log(`${action.action.padEnd(8)} ${action.path}${action.note ? ` (${action.note})` : ""}`);
8726
+ }
8727
+ //#endregion
7810
8728
  //#region src/cli/commands/index.ts
7811
8729
  /**
7812
8730
  * Every command the `kanbo` binary answers to, put on one program.
@@ -7820,6 +8738,8 @@ function registerSprintCommands(program) {
7820
8738
  */
7821
8739
  function registerKanboCommands(program) {
7822
8740
  registerInitCommand(program);
8741
+ registerDoctorCommand(program);
8742
+ registerUninstallCommand(program);
7823
8743
  registerCapabilitiesCommand(program);
7824
8744
  registerPrimeCommand(program);
7825
8745
  registerReadyCommand(program);
@@ -8428,4 +9348,4 @@ Object.defineProperty(exports, "writeBinding", {
8428
9348
  }
8429
9349
  });
8430
9350
 
8431
- //# sourceMappingURL=manifest-CmiuDqH-.cjs.map
9351
+ //# sourceMappingURL=manifest-RDyoGrVR.cjs.map