kandown 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/kandown.js CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  import { spawn, execSync } from 'node:child_process';
70
70
  import { createConnection } from 'node:net';
71
71
  import { randomBytes } from 'node:crypto';
72
+ import { watch as watchFs } from 'chokidar';
72
73
 
73
74
  // 📖 Global safety net: a stray exception or unhandled rejection prints a clean
74
75
  // one-liner instead of a raw stack trace. The daemon (KANDOWN_DAEMON=1) logs
@@ -621,7 +622,7 @@ function appendAgentReference(cwd, agentsFile, kandownPath) {
621
622
  ${marker}
622
623
  ## Task management
623
624
 
624
- This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`; if you can't run the CLI, read \`${kandownPath}/AGENT_KANDOWN.md\` instead.)
625
+ This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`.)
625
626
  `;
626
627
 
627
628
  try {
@@ -642,7 +643,7 @@ function createAgentsFileIfMissing(cwd, kandownPath) {
642
643
  <!-- kandown:agent-ref -->
643
644
  ## Task management
644
645
 
645
- This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`; if you can't run the CLI, read \`${kandownPath}/AGENT_KANDOWN.md\` instead.)
646
+ This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`.)
646
647
  `;
647
648
  writeFileSync(agentsPath, content, 'utf8');
648
649
  return true;
