kanbo-cli 0.1.1 → 0.1.3
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.
- package/README.md +2 -0
- package/dist/cli.cjs +10611 -8404
- package/dist/cli.cjs.map +1 -1
- package/dist/{dist-Cd6wg8pp.cjs → dist-BsCwQFEl.cjs} +48 -1
- package/dist/dist-BsCwQFEl.cjs.map +1 -0
- package/dist/lib/index.cjs +1 -1
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/{manifest-wdwFSquz.cjs → manifest-BBJIrMw7.cjs} +1382 -372
- package/dist/lib/manifest-BBJIrMw7.cjs.map +1 -0
- package/dist/lib/{manifest-DfOBqo-Q.mjs → manifest-mkDJmxt2.mjs} +1260 -256
- package/dist/lib/manifest-mkDJmxt2.mjs.map +1 -0
- package/dist/lib/mcp/index.cjs +2 -1
- package/dist/lib/mcp/index.d.cts +17 -0
- package/dist/lib/mcp/index.d.mts +17 -0
- package/dist/lib/mcp/index.mjs +2 -2
- package/package.json +1 -1
- package/dist/dist-Cd6wg8pp.cjs.map +0 -1
- package/dist/lib/manifest-DfOBqo-Q.mjs.map +0 -1
- package/dist/lib/manifest-wdwFSquz.cjs.map +0 -1
|
@@ -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
|
|
@@ -2314,32 +2316,61 @@ async function migrateCards(store, input) {
|
|
|
2314
2316
|
};
|
|
2315
2317
|
}
|
|
2316
2318
|
//#endregion
|
|
2317
|
-
//#region src/ops/
|
|
2318
|
-
/**
|
|
2319
|
-
* The
|
|
2319
|
+
//#region src/ops/agent-rules.ts
|
|
2320
|
+
/**
|
|
2321
|
+
* The sentences that tell an agent how to work this board, written once.
|
|
2322
|
+
*
|
|
2323
|
+
* Four surfaces say them: the text `kanbo prime` returns (`BOARD_RULES`), the
|
|
2324
|
+
* block `kanbo init` writes into a project's `CLAUDE.md` / `AGENTS.md`, the
|
|
2325
|
+
* `instructions` the `kanbo mcp` server sends when a client connects, and the
|
|
2326
|
+
* `kanbo_card_create` tool's description. Each surface adds its own way of
|
|
2327
|
+
* doing the thing — a command, a tool name — but the rule itself is read from
|
|
2328
|
+
* here, so the four cannot come to say different things.
|
|
2329
|
+
*
|
|
2330
|
+
* No sentence names a tool or a command: the same text reaches an agent holding
|
|
2331
|
+
* MCP tools, one with a command line, and one talking to a server over HTTP.
|
|
2332
|
+
*/
|
|
2333
|
+
const MOVE_CARD_RULE = "You move the card between columns yourself, at the moment its real state changes. The board never moves a card for you.";
|
|
2334
|
+
const STATUS_LINE_RULE = "Write a status line at every step, including before and after anything long-running. One sentence, present tense, about what is happening right now.";
|
|
2335
|
+
const WAIT_FOR_PERSON_RULE = "When you need a person, mark the card as waiting for approval and end your turn. You will be told when the answer comes.";
|
|
2336
|
+
const PERSON_ONLY_RULE = "Only a person approves a card, and only a person takes one out of waiting. Never do either yourself.";
|
|
2337
|
+
const EXECUTION_MODE_RULE = "Each card says where it runs: `worktree` means a separate copy of the project, `main` means the project folder itself. Work in the mode the card names.";
|
|
2338
|
+
/** When to split a card at all — said on its own where the surface names the parent its own way. */
|
|
2339
|
+
const SUBTASK_WHEN_SENTENCE = "Create subtasks only when the person asks for them, or when a task has parts that can be done and checked separately (different stages, owners or pull requests); do not split small work you will finish in one go.";
|
|
2340
|
+
/** How to split one, with the way this surface names the parent card. */
|
|
2341
|
+
function subtaskSplitSentence(parentReference) {
|
|
2342
|
+
return `When you do split a card, make the parts subtasks of it (${parentReference}), not separate top-level cards.`;
|
|
2343
|
+
}
|
|
2344
|
+
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
2345
|
+
const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
|
|
2346
|
+
const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Say so when you start the run — `claude:<session id>` from Claude Code, `codex:<session id>` from Codex (the session id Codex prints) — so your own log of it can be found later.";
|
|
2347
|
+
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
2348
|
+
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
2349
|
+
/**
|
|
2350
|
+
* The rules of this board, as `kanbo prime` says them under its column list.
|
|
2320
2351
|
*
|
|
2321
2352
|
* They are about the board and nothing else. The board does not hand out a role, a
|
|
2322
2353
|
* method or a pipeline — that belongs to the instructions the person wrote for
|
|
2323
2354
|
* their own agent — so the list stops at what the board itself guarantees and
|
|
2324
|
-
* expects.
|
|
2325
|
-
* tools, one with a command line, and one talking to a server over HTTP, and each
|
|
2326
|
-
* of them already knows what it is holding.
|
|
2355
|
+
* expects.
|
|
2327
2356
|
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2357
|
+
* Also the source `buildCapabilitiesManifest` reads its `rules` from — one list,
|
|
2358
|
+
* read by the prompt an agent sees and by the manifest an orchestrator reads
|
|
2359
|
+
* about the board.
|
|
2331
2360
|
*/
|
|
2332
2361
|
const BOARD_RULES = [
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2362
|
+
MOVE_CARD_RULE,
|
|
2363
|
+
STATUS_LINE_RULE,
|
|
2364
|
+
WAIT_FOR_PERSON_RULE,
|
|
2365
|
+
PERSON_ONLY_RULE,
|
|
2366
|
+
EXECUTION_MODE_RULE,
|
|
2367
|
+
SUBTASK_RULE,
|
|
2368
|
+
PULL_REQUEST_RULE,
|
|
2369
|
+
OWN_SESSION_RULE,
|
|
2341
2370
|
"Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
|
|
2342
2371
|
];
|
|
2372
|
+
//#endregion
|
|
2373
|
+
//#region src/ops/prime.ts
|
|
2343
2374
|
/**
|
|
2344
2375
|
* What the board tells an agent about itself: the columns it has, what each one
|
|
2345
2376
|
* means, and how a card is expected to travel across them.
|
|
@@ -4295,211 +4326,1192 @@ function readRequestedRules(options) {
|
|
|
4295
4326
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
4296
4327
|
}
|
|
4297
4328
|
//#endregion
|
|
4298
|
-
//#region src/
|
|
4329
|
+
//#region src/package-version.ts
|
|
4330
|
+
(0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
4299
4331
|
/**
|
|
4300
|
-
*
|
|
4301
|
-
*
|
|
4302
|
-
*
|
|
4332
|
+
* This build's own version, one source for the CLI's `--version`, the MCP
|
|
4333
|
+
* server's reported version and the capabilities manifest's
|
|
4334
|
+
* `package.version`. A build carries it as a literal; running from source
|
|
4335
|
+
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
4303
4336
|
*/
|
|
4304
|
-
const
|
|
4337
|
+
const KANBO_PACKAGE_VERSION = "0.1.3";
|
|
4305
4338
|
//#endregion
|
|
4306
|
-
//#region src/cli/
|
|
4339
|
+
//#region src/cli/failure.ts
|
|
4307
4340
|
/**
|
|
4308
|
-
*
|
|
4309
|
-
* that work in it what the board expects.
|
|
4341
|
+
* What a failed command says, and what it leaves with.
|
|
4310
4342
|
*
|
|
4311
|
-
*
|
|
4312
|
-
*
|
|
4313
|
-
*
|
|
4314
|
-
* without being handed them; and a registration of the board's MCP server with
|
|
4315
|
-
* whichever tools the person actually uses. Nothing is written to a file the
|
|
4316
|
-
* person did not ask for, and nothing is written twice.
|
|
4343
|
+
* One place, because a failure is output as much as a result is: the exit code
|
|
4344
|
+
* a caller may branch on, the sentence a person reads, and the chain underneath
|
|
4345
|
+
* it that says what actually went wrong.
|
|
4317
4346
|
*/
|
|
4318
|
-
/**
|
|
4319
|
-
const
|
|
4320
|
-
const INSTRUCTION_END = "<!-- KANBO_END -->";
|
|
4321
|
-
/** The files an agent reads its standing instructions from. */
|
|
4322
|
-
const INSTRUCTION_FILES = {
|
|
4323
|
-
claude: "CLAUDE.md",
|
|
4324
|
-
agents: "AGENTS.md"
|
|
4325
|
-
};
|
|
4326
|
-
/** The tools that can be told about the board's MCP server. */
|
|
4327
|
-
const MCP_CLIENTS = [
|
|
4328
|
-
"claude",
|
|
4329
|
-
"codex",
|
|
4330
|
-
"cursor"
|
|
4331
|
-
];
|
|
4332
|
-
/** The name the board's MCP server is registered under, in every tool. */
|
|
4333
|
-
const MCP_SERVER_NAME = "kanbo";
|
|
4347
|
+
/** How far down a chain of causes is still worth reading. */
|
|
4348
|
+
const CAUSE_DEPTH_LIMIT = 5;
|
|
4334
4349
|
/**
|
|
4335
|
-
* What
|
|
4336
|
-
*
|
|
4337
|
-
*
|
|
4350
|
+
* What to say about a failure, and what to leave with.
|
|
4351
|
+
*
|
|
4352
|
+
* A `CliError` already carries both. A `BoardError` is a rule of the board the
|
|
4353
|
+
* caller broke: its code is the honest name of what happened, and its details
|
|
4354
|
+
* say which value did it, so both are printed rather than translated into a
|
|
4355
|
+
* sentence that could drift away from the rule. The one exception is a card
|
|
4356
|
+
* held at a column's door: its details are a list, and a person reads it as
|
|
4357
|
+
* one line per rule the card does not meet yet (`describeColumnRefusal`).
|
|
4358
|
+
*
|
|
4359
|
+
* Whatever it is, its causes are printed under it. A driver's failure arrives
|
|
4360
|
+
* wrapped — `Failed query: create schema …` with the connection refused
|
|
4361
|
+
* underneath — and the wrapper alone tells a person nothing they can act on.
|
|
4362
|
+
* Every line goes through the masking on its way out, because a message this
|
|
4363
|
+
* tool did not write is free to carry the whole connection string in it.
|
|
4338
4364
|
*/
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4365
|
+
function describeFailure(error) {
|
|
4366
|
+
const { message, exitCode } = readFailure(error);
|
|
4367
|
+
return {
|
|
4368
|
+
message: maskDatabaseUrls([message, ...describeCauses(error, 1)].join("\n")),
|
|
4369
|
+
exitCode
|
|
4370
|
+
};
|
|
4371
|
+
}
|
|
4372
|
+
function readFailure(error) {
|
|
4373
|
+
if (error instanceof CliError) return {
|
|
4374
|
+
message: error.message,
|
|
4375
|
+
exitCode: error.exitCode
|
|
4376
|
+
};
|
|
4377
|
+
if (error instanceof require_migrations_path.BoardError && error.code === "board_column_rules_unmet") return {
|
|
4378
|
+
message: describeColumnRefusal(error.details),
|
|
4379
|
+
exitCode: 1
|
|
4380
|
+
};
|
|
4381
|
+
if (error instanceof require_migrations_path.BoardError) {
|
|
4382
|
+
const details = error.details ? ` ${JSON.stringify(error.details)}` : "";
|
|
4383
|
+
return {
|
|
4384
|
+
message: `${error.code}${details}`,
|
|
4385
|
+
exitCode: 1
|
|
4386
|
+
};
|
|
4387
|
+
}
|
|
4388
|
+
return {
|
|
4389
|
+
message: error instanceof Error ? error.message : String(error),
|
|
4390
|
+
exitCode: 1
|
|
4391
|
+
};
|
|
4392
|
+
}
|
|
4342
4393
|
/**
|
|
4343
|
-
*
|
|
4344
|
-
*
|
|
4345
|
-
*
|
|
4394
|
+
* The chain under a failure, one indented line per link.
|
|
4395
|
+
*
|
|
4396
|
+
* Bounded, because `cause` is an ordinary property and nothing stops it
|
|
4397
|
+
* pointing back at the error it came from; a chain longer than this has stopped
|
|
4398
|
+
* explaining anything anyway.
|
|
4346
4399
|
*/
|
|
4347
|
-
|
|
4400
|
+
function describeCauses(error, depth) {
|
|
4401
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
4402
|
+
if (cause === void 0 || cause === null || depth > CAUSE_DEPTH_LIMIT) return [];
|
|
4403
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
4404
|
+
return [`${" ".repeat(depth)}Caused by: ${message}`, ...describeCauses(cause, depth + 1)];
|
|
4405
|
+
}
|
|
4406
|
+
//#endregion
|
|
4407
|
+
//#region src/cli/setup/instructions.ts
|
|
4348
4408
|
/**
|
|
4349
|
-
* The
|
|
4350
|
-
*
|
|
4351
|
-
*
|
|
4352
|
-
*
|
|
4409
|
+
* The instruction block kanbo writes into an agent's standing instructions,
|
|
4410
|
+
* and the three things done to it: written, found again, taken out.
|
|
4411
|
+
*
|
|
4412
|
+
* Two versions of the block exist. The project block says "this project is on
|
|
4413
|
+
* a board"; the global one, for a person's user-level file, says "a project
|
|
4414
|
+
* with `.kanbo/` is on a board" and binds nothing — one user file serves every
|
|
4415
|
+
* project, and most of them have no board.
|
|
4353
4416
|
*/
|
|
4354
|
-
|
|
4417
|
+
/** The markers that make the block something this tool can find again. */
|
|
4418
|
+
const INSTRUCTION_START = "<!-- KANBO_START -->";
|
|
4419
|
+
const INSTRUCTION_END = "<!-- KANBO_END -->";
|
|
4355
4420
|
/**
|
|
4356
|
-
* What
|
|
4357
|
-
*
|
|
4358
|
-
*
|
|
4359
|
-
* method is the person's own instructions to their agent, and the board only
|
|
4360
|
-
* insists on the part that keeps the card honest.
|
|
4421
|
+
* What to run and when, and nothing about how to do the work: the method is
|
|
4422
|
+
* the person's own instructions to their agent. The rules are the sentences
|
|
4423
|
+
* `ops/agent-rules.ts` holds for every surface, with the command beside each.
|
|
4361
4424
|
*/
|
|
4425
|
+
const RULE_LINES = [
|
|
4426
|
+
"- Take a card from `kanbo ready`.",
|
|
4427
|
+
`- ${MOVE_CARD_RULE} (\`kanbo card move <id> <column>\`)`,
|
|
4428
|
+
`- ${STATUS_LINE_RULE} (\`kanbo card status-line <id> --text "..."\`)`,
|
|
4429
|
+
`- ${SUBTASK_RULE} (\`kanbo card create --description "..." --parent <id>\`)`,
|
|
4430
|
+
`- ${COMMENT_RULE} (\`kanbo card comment <id> --content "..."\`)`,
|
|
4431
|
+
`- ${WAIT_FOR_PERSON_RULE} (\`kanbo card wait-approval <id>\`)`,
|
|
4432
|
+
`- ${PERSON_ONLY_RULE}`,
|
|
4433
|
+
`- ${OWN_SESSION_RULE} (\`kanbo run start <id> --agent <name> --session <ref>\`)`,
|
|
4434
|
+
"- Run `kanbo capabilities` for the full list of tools, commands and rules."
|
|
4435
|
+
];
|
|
4436
|
+
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
4362
4437
|
const INSTRUCTION_BLOCK = [
|
|
4363
4438
|
INSTRUCTION_START,
|
|
4364
4439
|
"## Kanbo board",
|
|
4365
4440
|
"",
|
|
4366
4441
|
"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.",
|
|
4367
4442
|
"",
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
"
|
|
4375
|
-
"
|
|
4443
|
+
...RULE_LINES,
|
|
4444
|
+
INSTRUCTION_END
|
|
4445
|
+
].join("\n");
|
|
4446
|
+
/** The block `kanbo init --global` writes into a person's user-level file. */
|
|
4447
|
+
const GLOBAL_INSTRUCTION_BLOCK = [
|
|
4448
|
+
INSTRUCTION_START,
|
|
4449
|
+
"## Kanbo boards",
|
|
4450
|
+
"",
|
|
4451
|
+
"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`.",
|
|
4452
|
+
"",
|
|
4453
|
+
...RULE_LINES,
|
|
4376
4454
|
INSTRUCTION_END
|
|
4377
4455
|
].join("\n");
|
|
4378
|
-
function registerInitCommand(program) {
|
|
4379
|
-
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) => {
|
|
4380
|
-
const projectDir = process.cwd();
|
|
4381
|
-
if (options.file !== void 0) {
|
|
4382
|
-
await initOwnFile(projectDir, options);
|
|
4383
|
-
return;
|
|
4384
|
-
}
|
|
4385
|
-
const target = resolveInitTarget(projectDir, options);
|
|
4386
|
-
if (target.kind === "postgres") {
|
|
4387
|
-
await initExternalBoard(projectDir, target, options);
|
|
4388
|
-
return;
|
|
4389
|
-
}
|
|
4390
|
-
await runBoardCommand(options, "read", async (session) => await initProject(projectDir, target, session.workspace, options));
|
|
4391
|
-
});
|
|
4392
|
-
}
|
|
4393
4456
|
/**
|
|
4394
|
-
*
|
|
4457
|
+
* The block in that file, once.
|
|
4395
4458
|
*
|
|
4396
|
-
*
|
|
4397
|
-
*
|
|
4398
|
-
*
|
|
4399
|
-
*
|
|
4400
|
-
* there is none is the board file itself the board. Always the person's
|
|
4401
|
-
* connection string: binding a project is not something an agent does.
|
|
4459
|
+
* A file that already carries the markers has its block replaced rather than
|
|
4460
|
+
* appended to, so a current block changes nothing at all and an outdated one is
|
|
4461
|
+
* brought forward. Everything outside the markers is the person's own writing
|
|
4462
|
+
* and is never touched.
|
|
4402
4463
|
*/
|
|
4403
|
-
function
|
|
4404
|
-
const
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
kind: "postgres",
|
|
4411
|
-
url
|
|
4412
|
-
} : resolveDbTarget({
|
|
4413
|
-
explicitPath: options.db,
|
|
4414
|
-
cwd: projectDir
|
|
4415
|
-
});
|
|
4464
|
+
function planInstructionBlock(path, block) {
|
|
4465
|
+
const existing = readText(path);
|
|
4466
|
+
const next = withBlock(existing, block);
|
|
4467
|
+
return {
|
|
4468
|
+
path,
|
|
4469
|
+
next: existing === next ? null : next
|
|
4470
|
+
};
|
|
4416
4471
|
}
|
|
4417
4472
|
/**
|
|
4418
|
-
*
|
|
4419
|
-
*
|
|
4420
|
-
*
|
|
4421
|
-
* `kanbo migrate`, and a database that holds no board yet is not a reason to
|
|
4422
|
-
* refuse to write down where it will be — nor could it answer the one question
|
|
4423
|
-
* that matters here, since it has no `workspaces` table to be asked.
|
|
4473
|
+
* The file without the block, or no change when it carries none. The file
|
|
4474
|
+
* itself stays even when nothing is left in it: kanbo does not record which
|
|
4475
|
+
* files it created, and a person's file is not this tool's to delete.
|
|
4424
4476
|
*/
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4477
|
+
function planInstructionBlockRemoval(path) {
|
|
4478
|
+
const existing = readText(path);
|
|
4479
|
+
const bounds = existing === null ? null : findBlock(existing);
|
|
4480
|
+
if (existing === null || bounds === null) return {
|
|
4481
|
+
path,
|
|
4482
|
+
next: null
|
|
4483
|
+
};
|
|
4484
|
+
const joined = [existing.slice(0, bounds.start).replace(/\s*$/, ""), existing.slice(bounds.end).trim()].filter((part) => part.length > 0).join("\n\n");
|
|
4485
|
+
return {
|
|
4486
|
+
path,
|
|
4487
|
+
next: joined ? `${joined}\n` : ""
|
|
4488
|
+
};
|
|
4428
4489
|
}
|
|
4490
|
+
/** The block a file carries, markers included, or `null` when it carries none. */
|
|
4491
|
+
function readInstructionBlock(path) {
|
|
4492
|
+
const existing = readText(path);
|
|
4493
|
+
const bounds = existing === null ? null : findBlock(existing);
|
|
4494
|
+
return existing === null || bounds === null ? null : existing.slice(bounds.start, bounds.end);
|
|
4495
|
+
}
|
|
4496
|
+
function withBlock(existing, block) {
|
|
4497
|
+
if (existing === null || !existing.trim()) return `${block}\n`;
|
|
4498
|
+
const bounds = findBlock(existing);
|
|
4499
|
+
if (bounds) return existing.slice(0, bounds.start) + block + existing.slice(bounds.end);
|
|
4500
|
+
return `${existing.replace(/\s*$/, "")}\n\n${block}\n`;
|
|
4501
|
+
}
|
|
4502
|
+
function findBlock(text) {
|
|
4503
|
+
const start = text.indexOf(INSTRUCTION_START);
|
|
4504
|
+
const end = text.indexOf(INSTRUCTION_END);
|
|
4505
|
+
return start >= 0 && end > start ? {
|
|
4506
|
+
start,
|
|
4507
|
+
end: end + 18
|
|
4508
|
+
} : null;
|
|
4509
|
+
}
|
|
4510
|
+
function readText(path) {
|
|
4511
|
+
return (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : null;
|
|
4512
|
+
}
|
|
4513
|
+
//#endregion
|
|
4514
|
+
//#region src/cli/setup/paths.ts
|
|
4429
4515
|
/**
|
|
4430
|
-
*
|
|
4431
|
-
*
|
|
4432
|
-
*
|
|
4433
|
-
* The file is migrated with this package's own chain right here, because
|
|
4434
|
-
* nothing else ever will: a host app migrates its own database and an
|
|
4435
|
-
* external database is migrated by `kanbo migrate`, but a file of this
|
|
4436
|
-
* package's own has no other owner to do it later — `kanbo migrate` runs the
|
|
4437
|
-
* same chain again afterwards, and idempotently, for the day it falls behind a
|
|
4438
|
-
* newer build (ruling 5-5). The marker written right after is what every later
|
|
4439
|
-
* command reads to know not to look for a host `workspaces` table in it
|
|
4440
|
-
* (ruling 5-4).
|
|
4516
|
+
* Where each agent tool keeps the files kanbo writes into, for one project and
|
|
4517
|
+
* for the person's whole machine.
|
|
4441
4518
|
*
|
|
4442
|
-
*
|
|
4443
|
-
*
|
|
4444
|
-
*
|
|
4445
|
-
* an id and a card-key stem — is either typed, already in the binding, or the
|
|
4446
|
-
* folder's own name. Asking a host database first would turn `--workspace <id>`
|
|
4447
|
-
* into a refusal on every machine that has one, and leave a board file behind
|
|
4448
|
-
* with no binding beside it.
|
|
4519
|
+
* Resolved on every call rather than at import, so `HOME` and `CODEX_HOME` set
|
|
4520
|
+
* by a test (or a person) are the ones obeyed. Each path was checked against
|
|
4521
|
+
* the tool's own documentation; `docs/agents.md` lists the pages.
|
|
4449
4522
|
*/
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4523
|
+
/** The tools whose user-level instruction file `kanbo init --global` can write. */
|
|
4524
|
+
const GLOBAL_INSTRUCTION_CLIENTS = [
|
|
4525
|
+
"claude",
|
|
4526
|
+
"codex",
|
|
4527
|
+
"gemini"
|
|
4528
|
+
];
|
|
4529
|
+
/** The files a project's agents read their standing instructions from. */
|
|
4530
|
+
const PROJECT_INSTRUCTION_FILES = {
|
|
4531
|
+
claude: "CLAUDE.md",
|
|
4532
|
+
agents: "AGENTS.md"
|
|
4533
|
+
};
|
|
4534
|
+
/** The tools that can be told about the board's MCP server. */
|
|
4535
|
+
const MCP_CLIENTS = [
|
|
4536
|
+
"claude",
|
|
4537
|
+
"codex",
|
|
4538
|
+
"cursor"
|
|
4539
|
+
];
|
|
4540
|
+
/** Where Codex keeps its global files: `$CODEX_HOME`, else `~/.codex`. */
|
|
4541
|
+
function codexHome() {
|
|
4542
|
+
return process.env.CODEX_HOME?.trim() || (0, node_path.join)((0, node_os.homedir)(), ".codex");
|
|
4543
|
+
}
|
|
4544
|
+
/** The user-level instruction file of each tool. */
|
|
4545
|
+
function globalInstructionPath(client) {
|
|
4546
|
+
switch (client) {
|
|
4547
|
+
case "claude": return (0, node_path.join)((0, node_os.homedir)(), ".claude", "CLAUDE.md");
|
|
4548
|
+
case "codex": return (0, node_path.join)(codexHome(), "AGENTS.md");
|
|
4549
|
+
case "gemini": return (0, node_path.join)((0, node_os.homedir)(), ".gemini", "GEMINI.md");
|
|
4550
|
+
}
|
|
4463
4551
|
}
|
|
4464
4552
|
/**
|
|
4465
|
-
*
|
|
4466
|
-
*
|
|
4467
|
-
*
|
|
4468
|
-
* A new board is built beside its own path and moved onto it at the end, so a
|
|
4469
|
-
* migration that fails half-way leaves no half-built file for the next command
|
|
4470
|
-
* to open and believe in. `rename` within one directory is the filesystem's own
|
|
4471
|
-
* atomic swap; a temporary name elsewhere would be a copy across devices and
|
|
4472
|
-
* not atomic at all. A file that is already there is a different question — it
|
|
4473
|
-
* may hold this project's cards — so it is migrated in place, idempotently,
|
|
4474
|
-
* which is what makes a second `kanbo init --file` a no-op.
|
|
4475
|
-
*
|
|
4476
|
-
* A file that is there and is *not* this package's is refused, for the reason
|
|
4477
|
-
* any careful host refuses it: migrating a board into somebody's own
|
|
4478
|
-
* database leaves it readable but contaminated, and only a different path
|
|
4479
|
-
* helps.
|
|
4553
|
+
* The user-level MCP configuration of each tool. Claude Code's is
|
|
4554
|
+
* `~/.claude.json`, its own state file: kanbo reads it but never writes it —
|
|
4555
|
+
* `claude mcp add` and `claude mcp remove` do.
|
|
4480
4556
|
*/
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
return;
|
|
4557
|
+
function globalMcpConfigPath(client) {
|
|
4558
|
+
switch (client) {
|
|
4559
|
+
case "claude": return (0, node_path.join)((0, node_os.homedir)(), ".claude.json");
|
|
4560
|
+
case "codex": return (0, node_path.join)(codexHome(), "config.toml");
|
|
4561
|
+
case "cursor": return (0, node_path.join)((0, node_os.homedir)(), ".cursor", "mcp.json");
|
|
4487
4562
|
}
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
`${temporaryPath}-wal`,
|
|
4496
|
-
`${temporaryPath}-shm`
|
|
4497
|
-
]) (0, node_fs.rmSync)(leftover, { force: true });
|
|
4498
|
-
throw error;
|
|
4563
|
+
}
|
|
4564
|
+
/** The project-level MCP configuration of each tool. */
|
|
4565
|
+
function projectMcpConfigPath(projectDir, client) {
|
|
4566
|
+
switch (client) {
|
|
4567
|
+
case "claude": return (0, node_path.join)(projectDir, ".mcp.json");
|
|
4568
|
+
case "codex": return (0, node_path.join)(projectDir, ".codex", "config.toml");
|
|
4569
|
+
case "cursor": return (0, node_path.join)(projectDir, ".cursor", "mcp.json");
|
|
4499
4570
|
}
|
|
4500
4571
|
}
|
|
4501
|
-
/**
|
|
4502
|
-
|
|
4572
|
+
/**
|
|
4573
|
+
* Every executable of that name on `PATH`, in the order a shell would find
|
|
4574
|
+
* them. A path with a separator in it is looked at directly, as a shell does.
|
|
4575
|
+
*/
|
|
4576
|
+
function findAllOnPath(command) {
|
|
4577
|
+
if ((0, node_path.isAbsolute)(command) || command.includes("/")) return isExecutableFile(command) ? [command] : [];
|
|
4578
|
+
const extensions = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
4579
|
+
const found = [];
|
|
4580
|
+
for (const directory of (process.env.PATH ?? "").split(node_path.delimiter).filter(Boolean)) for (const extension of extensions) {
|
|
4581
|
+
const candidate = (0, node_path.join)(directory, command + extension);
|
|
4582
|
+
if (isExecutableFile(candidate) && !found.includes(candidate)) found.push(candidate);
|
|
4583
|
+
}
|
|
4584
|
+
return found;
|
|
4585
|
+
}
|
|
4586
|
+
/** The executable a shell would start for that command, or `null` when there is none. */
|
|
4587
|
+
function findOnPath(command) {
|
|
4588
|
+
return findAllOnPath(command)[0] ?? null;
|
|
4589
|
+
}
|
|
4590
|
+
function isExecutableFile(path) {
|
|
4591
|
+
try {
|
|
4592
|
+
if (!(0, node_fs.statSync)(path).isFile()) return false;
|
|
4593
|
+
(0, node_fs.accessSync)(path, node_fs.constants.X_OK);
|
|
4594
|
+
return true;
|
|
4595
|
+
} catch {
|
|
4596
|
+
return false;
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4599
|
+
//#endregion
|
|
4600
|
+
//#region src/cli/setup/mcp-config.ts
|
|
4601
|
+
/**
|
|
4602
|
+
* The board's MCP server entry, in the two formats the tools keep it in: the
|
|
4603
|
+
* `mcpServers` JSON map Claude Code (`.mcp.json`) and Cursor (`mcp.json`) read,
|
|
4604
|
+
* and the `[mcp_servers.<name>]` TOML table Codex reads (`config.toml`). Each
|
|
4605
|
+
* is planned before anything is written, found again by the doctor, and taken
|
|
4606
|
+
* out by `kanbo uninstall` without touching any other server.
|
|
4607
|
+
*/
|
|
4608
|
+
/** The name the board's MCP server is registered under, in every tool. */
|
|
4609
|
+
const MCP_SERVER_NAME = "kanbo";
|
|
4610
|
+
/** How every registration kanbo writes starts the server. */
|
|
4611
|
+
const MCP_COMMAND = "kanbo";
|
|
4612
|
+
const MCP_ARGS = ["mcp"];
|
|
4613
|
+
/** The line that registers the server with Claude Code for every project of this user. */
|
|
4614
|
+
const CLAUDE_USER_ADD = [
|
|
4615
|
+
"claude",
|
|
4616
|
+
"mcp",
|
|
4617
|
+
"add",
|
|
4618
|
+
"--scope",
|
|
4619
|
+
"user",
|
|
4620
|
+
MCP_SERVER_NAME,
|
|
4621
|
+
"--",
|
|
4622
|
+
MCP_COMMAND,
|
|
4623
|
+
...MCP_ARGS
|
|
4624
|
+
];
|
|
4625
|
+
/** The line that takes that registration out again. */
|
|
4626
|
+
const CLAUDE_USER_REMOVE = [
|
|
4627
|
+
"claude",
|
|
4628
|
+
"mcp",
|
|
4629
|
+
"remove",
|
|
4630
|
+
MCP_SERVER_NAME,
|
|
4631
|
+
"--scope",
|
|
4632
|
+
"user"
|
|
4633
|
+
];
|
|
4634
|
+
/**
|
|
4635
|
+
* A Codex table header naming this server or one of its subtables
|
|
4636
|
+
* (`[mcp_servers.kanbo.env]`), in every spelling TOML allows for the key: bare,
|
|
4637
|
+
* double-quoted and single-quoted all name the same server, and a check that
|
|
4638
|
+
* only knew the bare one would write a second table beside a registration the
|
|
4639
|
+
* person already has.
|
|
4640
|
+
*/
|
|
4641
|
+
const CODEX_HEADER_PATTERN = new RegExp(String.raw`^\s*\[\s*mcp_servers\s*\.\s*(?:${MCP_SERVER_NAME}|"${MCP_SERVER_NAME}"|'${MCP_SERVER_NAME}')\s*(\.[^\]]*)?\]`);
|
|
4642
|
+
/** Any table header, which is where a table's keys end. */
|
|
4643
|
+
const TOML_HEADER_PATTERN = /^\s*\[/;
|
|
4644
|
+
/** A `command = "..."` line, either string spelling. */
|
|
4645
|
+
const TOML_COMMAND_PATTERN = /^\s*command\s*=\s*(?:"([^"]*)"|'([^']*)')/m;
|
|
4646
|
+
/**
|
|
4647
|
+
* The `mcpServers` map, merged rather than replaced: the file may already
|
|
4648
|
+
* describe servers that have nothing to do with this board. A `kanbo` entry
|
|
4649
|
+
* that is already there belongs to the person — it may point at a different
|
|
4650
|
+
* binary or carry arguments of its own — and is left as it is.
|
|
4651
|
+
*/
|
|
4652
|
+
function planJsonMcpServer(path) {
|
|
4653
|
+
const existing = readJsonConfig(path);
|
|
4654
|
+
if (existing.mcpServers && "kanbo" in existing.mcpServers) return {
|
|
4655
|
+
path,
|
|
4656
|
+
next: null
|
|
4657
|
+
};
|
|
4658
|
+
const next = {
|
|
4659
|
+
...existing,
|
|
4660
|
+
mcpServers: {
|
|
4661
|
+
...existing.mcpServers,
|
|
4662
|
+
[MCP_SERVER_NAME]: {
|
|
4663
|
+
type: "stdio",
|
|
4664
|
+
command: MCP_COMMAND,
|
|
4665
|
+
args: [...MCP_ARGS]
|
|
4666
|
+
}
|
|
4667
|
+
}
|
|
4668
|
+
};
|
|
4669
|
+
return {
|
|
4670
|
+
path,
|
|
4671
|
+
next: `${JSON.stringify(next, null, 2)}\n`
|
|
4672
|
+
};
|
|
4673
|
+
}
|
|
4674
|
+
/**
|
|
4675
|
+
* The same file without the `kanbo` entry. Everything else stays, the
|
|
4676
|
+
* `mcpServers` object included when nothing else is left in it: kanbo does not
|
|
4677
|
+
* record which files it created, so it leaves the structure rather than guess.
|
|
4678
|
+
* A file it cannot read as a JSON object is not touched at all.
|
|
4679
|
+
*/
|
|
4680
|
+
function planJsonMcpServerRemoval(path) {
|
|
4681
|
+
const existing = readJsonIfObject(path);
|
|
4682
|
+
if (!existing || !isPlainObject(existing.mcpServers) || !("kanbo" in existing.mcpServers)) return {
|
|
4683
|
+
path,
|
|
4684
|
+
next: null
|
|
4685
|
+
};
|
|
4686
|
+
const { [MCP_SERVER_NAME]: _removed, ...others } = existing.mcpServers;
|
|
4687
|
+
return {
|
|
4688
|
+
path,
|
|
4689
|
+
next: `${JSON.stringify({
|
|
4690
|
+
...existing,
|
|
4691
|
+
mcpServers: others
|
|
4692
|
+
}, null, 2)}\n`
|
|
4693
|
+
};
|
|
4694
|
+
}
|
|
4695
|
+
/**
|
|
4696
|
+
* The command a JSON registration starts the server with: `undefined` when the
|
|
4697
|
+
* file registers no `kanbo` server, `null` when it does but names no command
|
|
4698
|
+
* (a URL server, say).
|
|
4699
|
+
*/
|
|
4700
|
+
function readJsonMcpCommand(path) {
|
|
4701
|
+
const servers = readJsonIfObject(path)?.mcpServers;
|
|
4702
|
+
if (!isPlainObject(servers) || !isPlainObject(servers["kanbo"])) return;
|
|
4703
|
+
const command = servers[MCP_SERVER_NAME].command;
|
|
4704
|
+
return typeof command === "string" && command.trim() ? command.trim() : null;
|
|
4705
|
+
}
|
|
4706
|
+
/** Codex keeps its servers in TOML tables, so the table is appended rather than merged. */
|
|
4707
|
+
function planCodexMcpServer(path) {
|
|
4708
|
+
const existing = (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : "";
|
|
4709
|
+
if (findCodexSections(existing).length > 0) return {
|
|
4710
|
+
path,
|
|
4711
|
+
next: null
|
|
4712
|
+
};
|
|
4713
|
+
const table = `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "${MCP_COMMAND}"\nargs = ${JSON.stringify(MCP_ARGS)}\n`;
|
|
4714
|
+
return {
|
|
4715
|
+
path,
|
|
4716
|
+
next: existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n${table}` : table
|
|
4717
|
+
};
|
|
4718
|
+
}
|
|
4719
|
+
/**
|
|
4720
|
+
* The same file without the `kanbo` table and its subtables. A table runs from
|
|
4721
|
+
* its header to the next header, so every other table — and every line before
|
|
4722
|
+
* the first — is kept byte for byte.
|
|
4723
|
+
*/
|
|
4724
|
+
function planCodexMcpServerRemoval(path) {
|
|
4725
|
+
if (!(0, node_fs.existsSync)(path)) return {
|
|
4726
|
+
path,
|
|
4727
|
+
next: null
|
|
4728
|
+
};
|
|
4729
|
+
const existing = (0, node_fs.readFileSync)(path, "utf8");
|
|
4730
|
+
const sections = findCodexSections(existing);
|
|
4731
|
+
if (sections.length === 0) return {
|
|
4732
|
+
path,
|
|
4733
|
+
next: null
|
|
4734
|
+
};
|
|
4735
|
+
const lines = existing.split("\n");
|
|
4736
|
+
const removed = new Set(sections.flatMap(({ start, end }) => range(start, end)));
|
|
4737
|
+
const next = lines.filter((_, index) => !removed.has(index)).join("\n").replace(/^\n+/, "");
|
|
4738
|
+
return {
|
|
4739
|
+
path,
|
|
4740
|
+
next: next.trim() ? `${next.replace(/\s*$/, "")}\n` : ""
|
|
4741
|
+
};
|
|
4742
|
+
}
|
|
4743
|
+
/** The command the Codex table starts the server with, with the same `undefined`/`null` meaning as the JSON one. */
|
|
4744
|
+
function readCodexMcpCommand(path) {
|
|
4745
|
+
if (!(0, node_fs.existsSync)(path)) return;
|
|
4746
|
+
const text = (0, node_fs.readFileSync)(path, "utf8");
|
|
4747
|
+
const main = findCodexSections(text).find((section) => section.main);
|
|
4748
|
+
if (!main) return;
|
|
4749
|
+
const body = text.split("\n").slice(main.start, main.end).join("\n");
|
|
4750
|
+
const match = TOML_COMMAND_PATTERN.exec(body);
|
|
4751
|
+
return match ? match[1] ?? match[2] ?? null : null;
|
|
4752
|
+
}
|
|
4753
|
+
/** Is the server registered with Claude Code for every project of this user (`~/.claude.json`, read only)? */
|
|
4754
|
+
function readClaudeUserMcpCommand() {
|
|
4755
|
+
return readJsonMcpCommand(globalMcpConfigPath("claude"));
|
|
4756
|
+
}
|
|
4757
|
+
/**
|
|
4758
|
+
* Run `claude mcp …` when Claude Code is here to run it, and hand the line to
|
|
4759
|
+
* the person when it is not — or when it failed, with the first line it said.
|
|
4760
|
+
* `~/.claude.json` is Claude Code's own state file and is never edited by hand.
|
|
4761
|
+
*/
|
|
4762
|
+
function runClaudeMcp(argv) {
|
|
4763
|
+
const command = argv.join(" ");
|
|
4764
|
+
const executable = findOnPath(argv[0]);
|
|
4765
|
+
if (!executable) return {
|
|
4766
|
+
state: "manual",
|
|
4767
|
+
command,
|
|
4768
|
+
reason: `${argv[0]} is not on PATH`
|
|
4769
|
+
};
|
|
4770
|
+
const result = (0, node_child_process.spawnSync)(executable, argv.slice(1), {
|
|
4771
|
+
encoding: "utf8",
|
|
4772
|
+
timeout: 6e4
|
|
4773
|
+
});
|
|
4774
|
+
if (result.status === 0) return {
|
|
4775
|
+
state: "ran",
|
|
4776
|
+
command
|
|
4777
|
+
};
|
|
4778
|
+
const said = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim().split("\n")[0];
|
|
4779
|
+
return {
|
|
4780
|
+
state: "manual",
|
|
4781
|
+
command,
|
|
4782
|
+
reason: result.error ? result.error.message : `${argv[0]} exited ${result.status ?? "on a signal"}${said ? `: ${said}` : ""}`
|
|
4783
|
+
};
|
|
4784
|
+
}
|
|
4785
|
+
/** The line ranges (`end` exclusive) of the `kanbo` table and its subtables. */
|
|
4786
|
+
function findCodexSections(text) {
|
|
4787
|
+
const lines = text.split("\n");
|
|
4788
|
+
const sections = [];
|
|
4789
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
4790
|
+
const match = CODEX_HEADER_PATTERN.exec(lines[index]);
|
|
4791
|
+
if (!match) continue;
|
|
4792
|
+
let end = index + 1;
|
|
4793
|
+
while (end < lines.length && !TOML_HEADER_PATTERN.test(lines[end])) end += 1;
|
|
4794
|
+
sections.push({
|
|
4795
|
+
start: index,
|
|
4796
|
+
end,
|
|
4797
|
+
main: match[1] === void 0
|
|
4798
|
+
});
|
|
4799
|
+
index = end - 1;
|
|
4800
|
+
}
|
|
4801
|
+
return sections;
|
|
4802
|
+
}
|
|
4803
|
+
function range(start, end) {
|
|
4804
|
+
return Array.from({ length: end - start }, (_, offset) => start + offset);
|
|
4805
|
+
}
|
|
4806
|
+
/**
|
|
4807
|
+
* The tool's configuration file as an object to merge into — or a refusal.
|
|
4808
|
+
*
|
|
4809
|
+
* A file that is valid JSON but not an object (a list, a string, `null`) is
|
|
4810
|
+
* something this command has no idea how to merge into, and spreading it would
|
|
4811
|
+
* quietly throw the person's file away. The file is left exactly as it is and
|
|
4812
|
+
* they are told which one to look at.
|
|
4813
|
+
*/
|
|
4814
|
+
function readJsonConfig(path) {
|
|
4815
|
+
if (!(0, node_fs.existsSync)(path)) return {};
|
|
4816
|
+
let parsed;
|
|
4817
|
+
try {
|
|
4818
|
+
parsed = JSON.parse((0, node_fs.readFileSync)(path, "utf8"));
|
|
4819
|
+
} catch {
|
|
4820
|
+
throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo init again.`);
|
|
4821
|
+
}
|
|
4822
|
+
if (!isPlainObject(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo init again.`);
|
|
4823
|
+
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.`);
|
|
4824
|
+
return parsed;
|
|
4825
|
+
}
|
|
4826
|
+
/** The file as a JSON object, or `null` when it is missing or anything else — for reading and removal, which never refuse. */
|
|
4827
|
+
function readJsonIfObject(path) {
|
|
4828
|
+
if (!(0, node_fs.existsSync)(path)) return null;
|
|
4829
|
+
try {
|
|
4830
|
+
const parsed = JSON.parse((0, node_fs.readFileSync)(path, "utf8"));
|
|
4831
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
4832
|
+
} catch {
|
|
4833
|
+
return null;
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
function isPlainObject(value) {
|
|
4837
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4838
|
+
}
|
|
4839
|
+
//#endregion
|
|
4840
|
+
//#region src/cli/doctor.ts
|
|
4841
|
+
/** How long an external board may take to answer before the doctor gives up on it. */
|
|
4842
|
+
const POSTGRES_TIMEOUT_MS = 5e3;
|
|
4843
|
+
async function collectDoctorFindings(input) {
|
|
4844
|
+
const findings = [{
|
|
4845
|
+
check: "version",
|
|
4846
|
+
status: "ok",
|
|
4847
|
+
detail: `kanbo ${KANBO_PACKAGE_VERSION}${input.self ? ` (${input.self})` : ""}`
|
|
4848
|
+
}, checkPath(input.self)];
|
|
4849
|
+
const project = findKanboProject(input.cwd);
|
|
4850
|
+
findings.push(checkProject(project));
|
|
4851
|
+
const target = project?.binding ? resolveTarget(project.root) : null;
|
|
4852
|
+
if (project?.binding && !project.binding.databaseUrl) findings.push(await checkSqlite());
|
|
4853
|
+
if (project?.binding && findings.every((finding) => finding.check !== "sqlite" || finding.status === "ok")) findings.push(...await checkBoard(target));
|
|
4854
|
+
findings.push(...checkInstructions(project?.root ?? null));
|
|
4855
|
+
const registrations = findRegistrations(project?.root ?? null);
|
|
4856
|
+
findings.push(...checkRegistrations(registrations));
|
|
4857
|
+
if (project?.binding && !(target instanceof Error)) findings.push(await checkHandshake(registrations, project.root, input.handshakeTimeoutMs));
|
|
4858
|
+
findings.push(checkActor());
|
|
4859
|
+
return findings;
|
|
4860
|
+
}
|
|
4861
|
+
/** Is the `kanbo` a client would start this one? */
|
|
4862
|
+
function checkPath(self) {
|
|
4863
|
+
const all = findAllOnPath(MCP_COMMAND);
|
|
4864
|
+
if (all.length === 0) return {
|
|
4865
|
+
check: "path",
|
|
4866
|
+
status: "warn",
|
|
4867
|
+
detail: "No kanbo on PATH: a shell or MCP client that runs `kanbo` will not find it.",
|
|
4868
|
+
fix: "Install kanbo globally (see docs/installation.md), or put the directory that holds it on PATH."
|
|
4869
|
+
};
|
|
4870
|
+
const first = all[0];
|
|
4871
|
+
if (self && realPath(first) !== realPath(self)) return {
|
|
4872
|
+
check: "path",
|
|
4873
|
+
status: "warn",
|
|
4874
|
+
detail: `${first} comes first on PATH and is not this kanbo (${self}): agents and MCP clients start that one.`,
|
|
4875
|
+
fix: "Remove the other install, or put this one first on PATH."
|
|
4876
|
+
};
|
|
4877
|
+
return {
|
|
4878
|
+
check: "path",
|
|
4879
|
+
status: "ok",
|
|
4880
|
+
detail: `kanbo on PATH: ${first}`
|
|
4881
|
+
};
|
|
4882
|
+
}
|
|
4883
|
+
/** The nearest folder at or above `cwd` with a `.kanbo/` directory, and its binding. */
|
|
4884
|
+
function findKanboProject(cwd) {
|
|
4885
|
+
let directory = (0, node_path.resolve)(cwd);
|
|
4886
|
+
while (true) {
|
|
4887
|
+
const kanboDirectory = (0, node_path.join)(directory, (0, node_path.dirname)(BINDING_FILE_PATH));
|
|
4888
|
+
if ((0, node_fs.existsSync)(kanboDirectory) && (0, node_fs.statSync)(kanboDirectory).isDirectory()) return {
|
|
4889
|
+
root: directory,
|
|
4890
|
+
binding: readBinding((0, node_path.join)(directory, BINDING_FILE_PATH))
|
|
4891
|
+
};
|
|
4892
|
+
const parent = (0, node_path.dirname)(directory);
|
|
4893
|
+
if (parent === directory) return null;
|
|
4894
|
+
directory = parent;
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
function checkProject(project) {
|
|
4898
|
+
if (!project) return {
|
|
4899
|
+
check: "binding",
|
|
4900
|
+
status: "warn",
|
|
4901
|
+
detail: "Not inside a kanbo project (no .kanbo/ here or above); board checks are skipped.",
|
|
4902
|
+
fix: "Run kanbo init --file in a project to give it a board."
|
|
4903
|
+
};
|
|
4904
|
+
const path = (0, node_path.join)(project.root, BINDING_FILE_PATH);
|
|
4905
|
+
if (!project.binding) return {
|
|
4906
|
+
check: "binding",
|
|
4907
|
+
status: "fail",
|
|
4908
|
+
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.`,
|
|
4909
|
+
fix: `Run kanbo init (with --file, or the --database-url you used) in ${project.root} to bind it again.`
|
|
4910
|
+
};
|
|
4911
|
+
return {
|
|
4912
|
+
check: "binding",
|
|
4913
|
+
status: "ok",
|
|
4914
|
+
detail: `${path}: workspace ${project.binding.workspaceId}`
|
|
4915
|
+
};
|
|
4916
|
+
}
|
|
4917
|
+
function resolveTarget(root) {
|
|
4918
|
+
try {
|
|
4919
|
+
return resolveDbTarget({ cwd: root });
|
|
4920
|
+
} catch (error) {
|
|
4921
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4924
|
+
/** The native module a board file needs, loaded on a database of its own in memory. */
|
|
4925
|
+
async function checkSqlite() {
|
|
4926
|
+
try {
|
|
4927
|
+
(await require_migrate.openBoardDatabase(":memory:")).close();
|
|
4928
|
+
return {
|
|
4929
|
+
check: "sqlite",
|
|
4930
|
+
status: "ok",
|
|
4931
|
+
detail: "better-sqlite3 loads."
|
|
4932
|
+
};
|
|
4933
|
+
} catch (error) {
|
|
4934
|
+
return {
|
|
4935
|
+
check: "sqlite",
|
|
4936
|
+
status: "fail",
|
|
4937
|
+
detail: `better-sqlite3 cannot be loaded: ${describeFailure(error).message}`,
|
|
4938
|
+
fix: "Install better-sqlite3 next to kanbo (npm install -g better-sqlite3 for a global kanbo)."
|
|
4939
|
+
};
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
/** The board itself: it opens, and its schema is this build's. */
|
|
4943
|
+
async function checkBoard(target) {
|
|
4944
|
+
if (target === null) return [];
|
|
4945
|
+
if (target instanceof Error) return [{
|
|
4946
|
+
check: "board",
|
|
4947
|
+
status: "fail",
|
|
4948
|
+
detail: describeFailure(target).message,
|
|
4949
|
+
fix: "Run kanbo init in the project to bind it to a board."
|
|
4950
|
+
}];
|
|
4951
|
+
if (target.kind === "postgres") return [await checkPostgresBoard(target.url)];
|
|
4952
|
+
const board = await require_migrate.openBoardDatabase(target.path);
|
|
4953
|
+
try {
|
|
4954
|
+
require_migrate.assertBoardSchema(board.database);
|
|
4955
|
+
return [{
|
|
4956
|
+
check: "board",
|
|
4957
|
+
status: "ok",
|
|
4958
|
+
detail: `${target.path} opens; its schema is current.`
|
|
4959
|
+
}];
|
|
4960
|
+
} catch (error) {
|
|
4961
|
+
const outdated = error instanceof require_migrations_path.BoardError && error.code === "board_schema_outdated";
|
|
4962
|
+
const ownFile = target.owner === "kanbo";
|
|
4963
|
+
return [{
|
|
4964
|
+
check: "board",
|
|
4965
|
+
status: "fail",
|
|
4966
|
+
detail: outdated ? `${target.path}: ${ownFile ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE}` : `${target.path}: ${describeFailure(error).message}`,
|
|
4967
|
+
fix: ownFile ? "Run kanbo migrate." : "Start the app this database belongs to, so it applies its migrations."
|
|
4968
|
+
}];
|
|
4969
|
+
} finally {
|
|
4970
|
+
board.close();
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
async function checkPostgresBoard(url) {
|
|
4974
|
+
const where = maskDatabaseUrl(url);
|
|
4975
|
+
let timer;
|
|
4976
|
+
const timeout = new Promise((_, reject) => {
|
|
4977
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no answer within ${POSTGRES_TIMEOUT_MS / 1e3}s`)), POSTGRES_TIMEOUT_MS);
|
|
4978
|
+
});
|
|
4979
|
+
try {
|
|
4980
|
+
const board = await Promise.race([openPostgresBoard(url), timeout]);
|
|
4981
|
+
try {
|
|
4982
|
+
await Promise.race([assertInstalledBoard(board.database), timeout]);
|
|
4983
|
+
} finally {
|
|
4984
|
+
board.close();
|
|
4985
|
+
}
|
|
4986
|
+
return {
|
|
4987
|
+
check: "board",
|
|
4988
|
+
status: "ok",
|
|
4989
|
+
detail: `${where} answers; its schema is current.`
|
|
4990
|
+
};
|
|
4991
|
+
} catch (error) {
|
|
4992
|
+
return {
|
|
4993
|
+
check: "board",
|
|
4994
|
+
status: "fail",
|
|
4995
|
+
detail: `${where}: ${describeFailure(error).message}`,
|
|
4996
|
+
fix: "Check the connection string, then run kanbo migrate."
|
|
4997
|
+
};
|
|
4998
|
+
} finally {
|
|
4999
|
+
clearTimeout(timer);
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
/**
|
|
5003
|
+
* Every instruction block kanbo could have written, compared with the one this
|
|
5004
|
+
* build writes. Only files that carry a block are reported: a file without one
|
|
5005
|
+
* is not a fault, but having no block anywhere is worth a warning.
|
|
5006
|
+
*/
|
|
5007
|
+
function checkInstructions(root) {
|
|
5008
|
+
const findings = [];
|
|
5009
|
+
if (root) for (const [target, name] of Object.entries(PROJECT_INSTRUCTION_FILES)) {
|
|
5010
|
+
const path = (0, node_path.join)(root, name);
|
|
5011
|
+
const block = readInstructionBlock(path);
|
|
5012
|
+
if (block !== null) findings.push(blockFinding(path, block === INSTRUCTION_BLOCK, `cd ${root} && kanbo init --instructions ${target} --yes`));
|
|
5013
|
+
}
|
|
5014
|
+
for (const client of GLOBAL_INSTRUCTION_CLIENTS) {
|
|
5015
|
+
const path = globalInstructionPath(client);
|
|
5016
|
+
const block = readInstructionBlock(path);
|
|
5017
|
+
if (block !== null) findings.push(blockFinding(path, block === GLOBAL_INSTRUCTION_BLOCK, `kanbo init --global --instructions ${client}`));
|
|
5018
|
+
}
|
|
5019
|
+
if (findings.length === 0) findings.push({
|
|
5020
|
+
check: "instructions",
|
|
5021
|
+
status: "warn",
|
|
5022
|
+
detail: "No kanbo instruction block in this project's or your own agent files: agents learn the rules only from the MCP server.",
|
|
5023
|
+
fix: "Run kanbo init --instructions claude in the project, or kanbo init --global."
|
|
5024
|
+
});
|
|
5025
|
+
return findings;
|
|
5026
|
+
}
|
|
5027
|
+
function blockFinding(path, current, fix) {
|
|
5028
|
+
return current ? {
|
|
5029
|
+
check: "instructions",
|
|
5030
|
+
status: "ok",
|
|
5031
|
+
detail: `${path}: block is current.`
|
|
5032
|
+
} : {
|
|
5033
|
+
check: "instructions",
|
|
5034
|
+
status: "fail",
|
|
5035
|
+
detail: `${path}: block differs from the one this kanbo writes (stale).`,
|
|
5036
|
+
fix: `Run ${fix} to rewrite it.`
|
|
5037
|
+
};
|
|
5038
|
+
}
|
|
5039
|
+
/** Every `kanbo` registration in the project's and the person's own tool configuration. */
|
|
5040
|
+
function findRegistrations(root) {
|
|
5041
|
+
const found = [];
|
|
5042
|
+
for (const client of MCP_CLIENTS) {
|
|
5043
|
+
const paths = [...root ? [projectMcpConfigPath(root, client)] : [], globalMcpConfigPath(client)];
|
|
5044
|
+
for (const path of paths) {
|
|
5045
|
+
const command = client === "codex" ? readCodexMcpCommand(path) : readJsonMcpCommand(path);
|
|
5046
|
+
if (command !== void 0) found.push({
|
|
5047
|
+
client,
|
|
5048
|
+
path,
|
|
5049
|
+
command
|
|
5050
|
+
});
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
return found;
|
|
5054
|
+
}
|
|
5055
|
+
function checkRegistrations(registrations) {
|
|
5056
|
+
if (registrations.length === 0) return [{
|
|
5057
|
+
check: "mcp",
|
|
5058
|
+
status: "warn",
|
|
5059
|
+
detail: "No MCP client has the kanbo server registered.",
|
|
5060
|
+
fix: "Run kanbo init --mcp claude,codex,cursor in the project, or kanbo init --global."
|
|
5061
|
+
}];
|
|
5062
|
+
return registrations.map(({ client, path, command }) => {
|
|
5063
|
+
const check = `mcp:${client}`;
|
|
5064
|
+
if (command === null) return {
|
|
5065
|
+
check,
|
|
5066
|
+
status: "warn",
|
|
5067
|
+
detail: `${path}: the kanbo entry names no command.`,
|
|
5068
|
+
fix: `Set "command" to kanbo in ${path}.`
|
|
5069
|
+
};
|
|
5070
|
+
const resolved = findOnPath(command);
|
|
5071
|
+
return resolved ? {
|
|
5072
|
+
check,
|
|
5073
|
+
status: "ok",
|
|
5074
|
+
detail: `${path}: starts ${command} (${resolved}).`
|
|
5075
|
+
} : {
|
|
5076
|
+
check,
|
|
5077
|
+
status: "fail",
|
|
5078
|
+
detail: `${path}: starts ${command}, which is not on PATH — the client cannot start the server.`,
|
|
5079
|
+
fix: `Install kanbo globally, or change the command in ${path} to a path that exists.`
|
|
5080
|
+
};
|
|
5081
|
+
});
|
|
5082
|
+
}
|
|
5083
|
+
/**
|
|
5084
|
+
* Start the server a client would start, from the project folder, and ask it
|
|
5085
|
+
* `initialize`. It has to call itself `kanbo` and send its instructions.
|
|
5086
|
+
*/
|
|
5087
|
+
async function checkHandshake(registrations, root, timeoutMs) {
|
|
5088
|
+
const command = registrations.map((entry) => entry.command && findOnPath(entry.command)).find(Boolean) ?? findOnPath("kanbo");
|
|
5089
|
+
if (!command) return {
|
|
5090
|
+
check: "mcp:handshake",
|
|
5091
|
+
status: "warn",
|
|
5092
|
+
detail: "No kanbo to start, so the MCP server was not tried.",
|
|
5093
|
+
fix: "Put kanbo on PATH."
|
|
5094
|
+
};
|
|
5095
|
+
const transport = new _modelcontextprotocol_sdk_client_stdio_js.StdioClientTransport({
|
|
5096
|
+
command,
|
|
5097
|
+
args: ["mcp"],
|
|
5098
|
+
cwd: root,
|
|
5099
|
+
env: stringEnvironment(),
|
|
5100
|
+
stderr: "pipe"
|
|
5101
|
+
});
|
|
5102
|
+
let stderr = "";
|
|
5103
|
+
transport.stderr?.on("data", (chunk) => {
|
|
5104
|
+
stderr += chunk.toString();
|
|
5105
|
+
});
|
|
5106
|
+
const client = new _modelcontextprotocol_sdk_client_index_js.Client({
|
|
5107
|
+
name: "kanbo-doctor",
|
|
5108
|
+
version: KANBO_PACKAGE_VERSION
|
|
5109
|
+
});
|
|
5110
|
+
try {
|
|
5111
|
+
await client.connect(transport, { timeout: timeoutMs });
|
|
5112
|
+
const name = client.getServerVersion()?.name;
|
|
5113
|
+
const instructions = client.getInstructions();
|
|
5114
|
+
if (name !== "kanbo" || !instructions?.trim()) return {
|
|
5115
|
+
check: "mcp:handshake",
|
|
5116
|
+
status: "fail",
|
|
5117
|
+
detail: `${command} mcp answered as ${name ?? "nothing"}${instructions?.trim() ? "" : ", without instructions"}.`,
|
|
5118
|
+
fix: "Put a current kanbo first on PATH."
|
|
5119
|
+
};
|
|
5120
|
+
return {
|
|
5121
|
+
check: "mcp:handshake",
|
|
5122
|
+
status: "ok",
|
|
5123
|
+
detail: `${command} mcp answers initialize as kanbo, with instructions.`
|
|
5124
|
+
};
|
|
5125
|
+
} catch (error) {
|
|
5126
|
+
return {
|
|
5127
|
+
check: "mcp:handshake",
|
|
5128
|
+
status: "fail",
|
|
5129
|
+
detail: `${command} mcp did not answer initialize: ${stderr.trim().split("\n")[0] || describeFailure(error).message}`,
|
|
5130
|
+
fix: "Run kanbo mcp in the project yourself to see why it does not start."
|
|
5131
|
+
};
|
|
5132
|
+
} finally {
|
|
5133
|
+
await client.close().catch(() => {});
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
function checkActor() {
|
|
5137
|
+
if (isAgentShell()) return {
|
|
5138
|
+
check: "actor",
|
|
5139
|
+
status: "warn",
|
|
5140
|
+
detail: "This shell says it belongs to an agent (KANBO_ACTOR_KIND=agent): approve and return refuse here.",
|
|
5141
|
+
fix: "If this is your own terminal, remove KANBO_ACTOR_KIND from your shell profile."
|
|
5142
|
+
};
|
|
5143
|
+
return {
|
|
5144
|
+
check: "actor",
|
|
5145
|
+
status: "ok",
|
|
5146
|
+
detail: "This shell is a person's."
|
|
5147
|
+
};
|
|
5148
|
+
}
|
|
5149
|
+
/** The environment as the SDK wants it: every value a string, so `KANBO_*` reaches the server as it would from a client. */
|
|
5150
|
+
function stringEnvironment() {
|
|
5151
|
+
return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== void 0));
|
|
5152
|
+
}
|
|
5153
|
+
function realPath(path) {
|
|
5154
|
+
try {
|
|
5155
|
+
return (0, node_fs.realpathSync)(path);
|
|
5156
|
+
} catch {
|
|
5157
|
+
return path;
|
|
5158
|
+
}
|
|
5159
|
+
}
|
|
5160
|
+
//#endregion
|
|
5161
|
+
//#region src/cli/commands/doctor.ts
|
|
5162
|
+
/** How long `kanbo mcp` may take to answer `initialize`. */
|
|
5163
|
+
const HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
5164
|
+
const MARKS = {
|
|
5165
|
+
ok: "ok ",
|
|
5166
|
+
warn: "warn",
|
|
5167
|
+
fail: "FAIL"
|
|
5168
|
+
};
|
|
5169
|
+
function registerDoctorCommand(program) {
|
|
5170
|
+
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) => {
|
|
5171
|
+
const findings = await collectDoctorFindings({
|
|
5172
|
+
cwd: process.cwd(),
|
|
5173
|
+
self: process.argv[1] ?? null,
|
|
5174
|
+
handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS
|
|
5175
|
+
});
|
|
5176
|
+
if (options.json) console.log(JSON.stringify({
|
|
5177
|
+
version: KANBO_PACKAGE_VERSION,
|
|
5178
|
+
findings
|
|
5179
|
+
}, null, 2));
|
|
5180
|
+
else for (const finding of findings) {
|
|
5181
|
+
console.log(`${MARKS[finding.status]} ${finding.check.padEnd(14)} ${finding.detail}`);
|
|
5182
|
+
if (finding.fix) console.log(`${" ".repeat(21)}fix: ${finding.fix}`);
|
|
5183
|
+
}
|
|
5184
|
+
const failed = findings.filter((finding) => finding.status === "fail").length;
|
|
5185
|
+
if (failed > 0) throw new CliError(1, `kanbo doctor: ${failed} check${failed === 1 ? "" : "s"} failed.`);
|
|
5186
|
+
});
|
|
5187
|
+
}
|
|
5188
|
+
//#endregion
|
|
5189
|
+
//#region src/default-board-file-path.ts
|
|
5190
|
+
/**
|
|
5191
|
+
* Where a board file of a project's own goes when nobody names a path —
|
|
5192
|
+
* `kanbo init --file` writes it here, and an app that attaches a project's
|
|
5193
|
+
* board file can import the same constant rather than spelling it again.
|
|
5194
|
+
*/
|
|
5195
|
+
const DEFAULT_BOARD_FILE_PATH = (0, node_path.join)(".kanbo", "board.db");
|
|
5196
|
+
//#endregion
|
|
5197
|
+
//#region src/cli/setup/confirm.ts
|
|
5198
|
+
/**
|
|
5199
|
+
* Show a plan of file changes and ask before carrying it out.
|
|
5200
|
+
*
|
|
5201
|
+
* `--yes` is the answer given in advance. A shell with nobody at its terminal
|
|
5202
|
+
* cannot be asked, so it is refused rather than taken as a yes: these commands
|
|
5203
|
+
* write into a person's own configuration, which is the kind of thing a person
|
|
5204
|
+
* says yes to. When the caller asked for machine output, stdout belongs to the
|
|
5205
|
+
* result and the plan goes to stderr.
|
|
5206
|
+
*/
|
|
5207
|
+
async function confirmPlan(input) {
|
|
5208
|
+
(input.machineOutput ? console.error : console.log)(input.preview);
|
|
5209
|
+
if (input.yes) return true;
|
|
5210
|
+
if (!isInteractive()) throw new CliError(1, `Nothing was changed. Run ${input.command} again with --yes to do this without being asked.`);
|
|
5211
|
+
return await (await import("@clack/prompts")).confirm({ message: input.question }) === true;
|
|
5212
|
+
}
|
|
5213
|
+
/** Is there a person on the other end of this shell to ask? */
|
|
5214
|
+
function isInteractive() {
|
|
5215
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
5216
|
+
}
|
|
5217
|
+
//#endregion
|
|
5218
|
+
//#region src/cli/setup/file-change.ts
|
|
5219
|
+
/** Write the change, creating the directory it lives in, and say what happened. */
|
|
5220
|
+
function applyFileChange(change) {
|
|
5221
|
+
if (change.next === null) return {
|
|
5222
|
+
path: change.path,
|
|
5223
|
+
state: "unchanged"
|
|
5224
|
+
};
|
|
5225
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(change.path), { recursive: true });
|
|
5226
|
+
(0, node_fs.writeFileSync)(change.path, change.next);
|
|
5227
|
+
return {
|
|
5228
|
+
path: change.path,
|
|
5229
|
+
state: "written"
|
|
5230
|
+
};
|
|
5231
|
+
}
|
|
5232
|
+
//#endregion
|
|
5233
|
+
//#region src/cli/commands/init-global.ts
|
|
5234
|
+
/**
|
|
5235
|
+
* `kanbo init --global` — this person's own agent tools, told about kanbo once
|
|
5236
|
+
* for every project.
|
|
5237
|
+
*
|
|
5238
|
+
* It binds no board and creates none: the block it writes says "a project with
|
|
5239
|
+
* `.kanbo/` is on a board", and a project becomes one with a plain `kanbo init`.
|
|
5240
|
+
* The MCP registrations are the same `kanbo mcp` every project registration
|
|
5241
|
+
* starts, which resolves the board from the folder the client starts it in.
|
|
5242
|
+
*
|
|
5243
|
+
* Every change is worked out first and shown; nothing is written until the
|
|
5244
|
+
* person says yes (or said `--yes`), and a file that already says it is
|
|
5245
|
+
* reported `unchanged`.
|
|
5246
|
+
*/
|
|
5247
|
+
/** Written when `--instructions` is not given. Gemini is asked for by name. */
|
|
5248
|
+
const DEFAULT_INSTRUCTION_CLIENTS = ["claude", "codex"];
|
|
5249
|
+
/** The flags that are about binding a project, which `--global` does not do. */
|
|
5250
|
+
const PROJECT_ONLY_FLAGS = [
|
|
5251
|
+
["file", "--file"],
|
|
5252
|
+
["db", "--db"],
|
|
5253
|
+
["databaseUrl", "--database-url"],
|
|
5254
|
+
["workspace", "--workspace"],
|
|
5255
|
+
["board", "--board"],
|
|
5256
|
+
["identifier", "--identifier"],
|
|
5257
|
+
["agentUrl", "--agent-url"]
|
|
5258
|
+
];
|
|
5259
|
+
async function initGlobal(options) {
|
|
5260
|
+
const format = readFormat(options);
|
|
5261
|
+
const given = PROJECT_ONLY_FLAGS.filter(([key]) => options[key] !== void 0).map(([, flag]) => flag);
|
|
5262
|
+
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.`);
|
|
5263
|
+
const instructionClients = parseGlobalInstructions(options.instructions);
|
|
5264
|
+
const mcpClients = options.mcp ?? [...MCP_CLIENTS];
|
|
5265
|
+
const instructionChanges = instructionClients.map((client) => ({
|
|
5266
|
+
client,
|
|
5267
|
+
change: planInstructionBlock(globalInstructionPath(client), GLOBAL_INSTRUCTION_BLOCK)
|
|
5268
|
+
}));
|
|
5269
|
+
const fileMcpChanges = mcpClients.filter((client) => client !== "claude").map((client) => ({
|
|
5270
|
+
client,
|
|
5271
|
+
change: planGlobalMcpFile(client)
|
|
5272
|
+
}));
|
|
5273
|
+
const claudeNeeded = mcpClients.includes("claude") && readClaudeUserMcpCommand() === void 0;
|
|
5274
|
+
const pending = [...instructionChanges, ...fileMcpChanges].filter(({ change }) => change.next !== null);
|
|
5275
|
+
let confirmed = true;
|
|
5276
|
+
if (pending.length > 0 || claudeNeeded) confirmed = await confirmPlan({
|
|
5277
|
+
preview: describePlan$1(pending.map(({ change }) => change), claudeNeeded),
|
|
5278
|
+
question: "Make these changes?",
|
|
5279
|
+
yes: options.yes,
|
|
5280
|
+
machineOutput: Boolean(options.json || format),
|
|
5281
|
+
command: "kanbo init --global"
|
|
5282
|
+
});
|
|
5283
|
+
if (!confirmed) throw new CliError(1, "Nothing was changed.");
|
|
5284
|
+
printResult(describeResult(instructionChanges.map(({ client, change }) => ({
|
|
5285
|
+
client,
|
|
5286
|
+
...applyFileChange(change)
|
|
5287
|
+
})), mcpClients.map((client) => {
|
|
5288
|
+
if (client === "claude") return registerWithClaude(claudeNeeded);
|
|
5289
|
+
return {
|
|
5290
|
+
client,
|
|
5291
|
+
...applyFileChange(fileMcpChanges.find((entry) => entry.client === client).change)
|
|
5292
|
+
};
|
|
5293
|
+
})), options);
|
|
5294
|
+
}
|
|
5295
|
+
function planGlobalMcpFile(client) {
|
|
5296
|
+
const path = globalMcpConfigPath(client);
|
|
5297
|
+
return client === "codex" ? planCodexMcpServer(path) : planJsonMcpServer(path);
|
|
5298
|
+
}
|
|
5299
|
+
/** Claude Code's user-scope registration goes through its own command, never an edit of `~/.claude.json`. */
|
|
5300
|
+
function registerWithClaude(needed) {
|
|
5301
|
+
const path = globalMcpConfigPath("claude");
|
|
5302
|
+
if (!needed) return {
|
|
5303
|
+
client: "claude",
|
|
5304
|
+
path,
|
|
5305
|
+
state: "unchanged"
|
|
5306
|
+
};
|
|
5307
|
+
const outcome = runClaudeMcp(CLAUDE_USER_ADD);
|
|
5308
|
+
return outcome.state === "ran" ? {
|
|
5309
|
+
client: "claude",
|
|
5310
|
+
path,
|
|
5311
|
+
state: "written",
|
|
5312
|
+
command: outcome.command
|
|
5313
|
+
} : {
|
|
5314
|
+
client: "claude",
|
|
5315
|
+
path,
|
|
5316
|
+
state: "manual",
|
|
5317
|
+
command: outcome.command,
|
|
5318
|
+
reason: outcome.reason
|
|
5319
|
+
};
|
|
5320
|
+
}
|
|
5321
|
+
function describePlan$1(changes, claudeNeeded) {
|
|
5322
|
+
const lines = ["kanbo init --global will:"];
|
|
5323
|
+
for (const change of changes) lines.push(` write ${change.path}`);
|
|
5324
|
+
if (claudeNeeded) lines.push(` run ${CLAUDE_USER_ADD.join(" ")}`);
|
|
5325
|
+
lines.push("No board is created. Narrow this with --instructions and --mcp.");
|
|
5326
|
+
return lines.join("\n");
|
|
5327
|
+
}
|
|
5328
|
+
function describeResult(instructions, mcp) {
|
|
5329
|
+
const lines = [];
|
|
5330
|
+
for (const outcome of instructions) lines.push(`Instructions: ${outcome.path} (${outcome.state})`);
|
|
5331
|
+
for (const outcome of mcp) if (outcome.state === "manual") lines.push(`MCP: ${outcome.client} — run this yourself: ${outcome.command} (${outcome.reason})`);
|
|
5332
|
+
else lines.push(`MCP: ${outcome.path} (${outcome.state})`);
|
|
5333
|
+
lines.push("A project uses the board once it has run kanbo init. Check everything with kanbo doctor.");
|
|
5334
|
+
return {
|
|
5335
|
+
value: {
|
|
5336
|
+
scope: "global",
|
|
5337
|
+
instructions,
|
|
5338
|
+
mcp
|
|
5339
|
+
},
|
|
5340
|
+
text: lines.join("\n"),
|
|
5341
|
+
fields: [
|
|
5342
|
+
"scope",
|
|
5343
|
+
"instructions",
|
|
5344
|
+
"mcp"
|
|
5345
|
+
]
|
|
5346
|
+
};
|
|
5347
|
+
}
|
|
5348
|
+
function parseGlobalInstructions(value) {
|
|
5349
|
+
if (value === void 0) return DEFAULT_INSTRUCTION_CLIENTS;
|
|
5350
|
+
const names = value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
|
|
5351
|
+
if (names.length === 1 && names[0] === "none") return [];
|
|
5352
|
+
return names.map((name) => {
|
|
5353
|
+
const client = GLOBAL_INSTRUCTION_CLIENTS.find((candidate) => candidate === name);
|
|
5354
|
+
if (!client) throw new CliError(1, `Unknown instructions target "${name}" for --global. Use one or more of ${GLOBAL_INSTRUCTION_CLIENTS.join(", ")}, or none.`);
|
|
5355
|
+
return client;
|
|
5356
|
+
});
|
|
5357
|
+
}
|
|
5358
|
+
//#endregion
|
|
5359
|
+
//#region src/cli/commands/init.ts
|
|
5360
|
+
/**
|
|
5361
|
+
* `kanbo init` — tying a project folder to a board, and telling the agents
|
|
5362
|
+
* that work in it what the board expects.
|
|
5363
|
+
*
|
|
5364
|
+
* Three things come out of it, and each is optional except the first: the
|
|
5365
|
+
* binding, so no later command has to be told which workspace this is; a block
|
|
5366
|
+
* in the project's agent instructions, so an agent reads the board's rules
|
|
5367
|
+
* without being handed them; and a registration of the board's MCP server with
|
|
5368
|
+
* whichever tools the person actually uses. Nothing is written to a file the
|
|
5369
|
+
* person did not ask for, and nothing is written twice.
|
|
5370
|
+
*/
|
|
5371
|
+
/**
|
|
5372
|
+
* What a person is told when an external board is named and nothing says which
|
|
5373
|
+
* workspace it is about. There is no table to look the answer up in, and a card
|
|
5374
|
+
* numbered under a guess carries that guess for the rest of its life.
|
|
5375
|
+
*/
|
|
5376
|
+
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.";
|
|
5377
|
+
/** What a person is told when a board file of the project's own and an external one are both named at once. */
|
|
5378
|
+
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.";
|
|
5379
|
+
/**
|
|
5380
|
+
* What a person is told when their agents are about to reach the board as its
|
|
5381
|
+
* owner. Not a refusal: the board works this way from the first minute, and the
|
|
5382
|
+
* rule an agent is missing is one the database keeps, not this command.
|
|
5383
|
+
*/
|
|
5384
|
+
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.";
|
|
5385
|
+
function registerInitCommand(program) {
|
|
5386
|
+
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) => {
|
|
5387
|
+
if (options.global) {
|
|
5388
|
+
await initGlobal(options);
|
|
5389
|
+
return;
|
|
5390
|
+
}
|
|
5391
|
+
if (options.instructions !== void 0) parseInstructions(options.instructions);
|
|
5392
|
+
const projectDir = process.cwd();
|
|
5393
|
+
if (options.file !== void 0) {
|
|
5394
|
+
await initOwnFile(projectDir, options);
|
|
5395
|
+
return;
|
|
5396
|
+
}
|
|
5397
|
+
const target = resolveInitTarget(projectDir, options);
|
|
5398
|
+
if (target.kind === "postgres") {
|
|
5399
|
+
await initExternalBoard(projectDir, target, options);
|
|
5400
|
+
return;
|
|
5401
|
+
}
|
|
5402
|
+
await runBoardCommand(options, "read", async (session) => await initProject(projectDir, target, session.workspace, options));
|
|
5403
|
+
});
|
|
5404
|
+
}
|
|
5405
|
+
/**
|
|
5406
|
+
* Which board `init` is binding this project to.
|
|
5407
|
+
*
|
|
5408
|
+
* `--db` means something else here than it does in every other command: it
|
|
5409
|
+
* names the host database this one reads the workspace off, not the
|
|
5410
|
+
* board being bound. So the external board is asked for on its own — the flag,
|
|
5411
|
+
* the environment, then the binding this project already has — and only when
|
|
5412
|
+
* there is none is the board file itself the board. Always the person's
|
|
5413
|
+
* connection string: binding a project is not something an agent does.
|
|
5414
|
+
*/
|
|
5415
|
+
function resolveInitTarget(projectDir, options) {
|
|
5416
|
+
const url = resolveDatabaseUrl({
|
|
5417
|
+
explicitUrl: options.databaseUrl,
|
|
5418
|
+
cwd: projectDir,
|
|
5419
|
+
asAgent: false
|
|
5420
|
+
});
|
|
5421
|
+
return url ? {
|
|
5422
|
+
kind: "postgres",
|
|
5423
|
+
url
|
|
5424
|
+
} : resolveDbTarget({
|
|
5425
|
+
explicitPath: options.db,
|
|
5426
|
+
cwd: projectDir
|
|
5427
|
+
});
|
|
5428
|
+
}
|
|
5429
|
+
/**
|
|
5430
|
+
* Bind a project to an external board.
|
|
5431
|
+
*
|
|
5432
|
+
* The board is never opened. `init` is what a person runs *before*
|
|
5433
|
+
* `kanbo migrate`, and a database that holds no board yet is not a reason to
|
|
5434
|
+
* refuse to write down where it will be — nor could it answer the one question
|
|
5435
|
+
* that matters here, since it has no `workspaces` table to be asked.
|
|
5436
|
+
*/
|
|
5437
|
+
async function initExternalBoard(projectDir, target, options) {
|
|
5438
|
+
readFormat(options);
|
|
5439
|
+
printResult(await initProject(projectDir, target, await resolveExternalWorkspace(projectDir, options), options), options);
|
|
5440
|
+
}
|
|
5441
|
+
/**
|
|
5442
|
+
* Create and use a board file of this project's own — `.kanbo/board.db` by
|
|
5443
|
+
* default — with no host app and no Postgres anywhere near it.
|
|
5444
|
+
*
|
|
5445
|
+
* The file is migrated with this package's own chain right here, because
|
|
5446
|
+
* nothing else ever will: a host app migrates its own database and an
|
|
5447
|
+
* external database is migrated by `kanbo migrate`, but a file of this
|
|
5448
|
+
* package's own has no other owner to do it later — `kanbo migrate` runs the
|
|
5449
|
+
* same chain again afterwards, and idempotently, for the day it falls behind a
|
|
5450
|
+
* newer build (ruling 5-5). The marker written right after is what every later
|
|
5451
|
+
* command reads to know not to look for a host `workspaces` table in it
|
|
5452
|
+
* (ruling 5-4).
|
|
5453
|
+
*
|
|
5454
|
+
* The workspace is settled before a single byte is written, and nothing here
|
|
5455
|
+
* opens a host database at all (ruling 5-9): this is the one command that has
|
|
5456
|
+
* to work on a machine with nothing else installed, and the answer it needs —
|
|
5457
|
+
* an id and a card-key stem — is either typed, already in the binding, or the
|
|
5458
|
+
* folder's own name. Asking a host database first would turn `--workspace <id>`
|
|
5459
|
+
* into a refusal on every machine that has one, and leave a board file behind
|
|
5460
|
+
* with no binding beside it.
|
|
5461
|
+
*/
|
|
5462
|
+
async function initOwnFile(projectDir, options) {
|
|
5463
|
+
readFormat(options);
|
|
5464
|
+
if (options.databaseUrl) throw new CliError(1, FILE_AND_DATABASE_URL_MESSAGE);
|
|
5465
|
+
const relativePath = typeof options.file === "string" && options.file.trim() ? options.file.trim() : DEFAULT_BOARD_FILE_PATH;
|
|
5466
|
+
const absolutePath = (0, node_path.resolve)(projectDir, relativePath);
|
|
5467
|
+
const workspace = resolveOwnFileWorkspace(projectDir, options);
|
|
5468
|
+
await createOwnBoardFile(absolutePath);
|
|
5469
|
+
ignoreBoardFile(projectDir, absolutePath);
|
|
5470
|
+
printResult(await initProject(projectDir, {
|
|
5471
|
+
kind: "sqlite",
|
|
5472
|
+
path: absolutePath,
|
|
5473
|
+
owner: "kanbo"
|
|
5474
|
+
}, workspace, options), options);
|
|
5475
|
+
}
|
|
5476
|
+
/**
|
|
5477
|
+
* Put a migrated, marked board at this path — or leave the folder exactly as it
|
|
5478
|
+
* was found (ruling 5-9).
|
|
5479
|
+
*
|
|
5480
|
+
* A new board is built beside its own path and moved onto it at the end, so a
|
|
5481
|
+
* migration that fails half-way leaves no half-built file for the next command
|
|
5482
|
+
* to open and believe in. `rename` within one directory is the filesystem's own
|
|
5483
|
+
* atomic swap; a temporary name elsewhere would be a copy across devices and
|
|
5484
|
+
* not atomic at all. A file that is already there is a different question — it
|
|
5485
|
+
* may hold this project's cards — so it is migrated in place, idempotently,
|
|
5486
|
+
* which is what makes a second `kanbo init --file` a no-op.
|
|
5487
|
+
*
|
|
5488
|
+
* A file that is there and is *not* this package's is refused, for the reason
|
|
5489
|
+
* any careful host refuses it: migrating a board into somebody's own
|
|
5490
|
+
* database leaves it readable but contaminated, and only a different path
|
|
5491
|
+
* helps.
|
|
5492
|
+
*/
|
|
5493
|
+
async function createOwnBoardFile(absolutePath) {
|
|
5494
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(absolutePath), { recursive: true });
|
|
5495
|
+
if ((0, node_fs.existsSync)(absolutePath)) {
|
|
5496
|
+
assertOwnBoardFile(absolutePath);
|
|
5497
|
+
await migrateBoardFileAt(absolutePath);
|
|
5498
|
+
return;
|
|
5499
|
+
}
|
|
5500
|
+
const temporaryPath = `${absolutePath}.${process.pid}.tmp`;
|
|
5501
|
+
try {
|
|
5502
|
+
await migrateBoardFileAt(temporaryPath);
|
|
5503
|
+
(0, node_fs.renameSync)(temporaryPath, absolutePath);
|
|
5504
|
+
} catch (error) {
|
|
5505
|
+
for (const leftover of [
|
|
5506
|
+
temporaryPath,
|
|
5507
|
+
`${temporaryPath}-wal`,
|
|
5508
|
+
`${temporaryPath}-shm`
|
|
5509
|
+
]) (0, node_fs.rmSync)(leftover, { force: true });
|
|
5510
|
+
throw error;
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
/** Refuse a file already at the board's path that this package did not write. */
|
|
5514
|
+
function assertOwnBoardFile(absolutePath) {
|
|
4503
5515
|
let contents;
|
|
4504
5516
|
try {
|
|
4505
5517
|
contents = require_migrate.readBoardFileContents(absolutePath);
|
|
@@ -4709,9 +5721,9 @@ function warnAboutOwnerAccess(target, options) {
|
|
|
4709
5721
|
* instructions is the kind of thing a person says yes to.
|
|
4710
5722
|
*/
|
|
4711
5723
|
async function writeInstructions(projectDir, options) {
|
|
4712
|
-
const target = options.instructions
|
|
5724
|
+
const target = options.instructions === void 0 ? await askInstructionTarget(options) : parseInstructions(options.instructions);
|
|
4713
5725
|
if (target === "none") return null;
|
|
4714
|
-
return
|
|
5726
|
+
return applyFileChange(planInstructionBlock((0, node_path.join)(projectDir, PROJECT_INSTRUCTION_FILES[target]), INSTRUCTION_BLOCK));
|
|
4715
5727
|
}
|
|
4716
5728
|
async function askInstructionTarget(options) {
|
|
4717
5729
|
showBlock(options);
|
|
@@ -4745,35 +5757,6 @@ function showBlock(options) {
|
|
|
4745
5757
|
if (!options.json && !options.format) console.log(`${INSTRUCTION_BLOCK}\n`);
|
|
4746
5758
|
}
|
|
4747
5759
|
/**
|
|
4748
|
-
* The block, in that file, once.
|
|
4749
|
-
*
|
|
4750
|
-
* A file that already carries the markers has its block replaced rather than
|
|
4751
|
-
* appended to, so running `init` again on a project whose block is current
|
|
4752
|
-
* changes nothing at all, and running it after an upgrade brings the block
|
|
4753
|
-
* forward. Everything outside the markers is the person's own writing and is
|
|
4754
|
-
* never touched.
|
|
4755
|
-
*/
|
|
4756
|
-
function writeInstructionBlock(path) {
|
|
4757
|
-
const existing = (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : null;
|
|
4758
|
-
const next = buildInstructionFile(existing);
|
|
4759
|
-
if (existing === next) return {
|
|
4760
|
-
path,
|
|
4761
|
-
state: "unchanged"
|
|
4762
|
-
};
|
|
4763
|
-
(0, node_fs.writeFileSync)(path, next);
|
|
4764
|
-
return {
|
|
4765
|
-
path,
|
|
4766
|
-
state: "written"
|
|
4767
|
-
};
|
|
4768
|
-
}
|
|
4769
|
-
function buildInstructionFile(existing) {
|
|
4770
|
-
if (existing === null) return `${INSTRUCTION_BLOCK}\n`;
|
|
4771
|
-
const start = existing.indexOf(INSTRUCTION_START);
|
|
4772
|
-
const end = existing.indexOf(INSTRUCTION_END);
|
|
4773
|
-
if (start >= 0 && end > start) return existing.slice(0, start) + INSTRUCTION_BLOCK + existing.slice(end + 18);
|
|
4774
|
-
return `${existing.replace(/\s*$/, "")}\n\n${INSTRUCTION_BLOCK}\n`;
|
|
4775
|
-
}
|
|
4776
|
-
/**
|
|
4777
5760
|
* Tell the tools the person uses about the board's MCP server.
|
|
4778
5761
|
*
|
|
4779
5762
|
* Only the ones they named, and only when the tool does not already know: a
|
|
@@ -4808,81 +5791,8 @@ async function askMcpClients(options) {
|
|
|
4808
5791
|
return prompts.isCancel(answer) ? [] : answer;
|
|
4809
5792
|
}
|
|
4810
5793
|
function registerMcpServer(projectDir, client) {
|
|
4811
|
-
|
|
4812
|
-
return
|
|
4813
|
-
}
|
|
4814
|
-
/**
|
|
4815
|
-
* The `mcpServers` map Claude Code and Cursor both read, merged rather than
|
|
4816
|
-
* replaced: the file is the project's, and it may already describe servers that
|
|
4817
|
-
* have nothing to do with this board.
|
|
4818
|
-
*/
|
|
4819
|
-
function writeJsonMcpServer(path) {
|
|
4820
|
-
const existing = readJsonFile(path);
|
|
4821
|
-
if (existing.mcpServers && MCP_SERVER_NAME in existing.mcpServers) return {
|
|
4822
|
-
path,
|
|
4823
|
-
state: "unchanged"
|
|
4824
|
-
};
|
|
4825
|
-
const next = {
|
|
4826
|
-
...existing,
|
|
4827
|
-
mcpServers: {
|
|
4828
|
-
...existing.mcpServers,
|
|
4829
|
-
[MCP_SERVER_NAME]: {
|
|
4830
|
-
type: "stdio",
|
|
4831
|
-
command: "kanbo",
|
|
4832
|
-
args: ["mcp"]
|
|
4833
|
-
}
|
|
4834
|
-
}
|
|
4835
|
-
};
|
|
4836
|
-
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
|
|
4837
|
-
(0, node_fs.writeFileSync)(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
4838
|
-
return {
|
|
4839
|
-
path,
|
|
4840
|
-
state: "written"
|
|
4841
|
-
};
|
|
4842
|
-
}
|
|
4843
|
-
/**
|
|
4844
|
-
* The tool's configuration file as an object to merge into — or a refusal.
|
|
4845
|
-
*
|
|
4846
|
-
* A file that is valid JSON but not an object (a list, a string, `null`) is
|
|
4847
|
-
* something this command has no idea how to merge into, and spreading it would
|
|
4848
|
-
* quietly throw the person's file away. The file is left exactly as it is and
|
|
4849
|
-
* they are told which one to look at.
|
|
4850
|
-
*/
|
|
4851
|
-
function readJsonFile(path) {
|
|
4852
|
-
if (!(0, node_fs.existsSync)(path)) return {};
|
|
4853
|
-
const parsed = parseJsonFile(path);
|
|
4854
|
-
if (!isPlainObject(parsed)) throw new CliError(1, `${path} does not hold a JSON object. Fix or remove it, then run kanbo init again.`);
|
|
4855
|
-
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.`);
|
|
4856
|
-
return parsed;
|
|
4857
|
-
}
|
|
4858
|
-
function parseJsonFile(path) {
|
|
4859
|
-
try {
|
|
4860
|
-
return JSON.parse((0, node_fs.readFileSync)(path, "utf8"));
|
|
4861
|
-
} catch {
|
|
4862
|
-
throw new CliError(1, `${path} is not valid JSON. Fix or remove it, then run kanbo init again.`);
|
|
4863
|
-
}
|
|
4864
|
-
}
|
|
4865
|
-
function isPlainObject(value) {
|
|
4866
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4867
|
-
}
|
|
4868
|
-
/** Codex keeps its servers in TOML tables, so the table is appended rather than merged. */
|
|
4869
|
-
function writeCodexMcpServer(path) {
|
|
4870
|
-
const existing = (0, node_fs.existsSync)(path) ? (0, node_fs.readFileSync)(path, "utf8") : "";
|
|
4871
|
-
const table = `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "kanbo"\nargs = ["mcp"]\n`;
|
|
4872
|
-
if (CODEX_TABLE_PATTERN.test(existing)) return {
|
|
4873
|
-
path,
|
|
4874
|
-
state: "unchanged"
|
|
4875
|
-
};
|
|
4876
|
-
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
|
|
4877
|
-
(0, node_fs.writeFileSync)(path, existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n${table}` : table);
|
|
4878
|
-
return {
|
|
4879
|
-
path,
|
|
4880
|
-
state: "written"
|
|
4881
|
-
};
|
|
4882
|
-
}
|
|
4883
|
-
/** Is there a person on the other end of this shell to ask? */
|
|
4884
|
-
function isInteractive() {
|
|
4885
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
5794
|
+
const path = projectMcpConfigPath(projectDir, client);
|
|
5795
|
+
return applyFileChange(client === "codex" ? planCodexMcpServer(path) : planJsonMcpServer(path));
|
|
4886
5796
|
}
|
|
4887
5797
|
function describeInit(workspace, target, bindingPath, instructions, mcp) {
|
|
4888
5798
|
const lines = [`Bound this project to ${workspace.name} — ${bindingPath}`];
|
|
@@ -5155,16 +6065,6 @@ async function createDbTransport(input) {
|
|
|
5155
6065
|
};
|
|
5156
6066
|
}
|
|
5157
6067
|
//#endregion
|
|
5158
|
-
//#region src/package-version.ts
|
|
5159
|
-
(0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
5160
|
-
/**
|
|
5161
|
-
* This build's own version, one source for the CLI's `--version`, the MCP
|
|
5162
|
-
* server's reported version and the capabilities manifest's
|
|
5163
|
-
* `package.version`. A build carries it as a literal; running from source
|
|
5164
|
-
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
5165
|
-
*/
|
|
5166
|
-
const KANBO_PACKAGE_VERSION = "0.1.1";
|
|
5167
|
-
//#endregion
|
|
5168
6068
|
//#region src/mcp/resources.ts
|
|
5169
6069
|
/** The URI a client reads the manifest as JSON from. */
|
|
5170
6070
|
const KANBO_CAPABILITIES_RESOURCE_URI = "kanbo://capabilities";
|
|
@@ -5204,6 +6104,36 @@ function registerKanboCapabilitiesResources(server) {
|
|
|
5204
6104
|
}] }));
|
|
5205
6105
|
}
|
|
5206
6106
|
//#endregion
|
|
6107
|
+
//#region src/mcp/instructions.ts
|
|
6108
|
+
/** A tool name as the instructions print it — typed, so a renamed tool fails the build here. */
|
|
6109
|
+
function tool(name) {
|
|
6110
|
+
return `\`${name}\``;
|
|
6111
|
+
}
|
|
6112
|
+
/**
|
|
6113
|
+
* What `kanbo mcp` tells a client when it connects — the `instructions` of the
|
|
6114
|
+
* MCP initialize result, which a client hands its model before any tool is
|
|
6115
|
+
* called. An agent with nothing but this server connected learns from it how
|
|
6116
|
+
* to work the board, with no instruction file in the project.
|
|
6117
|
+
*
|
|
6118
|
+
* Static on purpose: it is sent before any board is resolved, so it says
|
|
6119
|
+
* nothing about columns — `kanbo_prime` returns those. The rules are the
|
|
6120
|
+
* sentences `ops/agent-rules.ts` holds for every surface, with the tool beside
|
|
6121
|
+
* each; the full manifest stays a resource.
|
|
6122
|
+
*/
|
|
6123
|
+
const KANBO_MCP_INSTRUCTIONS = [
|
|
6124
|
+
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
6125
|
+
"",
|
|
6126
|
+
`- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
|
|
6127
|
+
`- Take work from ${tool("kanbo_ready")}.`,
|
|
6128
|
+
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
6129
|
+
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
6130
|
+
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
6131
|
+
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
6132
|
+
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
6133
|
+
`- ${PERSON_ONLY_RULE}`,
|
|
6134
|
+
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
6135
|
+
].join("\n");
|
|
6136
|
+
//#endregion
|
|
5207
6137
|
//#region src/mcp/tools/tool.ts
|
|
5208
6138
|
/**
|
|
5209
6139
|
* Declare a tool: what it is called, what it takes, and the one thing it does.
|
|
@@ -5301,7 +6231,7 @@ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pas
|
|
|
5301
6231
|
const cardCreateTool = defineKanboTool({
|
|
5302
6232
|
name: "kanbo_card_create",
|
|
5303
6233
|
title: "Create a card",
|
|
5304
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE}
|
|
6234
|
+
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
|
|
5305
6235
|
inputSchema: {
|
|
5306
6236
|
title: zod.z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
5307
6237
|
description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
@@ -5573,85 +6503,20 @@ function registerKanboTools(server, transport, options) {
|
|
|
5573
6503
|
* function: it documents its capabilities its own way, and adding a second
|
|
5574
6504
|
* `kanbo://` resource namespace to an inventory that is not only the board's
|
|
5575
6505
|
* is a decision for that server to make, not this one.
|
|
6506
|
+
*
|
|
6507
|
+
* For the same reason it alone sends `KANBO_MCP_INSTRUCTIONS` in the initialize
|
|
6508
|
+
* result: the instructions of a server that is only the board.
|
|
5576
6509
|
*/
|
|
5577
6510
|
function createKanboMcpServer(transport, options) {
|
|
5578
6511
|
const server = new _modelcontextprotocol_sdk_server_mcp_js.McpServer({
|
|
5579
6512
|
name: KANBO_MCP_SERVER_NAME,
|
|
5580
6513
|
version: KANBO_MCP_SERVER_VERSION
|
|
5581
|
-
});
|
|
6514
|
+
}, { instructions: KANBO_MCP_INSTRUCTIONS });
|
|
5582
6515
|
registerKanboTools(server, transport, options);
|
|
5583
6516
|
registerKanboCapabilitiesResources(server);
|
|
5584
6517
|
return server;
|
|
5585
6518
|
}
|
|
5586
6519
|
//#endregion
|
|
5587
|
-
//#region src/cli/failure.ts
|
|
5588
|
-
/**
|
|
5589
|
-
* What a failed command says, and what it leaves with.
|
|
5590
|
-
*
|
|
5591
|
-
* One place, because a failure is output as much as a result is: the exit code
|
|
5592
|
-
* a caller may branch on, the sentence a person reads, and the chain underneath
|
|
5593
|
-
* it that says what actually went wrong.
|
|
5594
|
-
*/
|
|
5595
|
-
/** How far down a chain of causes is still worth reading. */
|
|
5596
|
-
const CAUSE_DEPTH_LIMIT = 5;
|
|
5597
|
-
/**
|
|
5598
|
-
* What to say about a failure, and what to leave with.
|
|
5599
|
-
*
|
|
5600
|
-
* A `CliError` already carries both. A `BoardError` is a rule of the board the
|
|
5601
|
-
* caller broke: its code is the honest name of what happened, and its details
|
|
5602
|
-
* say which value did it, so both are printed rather than translated into a
|
|
5603
|
-
* sentence that could drift away from the rule. The one exception is a card
|
|
5604
|
-
* held at a column's door: its details are a list, and a person reads it as
|
|
5605
|
-
* one line per rule the card does not meet yet (`describeColumnRefusal`).
|
|
5606
|
-
*
|
|
5607
|
-
* Whatever it is, its causes are printed under it. A driver's failure arrives
|
|
5608
|
-
* wrapped — `Failed query: create schema …` with the connection refused
|
|
5609
|
-
* underneath — and the wrapper alone tells a person nothing they can act on.
|
|
5610
|
-
* Every line goes through the masking on its way out, because a message this
|
|
5611
|
-
* tool did not write is free to carry the whole connection string in it.
|
|
5612
|
-
*/
|
|
5613
|
-
function describeFailure(error) {
|
|
5614
|
-
const { message, exitCode } = readFailure(error);
|
|
5615
|
-
return {
|
|
5616
|
-
message: maskDatabaseUrls([message, ...describeCauses(error, 1)].join("\n")),
|
|
5617
|
-
exitCode
|
|
5618
|
-
};
|
|
5619
|
-
}
|
|
5620
|
-
function readFailure(error) {
|
|
5621
|
-
if (error instanceof CliError) return {
|
|
5622
|
-
message: error.message,
|
|
5623
|
-
exitCode: error.exitCode
|
|
5624
|
-
};
|
|
5625
|
-
if (error instanceof require_migrations_path.BoardError && error.code === "board_column_rules_unmet") return {
|
|
5626
|
-
message: describeColumnRefusal(error.details),
|
|
5627
|
-
exitCode: 1
|
|
5628
|
-
};
|
|
5629
|
-
if (error instanceof require_migrations_path.BoardError) {
|
|
5630
|
-
const details = error.details ? ` ${JSON.stringify(error.details)}` : "";
|
|
5631
|
-
return {
|
|
5632
|
-
message: `${error.code}${details}`,
|
|
5633
|
-
exitCode: 1
|
|
5634
|
-
};
|
|
5635
|
-
}
|
|
5636
|
-
return {
|
|
5637
|
-
message: error instanceof Error ? error.message : String(error),
|
|
5638
|
-
exitCode: 1
|
|
5639
|
-
};
|
|
5640
|
-
}
|
|
5641
|
-
/**
|
|
5642
|
-
* The chain under a failure, one indented line per link.
|
|
5643
|
-
*
|
|
5644
|
-
* Bounded, because `cause` is an ordinary property and nothing stops it
|
|
5645
|
-
* pointing back at the error it came from; a chain longer than this has stopped
|
|
5646
|
-
* explaining anything anyway.
|
|
5647
|
-
*/
|
|
5648
|
-
function describeCauses(error, depth) {
|
|
5649
|
-
const cause = error instanceof Error ? error.cause : void 0;
|
|
5650
|
-
if (cause === void 0 || cause === null || depth > CAUSE_DEPTH_LIMIT) return [];
|
|
5651
|
-
const message = cause instanceof Error ? cause.message : String(cause);
|
|
5652
|
-
return [`${" ".repeat(depth)}Caused by: ${message}`, ...describeCauses(cause, depth + 1)];
|
|
5653
|
-
}
|
|
5654
|
-
//#endregion
|
|
5655
6520
|
//#region src/cli/commands/mcp.ts
|
|
5656
6521
|
function registerMcpCommand(program) {
|
|
5657
6522
|
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) => {
|
|
@@ -7743,6 +8608,143 @@ function registerSprintCommands(program) {
|
|
|
7743
8608
|
});
|
|
7744
8609
|
}
|
|
7745
8610
|
//#endregion
|
|
8611
|
+
//#region src/cli/commands/uninstall.ts
|
|
8612
|
+
function registerUninstallCommand(program) {
|
|
8613
|
+
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) => {
|
|
8614
|
+
await uninstall(options);
|
|
8615
|
+
});
|
|
8616
|
+
}
|
|
8617
|
+
async function uninstall(options) {
|
|
8618
|
+
const both = !options.project && !options.global;
|
|
8619
|
+
const withProject = both || Boolean(options.project);
|
|
8620
|
+
const withGlobal = both || Boolean(options.global);
|
|
8621
|
+
if (options.purge && !withProject) throw new CliError(1, "--purge removes a project's binding and board; run it without --global, in the project.");
|
|
8622
|
+
const projectRoot = withProject ? findKanboProject(process.cwd())?.root ?? process.cwd() : null;
|
|
8623
|
+
const edits = [...projectRoot ? planProjectEdits(projectRoot) : [], ...withGlobal ? planGlobalEdits() : []].filter((edit) => edit.change.next !== null);
|
|
8624
|
+
const claudeUser = withGlobal && readClaudeUserMcpCommand() !== void 0;
|
|
8625
|
+
const bindingPath = projectRoot && options.purge ? (0, node_path.join)(projectRoot, BINDING_FILE_PATH) : null;
|
|
8626
|
+
const purgeBinding = bindingPath !== null && (0, node_fs.existsSync)(bindingPath);
|
|
8627
|
+
const boardFile = projectRoot && options.purge ? findOwnBoardFile(projectRoot) : null;
|
|
8628
|
+
const actions = [];
|
|
8629
|
+
if (edits.length > 0 || claudeUser || purgeBinding || boardFile) {
|
|
8630
|
+
if (!await confirmPlan({
|
|
8631
|
+
preview: describePlan(edits, claudeUser, purgeBinding ? bindingPath : null, boardFile),
|
|
8632
|
+
question: "Remove these?",
|
|
8633
|
+
yes: options.yes,
|
|
8634
|
+
machineOutput: Boolean(options.json),
|
|
8635
|
+
command: "kanbo uninstall"
|
|
8636
|
+
})) throw new CliError(1, "Nothing was changed.");
|
|
8637
|
+
for (const edit of edits) {
|
|
8638
|
+
applyFileChange(edit.change);
|
|
8639
|
+
actions.push({
|
|
8640
|
+
path: edit.change.path,
|
|
8641
|
+
action: "edited",
|
|
8642
|
+
note: edit.what
|
|
8643
|
+
});
|
|
8644
|
+
}
|
|
8645
|
+
if (claudeUser) {
|
|
8646
|
+
const outcome = runClaudeMcp(CLAUDE_USER_REMOVE);
|
|
8647
|
+
actions.push({
|
|
8648
|
+
path: globalMcpConfigPath("claude"),
|
|
8649
|
+
action: outcome.state,
|
|
8650
|
+
note: outcome.state === "ran" ? outcome.command : `run this yourself: ${outcome.command} (${outcome.reason})`
|
|
8651
|
+
});
|
|
8652
|
+
}
|
|
8653
|
+
if (purgeBinding) {
|
|
8654
|
+
(0, node_fs.rmSync)(bindingPath);
|
|
8655
|
+
actions.push({
|
|
8656
|
+
path: bindingPath,
|
|
8657
|
+
action: "deleted",
|
|
8658
|
+
note: "project binding"
|
|
8659
|
+
});
|
|
8660
|
+
}
|
|
8661
|
+
if (boardFile) actions.push(await purgeBoardFile(boardFile));
|
|
8662
|
+
}
|
|
8663
|
+
printActions(actions, options);
|
|
8664
|
+
}
|
|
8665
|
+
function planProjectEdits(root) {
|
|
8666
|
+
return [...Object.values(PROJECT_INSTRUCTION_FILES).map((name) => ({
|
|
8667
|
+
change: planInstructionBlockRemoval((0, node_path.join)(root, name)),
|
|
8668
|
+
what: "kanbo instruction block"
|
|
8669
|
+
})), ...[
|
|
8670
|
+
"claude",
|
|
8671
|
+
"codex",
|
|
8672
|
+
"cursor"
|
|
8673
|
+
].map((client) => ({
|
|
8674
|
+
change: planMcpRemoval(projectMcpConfigPath(root, client), client),
|
|
8675
|
+
what: "kanbo MCP server"
|
|
8676
|
+
}))];
|
|
8677
|
+
}
|
|
8678
|
+
function planGlobalEdits() {
|
|
8679
|
+
return [...GLOBAL_INSTRUCTION_CLIENTS.map((client) => ({
|
|
8680
|
+
change: planInstructionBlockRemoval(globalInstructionPath(client)),
|
|
8681
|
+
what: "kanbo instruction block"
|
|
8682
|
+
})), ...["codex", "cursor"].map((client) => ({
|
|
8683
|
+
change: planMcpRemoval(globalMcpConfigPath(client), client),
|
|
8684
|
+
what: "kanbo MCP server"
|
|
8685
|
+
}))];
|
|
8686
|
+
}
|
|
8687
|
+
function planMcpRemoval(path, client) {
|
|
8688
|
+
return client === "codex" ? planCodexMcpServerRemoval(path) : planJsonMcpServerRemoval(path);
|
|
8689
|
+
}
|
|
8690
|
+
/**
|
|
8691
|
+
* The board file `kanbo init --file` made for this project — the only board
|
|
8692
|
+
* `--purge` may offer to delete. A host app's database or an external board is
|
|
8693
|
+
* not kanbo's to delete, and a binding that names none offers nothing.
|
|
8694
|
+
*/
|
|
8695
|
+
function findOwnBoardFile(root) {
|
|
8696
|
+
const binding = findKanboProject(root)?.binding;
|
|
8697
|
+
if (!binding?.dbPath) return null;
|
|
8698
|
+
const path = (0, node_path.resolve)(root, binding.dbPath);
|
|
8699
|
+
return (0, node_fs.existsSync)(path) ? path : null;
|
|
8700
|
+
}
|
|
8701
|
+
/** Delete the board file only on a yes to a question that names it; `--yes` is not that answer. */
|
|
8702
|
+
async function purgeBoardFile(path) {
|
|
8703
|
+
if (!isInteractive()) return {
|
|
8704
|
+
path,
|
|
8705
|
+
action: "kept",
|
|
8706
|
+
note: "board file: deleting it needs a confirmation in a terminal; delete it yourself if you mean to"
|
|
8707
|
+
};
|
|
8708
|
+
if (await (await import("@clack/prompts")).confirm({
|
|
8709
|
+
message: `Delete the board file ${path} and every card in it? This cannot be undone.`,
|
|
8710
|
+
initialValue: false
|
|
8711
|
+
}) !== true) return {
|
|
8712
|
+
path,
|
|
8713
|
+
action: "kept",
|
|
8714
|
+
note: "board file"
|
|
8715
|
+
};
|
|
8716
|
+
for (const file of [
|
|
8717
|
+
path,
|
|
8718
|
+
`${path}-wal`,
|
|
8719
|
+
`${path}-shm`
|
|
8720
|
+
]) (0, node_fs.rmSync)(file, { force: true });
|
|
8721
|
+
return {
|
|
8722
|
+
path,
|
|
8723
|
+
action: "deleted",
|
|
8724
|
+
note: "board file"
|
|
8725
|
+
};
|
|
8726
|
+
}
|
|
8727
|
+
function describePlan(edits, claudeUser, bindingPath, boardFile) {
|
|
8728
|
+
const lines = ["kanbo uninstall will:"];
|
|
8729
|
+
for (const edit of edits) lines.push(` edit ${edit.change.path} (remove the ${edit.what})`);
|
|
8730
|
+
if (claudeUser) lines.push(` run ${CLAUDE_USER_REMOVE.join(" ")}`);
|
|
8731
|
+
if (bindingPath) lines.push(` delete ${bindingPath}`);
|
|
8732
|
+
if (boardFile) lines.push(` ask whether to delete the board file ${boardFile} (only in a terminal; --yes does not answer this)`);
|
|
8733
|
+
lines.push("Files stay even when nothing is left in them. Boards stay unless you confirm deleting one.");
|
|
8734
|
+
return lines.join("\n");
|
|
8735
|
+
}
|
|
8736
|
+
function printActions(actions, options) {
|
|
8737
|
+
if (options.json) {
|
|
8738
|
+
console.log(JSON.stringify({ actions }, null, 2));
|
|
8739
|
+
return;
|
|
8740
|
+
}
|
|
8741
|
+
if (actions.length === 0) {
|
|
8742
|
+
console.log("Nothing to remove: no kanbo block or MCP entry in the files kanbo writes.");
|
|
8743
|
+
return;
|
|
8744
|
+
}
|
|
8745
|
+
for (const action of actions) console.log(`${action.action.padEnd(8)} ${action.path}${action.note ? ` (${action.note})` : ""}`);
|
|
8746
|
+
}
|
|
8747
|
+
//#endregion
|
|
7746
8748
|
//#region src/cli/commands/index.ts
|
|
7747
8749
|
/**
|
|
7748
8750
|
* Every command the `kanbo` binary answers to, put on one program.
|
|
@@ -7756,6 +8758,8 @@ function registerSprintCommands(program) {
|
|
|
7756
8758
|
*/
|
|
7757
8759
|
function registerKanboCommands(program) {
|
|
7758
8760
|
registerInitCommand(program);
|
|
8761
|
+
registerDoctorCommand(program);
|
|
8762
|
+
registerUninstallCommand(program);
|
|
7759
8763
|
registerCapabilitiesCommand(program);
|
|
7760
8764
|
registerPrimeCommand(program);
|
|
7761
8765
|
registerReadyCommand(program);
|
|
@@ -8027,6 +9031,12 @@ Object.defineProperty(exports, "KANBO_CAPABILITIES_RESOURCE_URI", {
|
|
|
8027
9031
|
return KANBO_CAPABILITIES_RESOURCE_URI;
|
|
8028
9032
|
}
|
|
8029
9033
|
});
|
|
9034
|
+
Object.defineProperty(exports, "KANBO_MCP_INSTRUCTIONS", {
|
|
9035
|
+
enumerable: true,
|
|
9036
|
+
get: function() {
|
|
9037
|
+
return KANBO_MCP_INSTRUCTIONS;
|
|
9038
|
+
}
|
|
9039
|
+
});
|
|
8030
9040
|
Object.defineProperty(exports, "KANBO_MCP_SERVER_NAME", {
|
|
8031
9041
|
enumerable: true,
|
|
8032
9042
|
get: function() {
|
|
@@ -8358,4 +9368,4 @@ Object.defineProperty(exports, "writeBinding", {
|
|
|
8358
9368
|
}
|
|
8359
9369
|
});
|
|
8360
9370
|
|
|
8361
|
-
//# sourceMappingURL=manifest-
|
|
9371
|
+
//# sourceMappingURL=manifest-BBJIrMw7.cjs.map
|