@@ -830,11 +831,14 @@ function doInit(args, cwd, kandownPath, kandownDir) {
830
831
  copyFileSync(htmlSrc, htmlDest);
831
832
  success('kandown.html');
832
833
 
834
+ // 📖 AGENT.md and AGENT_KANDOWN.md are intentionally NOT copied into new
835
+ // projects anymore. Both `kandown work` and the TUI's agent launcher (`a`)
836
+ // now read the rules straight from this package's templates/AGENT_KANDOWN.md
837
+ // at call time — a per-project copy would only go stale the moment the
838
+ // package updates, with nothing to keep it in sync. Existing projects with
839
+ // an old copy are left alone (never auto-deleted); customization now goes
840
+ // in `.kandown/instructions.md` / `~/.kandown/instructions.md` instead.
833
841
  const templatesDir = join(PKG_ROOT, 'templates');
834
- if (!existsSync(join(kandownDir, 'AGENT.md'))) {
835
- copyFileSync(join(templatesDir, 'AGENT.md'), join(kandownDir, 'AGENT.md'));
836
- success('AGENT.md');
837
- }
838
842
  if (!existsSync(join(kandownDir, 'README.md'))) {
839
843
  copyFileSync(join(templatesDir, 'README.md'), join(kandownDir, 'README.md'));
840
844
  success('README.md');
@@ -871,15 +875,6 @@ function doInit(args, cwd, kandownPath, kandownDir) {
871
875
  success('.gitignore (daemon runtime metadata ignored)');
872
876
  }
873
877
 
874
- const agentKandownSrc = join(templatesDir, 'AGENT_KANDOWN.md');
875
- const agentKandownDest = join(kandownDir, 'AGENT_KANDOWN.md');
876
- if (!existsSync(agentKandownDest)) {
877
- copyFileSync(agentKandownSrc, agentKandownDest);
878
- success('AGENT_KANDOWN.md');
879
- } else {
880
- info('AGENT_KANDOWN.md already exists (kept)');
881
- }
882
-
883
878
  if (!args.noAgents) {
884
879
  log('');
885
880
  const existingAgents = findAgentsFile(cwd);
@@ -892,6 +887,13 @@ function doInit(args, cwd, kandownPath, kandownDir) {
892
887
  }
893
888
  }
894
889
 
890
+ const hasGit = existsSync(join(cwd, '.git'));
891
+ if (!hasGit) {
892
+ log('');
893
+ warn(`No git repository detected in ${c.bold}${cwd}${c.reset}.`);
894
+ info(` Tip: Run ${c.cyan}git init${c.reset} and track ${c.bold}./tasks/${c.reset} for git history & portability!`);
895
+ }
896
+
895
897
  return true;
896
898
  }
897
899
 
@@ -1232,26 +1234,54 @@ function depIsResolved(kandownDir, id, terminalLower) {
1232
1234
  return isArch || (parsed.frontmatter.status || '').toLowerCase() === terminalLower;
1233
1235
  }
1234
1236
 
1237
+ function parseInlineQuickAdd(rawTitle) {
1238
+ let text = (rawTitle || '').trim();
1239
+ let priority;
1240
+ const tags = [];
1241
+ let assignee;
1242
+ let due;
1243
+ const dependsOn = [];
1244
+
1245
+ text = text.replace(/(?:^|\s)p([1-4])(?:\s|$)/i, (_, level) => {
1246
+ priority = `P${level}`;
1247
+ return ' ';
1248
+ });
1249
+
1250
+ text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => {
1251
+ tags.push(tag.toLowerCase());
1252
+ return ' ';
1253
+ });
1254
+
1255
+ text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, user) => {
1256
+ assignee = user;
1257
+ return ' ';
1258
+ });
1259
+
1260
+ text = text.replace(/(?:^|\s)due:([^\s]+)/i, (_, dateStr) => {
1261
+ due = dateStr;
1262
+ return ' ';
1263
+ });
1264
+
1265
+ text = text.replace(/(?:^|\s)\+([a-zA-Z0-9_-]+)/g, (_, depId) => {
1266
+ dependsOn.push(depId);
1267
+ return ' ';
1268
+ });
1269
+
1270
+ const title = text.replace(/\s+/g, ' ').trim();
1271
+ return { title: title || rawTitle, priority, tags, assignee, due, dependsOn };
1272
+ }
1273
+
1235
1274
  function cmdCreate(rawArgs) {
1236
1275
  const { kandownDir } = ensureKandownDir(rawArgs);
1237
1276
  const args = taskParseArgs(rawArgs);
1238
- const title = args.positional.join(' ').trim();
1239
- if (!title) {
1240
- err('Usage: kandown create "title" [-p priority] [-a assignee] [-t tag] [--to status]');
1277
+ const rawTitle = args.positional[0];
1278
+ if (!rawTitle) {
1279
+ err('Usage: kandown create "<title>" [-p priority] [-a assignee] [-t tag ...] [--to status]');
1241
1280
  process.exit(1);
1242
1281
  }
1243
1282
  const config = readKandownConfig(kandownDir);
1244
- const defaultStatus = (config && config.board && config.board.columns && config.board.columns[0]) || 'Backlog';
1245
- const targetStatus = args.flags.to ? resolveStatusArg(config, args.flags.to) : defaultStatus;
1246
- if (args.flags.to && !targetStatus) {
1247
- err(`Unknown status: ${args.flags.to}`);
1248
- process.exit(1);
1249
- }
1283
+ const targetStatus = resolveStatusArg(config, args.flags.to) || (config && config.board && config.board.columns ? config.board.columns[0] : 'Backlog');
1250
1284
  const id = args.flags.id || nextTaskId(kandownDir);
1251
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
1252
- err(`Invalid task id: ${id}`);
1253
- process.exit(1);
1254
- }
1255
1285
  const tasksDir = getTasksDir(kandownDir);
1256
1286
  if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
1257
1287
  const targetPath = join(tasksDir, `${id}.md`);
@@ -1259,6 +1289,8 @@ function cmdCreate(rawArgs) {
1259
1289
  err(`Task already exists: ${id}`);
1260
1290
  process.exit(1);
1261
1291
  }
1292
+ const parsedInline = parseInlineQuickAdd(rawTitle);
1293
+ const title = parsedInline.title;
1262
1294
  const fm = {
1263
1295
  id,
1264
1296
  title,
@@ -1889,11 +1921,37 @@ function handleCors(res) {
1889
1921
  * daemon token; otherwise answers 401 and returns false.
1890
1922
  */
1891
1923
  function requireToken(req, res) {
1892
- if (req.headers['x-kandown-token'] === DAEMON_TOKEN) return true;
1924
+ const tokenHeader = req.headers['x-kandown-token'];
1925
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
1926
+ const tokenQuery = requestUrl.searchParams.get('token');
1927
+ if (tokenHeader === DAEMON_TOKEN || tokenQuery === DAEMON_TOKEN) return true;
1893
1928
  writeJson(res, 401, { error: 'missing or invalid X-Kandown-Token' });
1894
1929
  return false;
1895
1930
  }
1896
1931
 
1932
+ const sseClients = new Set();
1933
+
1934
+ function broadcastSseEvent(eventData) {
1935
+ const payload = `data: ${JSON.stringify(eventData)}\n\n`;
1936
+ for (const client of sseClients) {
1937
+ try {
1938
+ client.write(payload);
1939
+ } catch {
1940
+ sseClients.delete(client);
1941
+ }
1942
+ }
1943
+ }
1944
+
1945
+ setInterval(() => {
1946
+ for (const client of sseClients) {
1947
+ try {
1948
+ client.write(':\n\n');
1949
+ } catch {
1950
+ sseClients.delete(client);
1951
+ }
1952
+ }
1953
+ }, 15000);
1954
+
1897
1955
  function writeJson(res, status, body) {
1898
1956
  const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
1899
1957
  res.writeHead(status, headers);
@@ -2315,6 +2373,44 @@ function handleApi(req, res, url, kandownDir) {
2315
2373
  }
2316
2374
  }
2317
2375
 
2376
+ if (resource === 'events') {
2377
+ if (req.method === 'GET') {
2378
+ res.writeHead(200, {
2379
+ 'Content-Type': 'text/event-stream',
2380
+ 'Cache-Control': 'no-cache',
2381
+ 'Connection': 'keep-alive',
2382
+ 'Access-Control-Allow-Origin': '*',
2383
+ });
2384
+ res.write('data: {"type":"connected"}\n\n');
2385
+ sseClients.add(res);
2386
+ req.on('close', () => {
2387
+ sseClients.delete(res);
2388
+ });
2389
+ return;
2390
+ }
2391
+ }
2392
+
2393
+ if (resource === 'git' && parts[1] === 'history') {
2394
+ const taskId = url.searchParams.get('id');
2395
+ if (!taskId) return writeJson(res, 400, { error: 'Missing task id' });
2396
+ try {
2397
+ const taskPath = findTaskPath(kandownDir, taskId);
2398
+ if (!taskPath) return writeJson(res, 404, { error: 'Task not found' });
2399
+ const relativePath = relative(getProjectRoot(kandownDir), taskPath);
2400
+ const output = execFileSync('git', ['log', '-n', '10', '--follow', '--format=%h|%an|%ar|%s', '--', relativePath], {
2401
+ cwd: getProjectRoot(kandownDir),
2402
+ encoding: 'utf8',
2403
+ }).trim();
2404
+ const commits = output ? output.split('\n').map(line => {
2405
+ const [hash, author, date, message] = line.split('|');
2406
+ return { hash, author, date, message };
2407
+ }) : [];
2408
+ return writeJson(res, 200, { commits });
2409
+ } catch {
2410
+ return writeJson(res, 200, { commits: [] });
2411
+ }
2412
+ }
2413
+
2318
2414
  if (resource === 'config') {
2319
2415
  if (req.method === 'GET') return getConfig(res, kandownDir);
2320
2416
  if (req.method === 'PUT') return putConfig(req, res, kandownDir);
@@ -2373,6 +2469,21 @@ function serveApp(res, kandownDir) {
2373
2469
  * follow-up REST task, keeping this refactor limited to server bootstrapping.
2374
2470
  */
2375
2471
  function createServeServer(kandownDir) {
2472
+ try {
2473
+ const tasksDir = getTasksDir(kandownDir);
2474
+ const configPath = join(kandownDir, 'kandown.json');
2475
+ const watcher = watchFs([join(tasksDir, '*.md'), configPath], {
2476
+ ignoreInitial: true,
2477
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
2478
+ });
2479
+ watcher.on('all', (_event, filePath) => {
2480
+ const taskId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.md$/, '') || '';
2481
+ broadcastSseEvent({ type: 'change', taskId });
2482
+ });
2483
+ } catch (e) {
2484
+ console.error('[daemon] File watcher init warning:', e.message);
2485
+ }
2486
+
2376
2487
  return createServer((req, res) => {
2377
2488
  const requestUrl = new URL(req.url || '/', 'http://localhost');
2378
2489
  if (req.method === 'OPTIONS') return handleCors(res);
@@ -2696,21 +2807,266 @@ const skipUpdate = process.argv.slice(2).includes('--no-update-check');
2696
2807
  // children, status probes) must never pay a network round-trip nor risk a
2697
2808
  // mid-pipeline respawn. Inside checkForUpdate there are further guards: 24h
2698
2809
  // throttle, TTY-only, and the KANDOWN_NO_UPDATE=1 opt-out.
2810
+ async function cmdDoctor(rawArgs) {
2811
+ const { kandownDir } = ensureKandownDir(rawArgs);
2812
+ const fix = rawArgs.includes('--fix');
2813
+ log(`${c.bold}kandown doctor${c.reset} ${c.dim}— environment & board diagnostic${c.reset}\n`);
2814
+
2815
+ let errors = 0;
2816
+ let warnings = 0;
2817
+
2818
+ const cliVer = getCurrentVersion();
2819
+ log(` ${c.cyan}CLI Version:${c.reset} ${cliVer}`);
2820
+
2821
+ const configPath = join(kandownDir, 'kandown.json');
2822
+ if (existsSync(configPath)) {
2823
+ try {
2824
+ JSON.parse(readFileSync(configPath, 'utf8'));
2825
+ log(` ${c.green}✓${c.reset} kandown.json valid`);
2826
+ } catch (e) {
2827
+ log(` ${c.red}✗${c.reset} kandown.json invalid JSON: ${e.message}`);
2828
+ errors++;
2829
+ }
2830
+ } else {
2831
+ log(` ${c.red}✗${c.reset} kandown.json missing`);
2832
+ errors++;
2833
+ }
2834
+
2835
+ const daemon = readDaemonMetadata(kandownDir);
2836
+ if (daemon) {
2837
+ const alive = isProcessAlive(daemon.pid);
2838
+ if (alive) {
2839
+ log(` ${c.green}✓${c.reset} Daemon running on port ${daemon.port} (PID ${daemon.pid})`);
2840
+ } else {
2841
+ log(` ${c.yellow}⚠${c.reset} Daemon metadata stale (PID ${daemon.pid} dead)`);
2842
+ warnings++;
2843
+ if (fix) {
2844
+ removeDaemonMetadata(kandownDir);
2845
+ log(` ${c.green}└─ Fixed: removed stale daemon.json${c.reset}`);
2846
+ }
2847
+ }
2848
+ } else {
2849
+ log(` ${c.dim}ℹ Daemon not running${c.reset}`);
2850
+ }
2851
+
2852
+ const tasksDir = getTasksDir(kandownDir);
2853
+ if (existsSync(tasksDir)) {
2854
+ const activeFiles = readdirSync(tasksDir).filter(f => f.endsWith('.md'));
2855
+ const archiveDir = join(tasksDir, 'archive');
2856
+ const archiveFiles = existsSync(archiveDir) ? readdirSync(archiveDir).filter(f => f.endsWith('.md')) : [];
2857
+
2858
+ log(` ${c.cyan}Tasks:${c.reset} ${activeFiles.length} active, ${archiveFiles.length} archived`);
2859
+
2860
+ const activeSet = new Set(activeFiles);
2861
+ const duplicates = archiveFiles.filter(f => activeSet.has(f));
2862
+ if (duplicates.length > 0) {
2863
+ log(` ${c.red}✗${c.reset} Found ${duplicates.length} duplicate file(s) in tasks/ and archive/: ${duplicates.join(', ')}`);
2864
+ errors++;
2865
+ if (fix) {
2866
+ for (const dup of duplicates) {
2867
+ unlinkSync(join(archiveDir, dup));
2868
+ }
2869
+ log(` ${c.green}└─ Fixed: removed duplicate archived files${c.reset}`);
2870
+ }
2871
+ } else {
2872
+ log(` ${c.green}✓${c.reset} No duplicate task files`);
2873
+ }
2874
+
2875
+ let invalidFm = 0;
2876
+ for (const f of activeFiles) {
2877
+ try {
2878
+ const content = readFileSync(join(tasksDir, f), 'utf8');
2879
+ parseFrontmatter(content);
2880
+ } catch {
2881
+ invalidFm++;
2882
+ }
2883
+ }
2884
+ if (invalidFm > 0) {
2885
+ log(` ${c.yellow}⚠${c.reset} ${invalidFm} task file(s) have invalid frontmatter formatting`);
2886
+ warnings++;
2887
+ } else {
2888
+ log(` ${c.green}✓${c.reset} Task frontmatters valid`);
2889
+ }
2890
+ }
2891
+
2892
+ log('');
2893
+ if (errors === 0 && warnings === 0) {
2894
+ success('Everything looks good!');
2895
+ } else {
2896
+ info(`Doctor summary: ${errors} error(s), ${warnings} warning(s). ${fix ? '' : 'Run with --fix to resolve automatically.'}`);
2897
+ }
2898
+ }
2899
+
2900
+ function cmdUndo(rawArgs) {
2901
+ const { kandownDir } = ensureKandownDir(rawArgs);
2902
+ const logPath = join(kandownDir, '.undo', 'log.json');
2903
+ if (!existsSync(logPath)) {
2904
+ info('No actions to undo');
2905
+ return;
2906
+ }
2907
+ try {
2908
+ const list = JSON.parse(readFileSync(logPath, 'utf8'));
2909
+ if (!list || list.length === 0) {
2910
+ info('No actions to undo');
2911
+ return;
2912
+ }
2913
+ const record = list.shift();
2914
+ writeFileSync(logPath, JSON.stringify(list, null, 2), 'utf8');
2915
+ if (record.previousContent === null) {
2916
+ if (existsSync(record.path)) unlinkSync(record.path);
2917
+ } else {
2918
+ const dir = dirname(record.path);
2919
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2920
+ writeFileSync(record.path, record.previousContent, 'utf8');
2921
+ if (record.newContent !== null && record.path.includes('/archive/')) {
2922
+ const activePath = record.path.replace('/archive/', '/');
2923
+ if (existsSync(activePath)) unlinkSync(activePath);
2924
+ }
2925
+ }
2926
+ success(`Undid last action (${record.type} ${record.taskId})`);
2927
+ } catch (e) {
2928
+ err(`Undo failed: ${e.message}`);
2929
+ }
2930
+ }
2931
+
2932
+ async function cmdProjects(rawArgs) {
2933
+ const isJson = rawArgs.includes('--json');
2934
+ const running = [];
2935
+
2936
+ for (let port = START_PORT_RANGE; port <= END_PORT_RANGE; port++) {
2937
+ if (isBrowserUnsafePort(port)) continue;
2938
+ try {
2939
+ const controller = new AbortController();
2940
+ const timeout = setTimeout(() => controller.abort(), 200);
2941
+ const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, { signal: controller.signal });
2942
+ clearTimeout(timeout);
2943
+ if (res.ok) {
2944
+ const data = await res.json();
2945
+ if (data && data.ok) {
2946
+ running.push({ port, pid: data.pid, kandownDir: data.kandownDir, startedAt: data.startedAt, version: data.version });
2947
+ }
2948
+ }
2949
+ } catch {}
2950
+ }
2951
+
2952
+ if (isJson) {
2953
+ out(JSON.stringify(running, null, 2));
2954
+ return;
2955
+ }
2956
+
2957
+ if (running.length === 0) {
2958
+ info('No active kandown web daemons running on this machine');
2959
+ return;
2960
+ }
2961
+
2962
+ log(`${c.bold}Active Kandown Daemons (${running.length})${c.reset}\n`);
2963
+ for (const d of running) {
2964
+ log(` ${c.green}●${c.reset} Port ${c.cyan}${d.port}${c.reset} PID ${d.pid} ${c.dim}${d.kandownDir}${c.reset}`);
2965
+ }
2966
+ function cmdExport(rawArgs) {
2967
+ const { kandownDir } = ensureKandownDir(rawArgs);
2968
+ const isCsv = rawArgs.includes('--csv');
2969
+ const board = readBoard(kandownDir);
2970
+
2971
+ if (isCsv) {
2972
+ let csv = 'id,title,status,priority,assignee,tags,created\n';
2973
+ for (const col of board.columns) {
2974
+ for (const t of col.tasks) {
2975
+ const task = readTask(kandownDir, t.id);
2976
+ const tags = (t.tags || []).join(';');
2977
+ csv += `"${t.id}","${t.title.replace(/"/g, '""')}","${col.name}","${t.priority || ''}","${t.assignee || ''}","${tags}","${task.frontmatter.created || ''}"\n`;
2978
+ }
2979
+ }
2980
+ out(csv);
2981
+ } else {
2982
+ const data = [];
2983
+ for (const col of board.columns) {
2984
+ for (const t of col.tasks) {
2985
+ const task = readTask(kandownDir, t.id);
2986
+ data.push({ ...task, status: col.name });
2987
+ }
2988
+ }
2989
+ out(JSON.stringify(data, null, 2));
2990
+ }
2991
+ }
2992
+
2993
+ function cmdImport(rawArgs) {
2994
+ const { kandownDir } = ensureKandownDir(rawArgs);
2995
+ const fileIdx = rawArgs.findIndex(a => a.endsWith('.json') || a.endsWith('.md'));
2996
+ if (fileIdx === -1 || !existsSync(rawArgs[fileIdx])) {
2997
+ err('Usage: kandown import <file.json | file.md>');
2998
+ process.exit(1);
2999
+ }
3000
+ const filePath = rawArgs[fileIdx];
3001
+ const content = readFileSync(filePath, 'utf8');
3002
+ let count = 0;
3003
+
3004
+ if (filePath.endsWith('.json')) {
3005
+ try {
3006
+ const parsed = JSON.parse(content);
3007
+ const cards = Array.isArray(parsed) ? parsed : (parsed.cards || []);
3008
+ for (const c of cards) {
3009
+ const title = c.name || c.title || 'Imported Task';
3010
+ createTaskInBoard(kandownDir, title, c.status || 'Backlog');
3011
+ count++;
3012
+ }
3013
+ } catch (e) {
3014
+ err(`Import failed: ${e.message}`);
3015
+ process.exit(1);
3016
+ }
3017
+ } else {
3018
+ const lines = content.split('\n');
3019
+ for (const line of lines) {
3020
+ const m = line.match(/^#{1,3}\s+(.+)$/);
3021
+ if (m) {
3022
+ createTaskInBoard(kandownDir, m[1].trim(), 'Backlog');
3023
+ count++;
3024
+ }
3025
+ }
3026
+ }
3027
+
3028
+ success(`Imported ${count} tasks into board`);
3029
+ }
3030
+
2699
3031
  const SCRIPTED_COMMANDS = new Set([
2700
3032
  'list', 'ls', 'show', 'create', 'new', 'move', 'assign', 'commit', 'tasks', 'work', 'daemon',
3033
+ 'doctor', 'undo', 'projects', 'export', 'import',
2701
3034
  ]);
2702
3035
  if (!skipUpdate && !SCRIPTED_COMMANDS.has(cmd)) await checkForUpdate(process.argv);
2703
3036
 
2704
- // 📖 Catches breaking-change notices for upgrades that bypassed the
2705
- // auto-updater above (manual `npm install -g kandown`, pnpm/yarn/bun, a
2706
- // respawned child, etc). See checkVersionSeenNotices' doc comment.
2707
3037
  if (!SCRIPTED_COMMANDS.has(cmd)) checkVersionSeenNotices();
2708
3038
 
2709
3039
  switch (cmd) {
3040
+ case 'export':
3041
+ cmdExport(rest);
3042
+ break;
3043
+
3044
+ case 'import':
3045
+ cmdImport(rest);
3046
+ break;
2710
3047
  case 'init':
2711
3048
  cmdInit(rest);
2712
3049
  break;
2713
3050
 
3051
+ case 'doctor':
3052
+ await cmdDoctor(rest);
3053
+ break;
3054
+
3055
+ case 'undo':
3056
+ cmdUndo(rest);
3057
+ break;
3058
+
3059
+ case 'mcp': {
3060
+ const { kandownDir } = ensureKandownDir(rest);
3061
+ const { startMcpServer } = await import('../src/cli/lib/mcp.js');
3062
+ startMcpServer(kandownDir);
3063
+ break;
3064
+ }
3065
+
3066
+ case 'projects':
3067
+ await cmdProjects(rest);
3068
+ break;
3069
+
2714
3070
  case 'board':
2715
3071
  // 📖 kandown board — open the interactive kanban board TUI only
2716
3072
  await cmdTui('board', rest);