kandown 0.11.2 → 0.13.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
@@ -19,7 +19,10 @@
19
19
  * → resolveKandownBin — resolves the global kandown binary path for respawn
20
20
  * → semverGt — compares two semver strings
21
21
  * → checkForUpdate — non-blocking auto-updater with lock file and graceful fallback
22
- * → cmdInitinstalls `.kandown`
22
+ * → getProjectRootreturns the project root (parent of .kandown/)
23
+ * → getTasksDir — returns the project-root tasks/ path
24
+ * → migrateTasksToTopLevel — silently moves legacy .kandown/tasks/ → ./tasks/
25
+ * → cmdInit — installs `.kandown` and top-level `tasks/`
23
26
  * → cmdUpdate — refreshes installed kandown.html
24
27
  * → injectServerRoot — injects the CLI server root into single-file HTML
25
28
  * → createServeServer — creates the local zero-dependency HTTP server
@@ -57,6 +60,7 @@ import {
57
60
  statSync,
58
61
  unlinkSync,
59
62
  renameSync,
63
+ rmdirSync,
60
64
  } from 'node:fs';
61
65
  import { spawnSync, spawn, execSync } from 'node:child_process';
62
66
 
@@ -298,6 +302,7 @@ ${c.bold}Commands:${c.reset}
298
302
  ${c.cyan}settings${c.reset} Open the settings TUI
299
303
  ${c.cyan}daemon${c.reset} Manage the per-project web daemon (start|stop|status)
300
304
  ${c.cyan}update${c.reset} Update kandown.html to the latest version
305
+ ${c.cyan}shell${c.reset} Run shellable task commands (list/show/create/move/assign/commit)
301
306
  ${c.cyan}help${c.reset} Show this help
302
307
 
303
308
  ${c.bold}Options:${c.reset}
@@ -311,6 +316,9 @@ ${c.bold}Examples:${c.reset}
311
316
  ${c.dim}$${c.reset} npx kandown init
312
317
  ${c.dim}$${c.reset} npx kandown init --path docs/kanban
313
318
  ${c.dim}$${c.reset} npx kandown init --no-agents
319
+ ${c.dim}$${c.reset} npx kandown shell list --json
320
+ ${c.dim}$${c.reset} npx kandown shell create "Refactor auth" -p P1
321
+ ${c.dim}$${c.reset} npx kandown shell commit -m "tasks: refactor auth"
314
322
  `);
315
323
  }
316
324
 
@@ -355,8 +363,8 @@ ${marker}
355
363
  **IMPORTANT:** Before touching any task files, you MUST read \`${kandownPath}/AGENT_KANDOWN.md\`.
356
364
 
357
365
  This project uses a file-based kanban:
358
- - **Tasks live in \`${kandownPath}/tasks/t-xxx.md\`** — each task file owns its status
359
- - **Columns live in \`${kandownPath}/kandown.json\`** under \`board.columns\`
366
+ - **Tasks live in \`./tasks/t-xxx.md\`** at the project root — each task file owns its status
367
+ - **Config lives in \`${kandownPath}/kandown.json\`** (columns, appearance, agent settings)
360
368
  - **Completion workflow:** set task frontmatter \`status: Done\` + write the completion report
361
369
  `;
362
370
 
@@ -376,8 +384,8 @@ function createAgentsFileIfMissing(cwd, kandownPath) {
376
384
  **IMPORTANT:** Before touching any task files, you MUST read \`AGENT_KANDOWN.md\`.
377
385
 
378
386
  This project uses a file-based kandown:
379
- - **Tasks live in \`${kandownPath}/tasks/t-xxx.md\`** — each task file owns its status
380
- - **Columns live in \`${kandownPath}/kandown.json\`** under \`board.columns\`
387
+ - **Tasks live in \`./tasks/t-xxx.md\`** at the project root — each task file owns its status
388
+ - **Config lives in \`${kandownPath}/kandown.json\`** (columns, appearance, agent settings)
381
389
  - **Completion workflow:** set task frontmatter \`status: Done\` + write the completion report
382
390
  `;
383
391
  writeFileSync(agentsPath, content, 'utf8');
@@ -396,9 +404,130 @@ function parseArgs(argv) {
396
404
  return args;
397
405
  }
398
406
 
407
+ /**
408
+ * 📖 Returns the absolute path of the project root (parent of the kandown
409
+ * config dir). Tasks live at the project root in `./tasks/`, not inside
410
+ * `.kandown/tasks/`. Used by every code path that touches task files.
411
+ * @param {string} kandownDir absolute path to the kandown config dir
412
+ * @returns {string} absolute path to the project root
413
+ */
414
+ function getProjectRoot(kandownDir) {
415
+ return dirname(kandownDir);
416
+ }
417
+
418
+ /**
419
+ * 📖 Returns the absolute path of the tasks directory at the project root.
420
+ * Mirrors the File System Access layout: `./tasks/` is a sibling of `.kandown/`.
421
+ * @param {string} kandownDir absolute path to the kandown config dir
422
+ * @returns {string} absolute path to `./tasks/`
423
+ */
424
+ function getTasksDir(kandownDir) {
425
+ return join(getProjectRoot(kandownDir), 'tasks');
426
+ }
427
+
428
+ /**
429
+ * 📖 Silently migrates task files from the legacy `.kandown/tasks/` location
430
+ * to the new top-level `./tasks/` location. Idempotent: returns
431
+ * `{ moved, cleanedUp }` describing what was done (or nothing).
432
+ *
433
+ * Rules:
434
+ * - If `./tasks/*.md` already exists, never move anything (avoid clobbering).
435
+ * - If `.kandown/tasks/` doesn't exist or has no .md files, no-op.
436
+ * - Move every `.md` file (including those in `archive/`) to `./tasks/`.
437
+ * - Delete `.kandown/tasks/` only if it's now empty (preserves any non-md
438
+ * files like `.scratch/` notes the user may have stashed there).
439
+ * - Never throw — failures are logged and skipped so a single bad file
440
+ * doesn't block the rest of the migration.
441
+ *
442
+ * @param {string} kandownDir absolute path to the kandown config dir
443
+ * @returns {{ moved: number, cleanedUp: boolean, skipped: boolean }}
444
+ */
445
+ function migrateTasksToTopLevel(kandownDir) {
446
+ const projectRoot = getProjectRoot(kandownDir);
447
+ const oldDir = join(kandownDir, 'tasks');
448
+ const newDir = getTasksDir(kandownDir);
449
+ const result = { moved: 0, cleanedUp: false, skipped: false };
450
+
451
+ if (!existsSync(oldDir)) return result;
452
+ if (!existsSync(newDir)) mkdirSync(newDir, { recursive: true });
453
+
454
+ // 📖 If the new location already has any .md file, don't touch anything.
455
+ // The user is in a hybrid state and we should not clobber their work.
456
+ const existingNew = existsSync(newDir)
457
+ ? readdirSync(newDir).filter(n => n.endsWith('.md'))
458
+ : [];
459
+ if (existingNew.length > 0) {
460
+ result.skipped = true;
461
+ return result;
462
+ }
463
+
464
+ // 📖 If the old location has no .md files either, no migration needed.
465
+ const oldMdFiles = readdirSync(oldDir, { withFileTypes: true })
466
+ .filter(e => e.isFile() && e.name.endsWith('.md'));
467
+ if (oldMdFiles.length === 0) {
468
+ // Still try the archive subfolder.
469
+ return migrateArchive(kandownDir, result);
470
+ }
471
+
472
+ for (const entry of oldMdFiles) {
473
+ try {
474
+ renameSync(join(oldDir, entry.name), join(newDir, entry.name));
475
+ result.moved += 1;
476
+ } catch (e) {
477
+ warn(`migrate: could not move ${entry.name} (${e.code ?? e.message})`);
478
+ }
479
+ }
480
+
481
+ return migrateArchive(kandownDir, result);
482
+ }
483
+
484
+ /**
485
+ * 📖 Helper that moves the legacy `archive/` subfolder to the new location.
486
+ * Called from migrateTasksToTopLevel after the active files have been moved.
487
+ */
488
+ function migrateArchive(kandownDir, result) {
489
+ const oldArchive = join(kandownDir, 'tasks', 'archive');
490
+ const newArchive = join(getTasksDir(kandownDir), 'archive');
491
+ if (!existsSync(oldArchive)) return cleanupLegacyTasksDir(kandownDir, result);
492
+
493
+ if (!existsSync(newArchive)) mkdirSync(newArchive, { recursive: true });
494
+ for (const entry of readdirSync(oldArchive, { withFileTypes: true })) {
495
+ if (entry.isFile() && entry.name.endsWith('.md')) {
496
+ try {
497
+ renameSync(join(oldArchive, entry.name), join(newArchive, entry.name));
498
+ result.moved += 1;
499
+ } catch (e) {
500
+ warn(`migrate: could not move archive/${entry.name} (${e.code ?? e.message})`);
501
+ }
502
+ }
503
+ }
504
+
505
+ return cleanupLegacyTasksDir(kandownDir, result);
506
+ }
507
+
508
+ /**
509
+ * 📖 Removes the legacy `.kandown/tasks/` directory only if it is empty.
510
+ * Preserves any leftover non-md files (e.g. `.scratch/` notes) so we never
511
+ * delete user data the migration didn't move.
512
+ */
513
+ function cleanupLegacyTasksDir(kandownDir, result) {
514
+ const oldDir = join(kandownDir, 'tasks');
515
+ if (!existsSync(oldDir)) return result;
516
+ const remaining = readdirSync(oldDir);
517
+ if (remaining.length === 0) {
518
+ try {
519
+ rmdirSync(oldDir);
520
+ result.cleanedUp = true;
521
+ } catch { /* directory not empty or platform limitation — leave it */ }
522
+ }
523
+ return result;
524
+ }
525
+
399
526
  /**
400
527
  * @returns {{ kandownDir: string, alreadyExisted: boolean }} — resolves the
401
528
  * kandown directory and auto-inits it if it doesn't exist (no prompt, silent init).
529
+ * Also performs a one-time silent migration of tasks from `.kandown/tasks/` to
530
+ * the project root `./tasks/` on first access of a legacy project.
402
531
  */
403
532
  function ensureKandownDir(rawArgs) {
404
533
  const args = parseArgs(rawArgs);
@@ -406,7 +535,18 @@ function ensureKandownDir(rawArgs) {
406
535
  const explicitPath = rawArgs.includes('--path') || rawArgs.includes('-p');
407
536
  const kandownDir = resolve(cwd, args.path);
408
537
 
409
- if (existsSync(kandownDir)) return { kandownDir, alreadyExisted: true };
538
+ if (existsSync(kandownDir)) {
539
+ // 📖 Silent one-time migration: move any legacy `.kandown/tasks/*.md` to
540
+ // the project-root `./tasks/`. Idempotent — safe to call on every startup.
541
+ const migration = migrateTasksToTopLevel(kandownDir);
542
+ if (migration.moved > 0) {
543
+ success(`Migrated ${migration.moved} task${migration.moved === 1 ? '' : 's'} from .kandown/tasks/ to ./tasks/`);
544
+ if (migration.cleanedUp) info('Removed empty .kandown/tasks/ folder');
545
+ } else if (migration.skipped) {
546
+ info('Both .kandown/tasks/ and ./tasks/ have files — leaving both in place');
547
+ }
548
+ return { kandownDir, alreadyExisted: true };
549
+ }
410
550
 
411
551
  log('');
412
552
  info(`No .kandown/ found — auto-installing...`);
@@ -440,13 +580,16 @@ function doInit(args, cwd, kandownPath, kandownDir) {
440
580
  success('README.md');
441
581
  }
442
582
 
583
+ // 📖 Tasks live at the project root in `./tasks/`, not inside `.kandown/`.
584
+ // This keeps config separate from data and follows the user-facing convention.
443
585
  const tasksSrc = join(templatesDir, 'tasks');
444
- const tasksDest = join(kandownDir, 'tasks');
586
+ const tasksDest = getTasksDir(kandownDir);
445
587
  if (!existsSync(tasksDest)) {
588
+ mkdirSync(tasksDest, { recursive: true });
446
589
  copyRecursive(tasksSrc, tasksDest);
447
- success('tasks/ (with welcome example)');
590
+ success('./tasks/ (with welcome example)');
448
591
  } else {
449
- info('tasks/ already exists (kept)');
592
+ info('./tasks/ already exists (kept)');
450
593
  }
451
594
 
452
595
  if (!existsSync(join(kandownDir, 'kandown.json'))) {
@@ -512,9 +655,13 @@ function cmdInit(rawArgs) {
512
655
  log('');
513
656
  log(`${c.green}${c.bold}Done.${c.reset}`);
514
657
  log('');
658
+ log(` ${c.dim}Layout:${c.reset}`);
659
+ log(` ${c.dim}└─${c.reset} ${c.bold}.kandown/${c.reset} — config, web UI, agent docs`);
660
+ log(` ${c.dim}└─${c.reset} ${c.bold}tasks/${c.reset} — task files (source of truth)`);
661
+ log('');
515
662
  log(` ${c.dim}Next steps:${c.reset}`);
516
663
  log(` ${c.cyan}1.${c.reset} Open ${c.bold}${kandownPath}/kandown.html${c.reset} in Chrome/Edge/Brave`);
517
- log(` ${c.cyan}2.${c.reset} Select the ${c.bold}${kandownPath}/${c.reset} folder when prompted`);
664
+ log(` ${c.cyan}2.${c.reset} Select the ${c.bold}project root${c.reset} (the parent of ${c.bold}${kandownPath}/${c.reset})`);
518
665
  log(` ${c.cyan}3.${c.reset} Start creating tasks. Press ${c.cyan}⌘K${c.reset} for the command palette`);
519
666
  log('');
520
667
  log(` ${c.dim}macOS:${c.reset} open ${kandownPath}/kandown.html`);
@@ -544,6 +691,476 @@ function cmdUpdate(rawArgs) {
544
691
  success(`Updated ${args.path}/kandown.html`);
545
692
  }
546
693
 
694
+ /* ═════════════ Shellable task commands ═════════════ */
695
+ /**
696
+ * 📖 Self-contained minimal YAML frontmatter parser/writer for the CLI shell
697
+ * commands. The web app uses a richer schema (parseSimpleYaml) in the
698
+ * browser; the CLI keeps its own because it can't import the browser bundle
699
+ * and these commands only need to round-trip a known small set of scalar
700
+ * fields (status, priority, assignee, tags, ownerType, depends_on, etc.).
701
+ *
702
+ * Quirk: tags and depends_on are emitted as inline YAML arrays
703
+ * `[a, b, c]` because list-as-block-scalar is harder to round-trip cleanly
704
+ * and shell tools downstream (jq, awk, grep) parse the inline form
705
+ * trivially.
706
+ */
707
+
708
+ function parseFrontmatter(content) {
709
+ const out = { frontmatter: {}, body: content || '' };
710
+ if (!content || !content.startsWith('---\n')) return out;
711
+ const end = content.indexOf('\n---\n', 4);
712
+ if (end === -1) {
713
+ out.body = content;
714
+ return out;
715
+ }
716
+ const yaml = content.slice(4, end);
717
+ out.body = content.slice(end + 5).replace(/^\n+/, '');
718
+ for (const line of yaml.split('\n')) {
719
+ if (!line.trim() || line.trim().startsWith('#')) continue;
720
+ const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
721
+ if (!m) continue;
722
+ const key = m[1];
723
+ let val = (m[2] || '').trim();
724
+ if (val.startsWith('[') && val.endsWith(']')) {
725
+ val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
726
+ } else if (val === 'true' || val === 'false') {
727
+ val = val === 'true';
728
+ } else if (val === 'null' || val === '') {
729
+ val = null;
730
+ } else if (/^-?\d+(\.\d+)?$/.test(val)) {
731
+ val = Number(val);
732
+ } else {
733
+ val = val.replace(/^["']|["']$/g, '');
734
+ }
735
+ out.frontmatter[key] = val;
736
+ }
737
+ return out;
738
+ }
739
+
740
+ function serializeFrontmatter(fm, body) {
741
+ const lines = ['---'];
742
+ for (const [k, v] of Object.entries(fm || {})) {
743
+ if (v === null || v === undefined || v === '') continue;
744
+ if (Array.isArray(v)) {
745
+ if (v.length === 0) continue;
746
+ lines.push(`${k}: [${v.join(', ')}]`);
747
+ } else if (typeof v === 'string' && v.includes('\n')) {
748
+ lines.push(`${k}: |`);
749
+ lines.push(...v.split('\n').map(l => (l === '' ? '' : ` ${l}`)));
750
+ } else {
751
+ lines.push(`${k}: ${v}`);
752
+ }
753
+ }
754
+ lines.push('---', '', (body || '').replace(/^\n+/, '').replace(/\n+$/, '') + '\n');
755
+ return lines.join('\n');
756
+ }
757
+
758
+ function readKandownConfig(kandownDir) {
759
+ const configPath = join(kandownDir, 'kandown.json');
760
+ if (!existsSync(configPath)) return null;
761
+ try {
762
+ return JSON.parse(readFileSync(configPath, 'utf8'));
763
+ } catch {
764
+ return null;
765
+ }
766
+ }
767
+
768
+ function findTaskFile(kandownDir, id) {
769
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return null;
770
+ const tasksDir = getTasksDir(kandownDir);
771
+ const inTasks = join(tasksDir, `${id}.md`);
772
+ if (existsSync(inTasks)) return inTasks;
773
+ const inArchive = join(tasksDir, 'archive', `${id}.md`);
774
+ if (existsSync(inArchive)) return inArchive;
775
+ return null;
776
+ }
777
+
778
+ function listAllTaskIds(kandownDir) {
779
+ const tasksDir = getTasksDir(kandownDir);
780
+ const ids = new Set();
781
+ if (existsSync(tasksDir)) {
782
+ for (const f of readdirSync(tasksDir).filter(f => f.endsWith('.md'))) {
783
+ ids.add(f.replace(/\.md$/, ''));
784
+ }
785
+ const archive = join(tasksDir, 'archive');
786
+ if (existsSync(archive)) {
787
+ for (const f of readdirSync(archive).filter(f => f.endsWith('.md'))) {
788
+ ids.add(f.replace(/\.md$/, ''));
789
+ }
790
+ }
791
+ }
792
+ return [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
793
+ }
794
+
795
+ function nextTaskId(kandownDir) {
796
+ const ids = listAllTaskIds(kandownDir);
797
+ let maxN = 0;
798
+ for (const id of ids) {
799
+ const m = id.match(/^t(\d+)$/i);
800
+ if (m) {
801
+ const n = parseInt(m[1], 10);
802
+ if (Number.isFinite(n) && n > maxN) maxN = n;
803
+ }
804
+ }
805
+ return 't' + (maxN + 1);
806
+ }
807
+
808
+ function shellPad(str, len) {
809
+ const s = String(str);
810
+ if (s.length >= len) return s.slice(0, Math.max(0, len - 1)) + (s.length > len ? '…' : '');
811
+ return s + ' '.repeat(len - s.length);
812
+ }
813
+
814
+ function shellParseArgs(argv) {
815
+ // 📖 Minimal flag parser for the shell subcommands. Stops at the first
816
+ // positional arg so `kandown create "Some title with -dash"` works.
817
+ const out = { positional: [], flags: {} };
818
+ for (let i = 0; i < argv.length; i++) {
819
+ const a = argv[i];
820
+ if (a === '--json') out.flags.json = true;
821
+ else if (a === '--archived') out.flags.archived = true;
822
+ else if (a === '-s' || a === '--status') out.flags.status = argv[++i];
823
+ else if (a === '-a' || a === '--assignee') out.flags.assignee = argv[++i];
824
+ else if (a === '-p' || a === '--priority') out.flags.priority = argv[++i];
825
+ else if (a === '-t' || a === '--tag') out.flags.tags = (out.flags.tags || []).concat([argv[++i]]);
826
+ else if (a === '-d' || a === '--depends-on') out.flags.dependsOn = (out.flags.dependsOn || []).concat([argv[++i]]);
827
+ else if (a === '-m' || a === '--message') out.flags.message = argv[++i];
828
+ else if (a === '--to') out.flags.to = argv[++i];
829
+ else if (a === '--id') out.flags.id = argv[++i];
830
+ else out.positional.push(a);
831
+ }
832
+ return out;
833
+ }
834
+
835
+ function shellResolveStatus(config, status) {
836
+ // 📖 Status can be a configured column name OR the reserved `archived`
837
+ // sentinel. Match is case-insensitive to keep shell usage forgiving.
838
+ if (!status) return null;
839
+ const lower = status.toLowerCase();
840
+ if (lower === 'archived') return 'archived';
841
+ const columns = (config && config.board && config.board.columns) || [];
842
+ for (const c of columns) {
843
+ if (c.toLowerCase() === lower) return c;
844
+ }
845
+ return null;
846
+ }
847
+
848
+ function shellList(rawArgs) {
849
+ const { kandownDir } = ensureKandownDir(rawArgs);
850
+ const args = shellParseArgs(rawArgs);
851
+ const config = readKandownConfig(kandownDir);
852
+ const statusFilter = args.flags.status ? shellResolveStatus(config, args.flags.status) : null;
853
+ if (args.flags.status && !statusFilter) {
854
+ err(`Unknown status: ${args.flags.status}`);
855
+ process.exit(1);
856
+ }
857
+ if (statusFilter && statusFilter !== 'archived' && config && !(config.board.columns || []).map(c => c.toLowerCase()).includes(statusFilter.toLowerCase())) {
858
+ err(`Status not in board columns: ${statusFilter}`);
859
+ process.exit(1);
860
+ }
861
+
862
+ const ids = listAllTaskIds(kandownDir);
863
+ const rows = [];
864
+ for (const id of ids) {
865
+ const path = findTaskFile(kandownDir, id);
866
+ if (!path) continue;
867
+ const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
868
+ const fm = parsed.frontmatter;
869
+ const isArchived = fm.archived === true || fm.archived === 'true' || path.includes('/archive/');
870
+ if (args.flags.archived ? !isArchived : isArchived) continue;
871
+ if (statusFilter && statusFilter !== 'archived' && (fm.status || '').toLowerCase() !== statusFilter.toLowerCase()) continue;
872
+ if (args.flags.assignee && fm.assignee !== args.flags.assignee) continue;
873
+ if (args.flags.priority && (fm.priority || '').toLowerCase() !== args.flags.priority.toLowerCase()) continue;
874
+ if (args.flags.tags && args.flags.tags.length > 0) {
875
+ const taskTags = Array.isArray(fm.tags) ? fm.tags : [];
876
+ const wanted = new Set(args.flags.tags);
877
+ let ok = true;
878
+ for (const t of wanted) { if (!taskTags.includes(t)) { ok = false; break; } }
879
+ if (!ok) continue;
880
+ }
881
+ rows.push({ id, fm, body: parsed.body });
882
+ }
883
+
884
+ if (args.flags.json) {
885
+ process.stdout.write(JSON.stringify(rows.map(r => ({
886
+ id: r.id, ...r.fm, archived: r.fm.archived === true || r.fm.archived === 'true',
887
+ })), null, 2) + '\n');
888
+ return;
889
+ }
890
+
891
+ if (rows.length === 0) {
892
+ log(c.dim + '(no tasks)' + c.reset);
893
+ return;
894
+ }
895
+
896
+ const idW = Math.max(2, ...rows.map(r => r.id.length));
897
+ log(`${c.dim}${shellPad('ID', idW)} ${shellPad('STATUS', 14)} ${shellPad('PRI', 4)} ${shellPad('ASSIGNEE', 12)} TITLE${c.reset}`);
898
+ for (const r of rows) {
899
+ const status = (r.fm.status || 'Backlog') + (r.fm.archived === true || r.fm.archived === 'true' ? ' (archived)' : '');
900
+ const pri = r.fm.priority || '';
901
+ const assignee = r.fm.assignee || '';
902
+ const title = (r.fm.title || '(untitled)').replace(/\n/g, ' ');
903
+ log(`${shellPad(r.id, idW)} ${shellPad(status, 14)} ${shellPad(pri, 4)} ${shellPad(assignee, 12)} ${title}`);
904
+ }
905
+ }
906
+
907
+ function shellShow(rawArgs) {
908
+ const { kandownDir } = ensureKandownDir(rawArgs);
909
+ const args = shellParseArgs(rawArgs);
910
+ const id = args.positional[0];
911
+ if (!id) {
912
+ err('Usage: kandown show <id>');
913
+ process.exit(1);
914
+ }
915
+ const path = findTaskFile(kandownDir, id);
916
+ if (!path) {
917
+ err(`Task not found: ${id}`);
918
+ process.exit(1);
919
+ }
920
+ process.stdout.write(readFileSync(path, 'utf8'));
921
+ }
922
+
923
+ /**
924
+ * 📖 Resolves a task id to whether it is in a "blocking" state (i.e. still
925
+ * pending). Used by the move gate. Mirrors the web store's logic: a dep is
926
+ * resolved when it lives in the terminal column OR is archived. Unknown
927
+ * ids and self-references never block.
928
+ */
929
+ function shellTaskIsResolved(kandownDir, id, terminalLower) {
930
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return true; // unknown / invalid → don't block
931
+ const path = findTaskFile(kandownDir, id);
932
+ if (!path) return true; // unknown id → don't block
933
+ const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
934
+ const isArch = parsed.frontmatter.archived === true || parsed.frontmatter.archived === 'true';
935
+ return isArch || (parsed.frontmatter.status || '').toLowerCase() === terminalLower;
936
+ }
937
+
938
+ function shellCreate(rawArgs) {
939
+ const { kandownDir } = ensureKandownDir(rawArgs);
940
+ const args = shellParseArgs(rawArgs);
941
+ const title = args.positional.join(' ').trim();
942
+ if (!title) {
943
+ err('Usage: kandown create "title" [-p priority] [-a assignee] [-t tag] [--to status]');
944
+ process.exit(1);
945
+ }
946
+ const config = readKandownConfig(kandownDir);
947
+ const defaultStatus = (config && config.board && config.board.columns && config.board.columns[0]) || 'Backlog';
948
+ const targetStatus = args.flags.to ? shellResolveStatus(config, args.flags.to) : defaultStatus;
949
+ if (args.flags.to && !targetStatus) {
950
+ err(`Unknown status: ${args.flags.to}`);
951
+ process.exit(1);
952
+ }
953
+ const id = args.flags.id || nextTaskId(kandownDir);
954
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
955
+ err(`Invalid task id: ${id}`);
956
+ process.exit(1);
957
+ }
958
+ const tasksDir = getTasksDir(kandownDir);
959
+ if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
960
+ const targetPath = join(tasksDir, `${id}.md`);
961
+ if (existsSync(targetPath)) {
962
+ err(`Task already exists: ${id}`);
963
+ process.exit(1);
964
+ }
965
+ const fm = {
966
+ id,
967
+ title,
968
+ status: targetStatus,
969
+ created: new Date().toISOString().slice(0, 10),
970
+ };
971
+ if (args.flags.priority) fm.priority = args.flags.priority;
972
+ if (args.flags.assignee) fm.assignee = args.flags.assignee;
973
+ if (args.flags.tags && args.flags.tags.length > 0) fm.tags = args.flags.tags;
974
+ if (args.flags.dependsOn && args.flags.dependsOn.length > 0) fm.depends_on = args.flags.dependsOn;
975
+ const content = serializeFrontmatter(fm, '');
976
+ writeFileSync(targetPath, content, 'utf8');
977
+ log(`${c.green}✓${c.reset} Created ${c.bold}${id}${c.reset} → ${targetStatus}`);
978
+ if (args.flags.json) {
979
+ process.stdout.write(JSON.stringify({ id, ...fm }, null, 2) + '\n');
980
+ } else {
981
+ // 📖 Print the id on stdout (last line) so scripts can do
982
+ // `ID=$(kandown create "...")` without parsing the colored status line.
983
+ process.stdout.write(id + '\n');
984
+ }
985
+ }
986
+
987
+ function shellMove(rawArgs) {
988
+ const { kandownDir } = ensureKandownDir(rawArgs);
989
+ const args = shellParseArgs(rawArgs);
990
+ const [id, rawStatus] = args.positional;
991
+ const targetStatus = rawStatus || args.flags.to;
992
+ if (!id || !targetStatus) {
993
+ err('Usage: kandown move <id> <status>');
994
+ process.exit(1);
995
+ }
996
+ const config = readKandownConfig(kandownDir);
997
+ const resolved = shellResolveStatus(config, targetStatus);
998
+ if (!resolved) {
999
+ err(`Unknown status: ${targetStatus}`);
1000
+ process.exit(1);
1001
+ }
1002
+ const path = findTaskFile(kandownDir, id);
1003
+ if (!path) {
1004
+ err(`Task not found: ${id}`);
1005
+ process.exit(1);
1006
+ }
1007
+ // 📖 Terminal-status gate: if the target is the configured terminal column
1008
+ // (default: last entry of `board.columns`, "Done"), refuse the move while
1009
+ // any `depends_on` is not yet resolved. Mirrors the web store + TUI gate.
1010
+ if (config && Array.isArray(config.board.columns) && config.board.columns.length > 0) {
1011
+ const terminalLower = (config.board.columns[config.board.columns.length - 1]).toLowerCase();
1012
+ if (resolved.toLowerCase() === terminalLower) {
1013
+ const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1014
+ const deps = Array.isArray(parsed.frontmatter.depends_on) ? parsed.frontmatter.depends_on : [];
1015
+ const blocked = [];
1016
+ for (const dep of deps) {
1017
+ if (typeof dep !== 'string' || !dep.trim() || dep === id) continue;
1018
+ if (!shellTaskIsResolved(kandownDir, dep, terminalLower)) blocked.push(dep);
1019
+ }
1020
+ if (blocked.length > 0) {
1021
+ const list = blocked.length === 1 ? blocked[0] : `${blocked.slice(0, -1).join(', ')} and ${blocked[blocked.length - 1]}`;
1022
+ err(`Cannot move ${id} to ${resolved}: blocked by ${list}`);
1023
+ process.exit(1);
1024
+ }
1025
+ }
1026
+ }
1027
+ const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1028
+ parsed.frontmatter.status = resolved;
1029
+ if (resolved === 'archived') parsed.frontmatter.archived = true;
1030
+ else delete parsed.frontmatter.archived;
1031
+ // 📖 When archiving, move the file to tasks/archive/ to match what the
1032
+ // web UI does. Mirrors src/lib/filesystem.ts#archiveTaskFile.
1033
+ if (resolved === 'archived') {
1034
+ const archiveDir = join(getTasksDir(kandownDir), 'archive');
1035
+ if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1036
+ writeFileSync(join(archiveDir, `${id}.md`), serializeFrontmatter(parsed.frontmatter, parsed.body), 'utf8');
1037
+ try { unlinkSync(path); } catch { /* already absent */ }
1038
+ } else {
1039
+ writeFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body), 'utf8');
1040
+ }
1041
+ log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${resolved}`);
1042
+ }
1043
+
1044
+ function shellAssign(rawArgs) {
1045
+ const { kandownDir } = ensureKandownDir(rawArgs);
1046
+ const args = shellParseArgs(rawArgs);
1047
+ const [id, name] = args.positional;
1048
+ if (!id) {
1049
+ err('Usage: kandown assign <id> [name] (omit name to unassign)');
1050
+ process.exit(1);
1051
+ }
1052
+ const path = findTaskFile(kandownDir, id);
1053
+ if (!path) {
1054
+ err(`Task not found: ${id}`);
1055
+ process.exit(1);
1056
+ }
1057
+ const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1058
+ if (name) parsed.frontmatter.assignee = name;
1059
+ else delete parsed.frontmatter.assignee;
1060
+ writeFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body), 'utf8');
1061
+ log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${name ? c.cyan + name : c.dim + '(unassigned)'}${c.reset}`);
1062
+ }
1063
+
1064
+ function shellCommit(rawArgs) {
1065
+ const { kandownDir } = ensureKandownDir(rawArgs);
1066
+ const args = shellParseArgs(rawArgs);
1067
+ const projectRoot = getProjectRoot(kandownDir);
1068
+ // 📖 Refuse to commit if not inside a git repo — silently failing here would
1069
+ // let the user believe they versioned their tasks when they didn't.
1070
+ try {
1071
+ execSync('git rev-parse --is-inside-work-tree', { cwd: projectRoot, stdio: 'pipe' });
1072
+ } catch {
1073
+ err('Not a git repository — kandown commit only stages and commits in a git repo.');
1074
+ err(' Run `git init` first or commit manually.');
1075
+ process.exit(1);
1076
+ }
1077
+ const tasksRel = 'tasks';
1078
+ const configRel = '.kandown/kandown.json';
1079
+ const staged = [];
1080
+ for (const rel of [tasksRel, configRel]) {
1081
+ const abs = join(projectRoot, rel);
1082
+ if (!existsSync(abs)) continue;
1083
+ try {
1084
+ execSync(`git add -A -- "${rel}"`, { cwd: projectRoot, stdio: 'pipe' });
1085
+ staged.push(rel);
1086
+ } catch (e) {
1087
+ err(`git add failed for ${rel}: ${e.message}`);
1088
+ process.exit(1);
1089
+ }
1090
+ }
1091
+ if (staged.length === 0) {
1092
+ log(c.dim + 'Nothing to commit.' + c.reset);
1093
+ return;
1094
+ }
1095
+ const message = args.flags.message || `chore(kandown): update tasks`;
1096
+ try {
1097
+ execSync(`git commit -m ${JSON.stringify(message)}`, { cwd: projectRoot, stdio: 'inherit' });
1098
+ success(`Committed ${staged.length} path${staged.length === 1 ? '' : 's'}: ${staged.join(', ')}`);
1099
+ } catch (e) {
1100
+ // 📖 git commit exits non-zero when there's nothing to commit (after the
1101
+ // add). Treat that as a no-op so the user doesn't think they broke
1102
+ // anything.
1103
+ if (e && e.status === 1) {
1104
+ log(c.dim + 'Nothing to commit (working tree clean).' + c.reset);
1105
+ return;
1106
+ }
1107
+ err(`git commit failed: ${e.message}`);
1108
+ process.exit(1);
1109
+ }
1110
+ }
1111
+
1112
+ function cmdShell(subcmd, rawArgs) {
1113
+ switch (subcmd) {
1114
+ case 'list':
1115
+ case 'ls':
1116
+ shellList(rawArgs);
1117
+ return;
1118
+ case 'show':
1119
+ shellShow(rawArgs);
1120
+ return;
1121
+ case 'create':
1122
+ case 'new':
1123
+ shellCreate(rawArgs);
1124
+ return;
1125
+ case 'move':
1126
+ shellMove(rawArgs);
1127
+ return;
1128
+ case 'assign':
1129
+ shellAssign(rawArgs);
1130
+ return;
1131
+ case 'commit':
1132
+ shellCommit(rawArgs);
1133
+ return;
1134
+ case undefined:
1135
+ case 'help':
1136
+ case '--help':
1137
+ case '-h':
1138
+ log(`
1139
+ ${c.bold}kandown shell${c.reset} ${c.dim}· task commands (one-shot, scriptable)${c.reset}
1140
+
1141
+ ${c.bold}Commands:${c.reset}
1142
+ ${c.cyan}list${c.reset} [-s status] [-a assignee] [-t tag] [-p priority] [--archived] [--json]
1143
+ ${c.cyan}show${c.reset} <id>
1144
+ ${c.cyan}create${c.reset} "title" [-p priority] [-a assignee] [-t tag ...] [--to status] [--id custom-id] [--json]
1145
+ ${c.cyan}move${c.reset} <id> <status> (status is a column name or "archived")
1146
+ ${c.cyan}assign${c.reset} <id> [name] (omit name to unassign)
1147
+ ${c.cyan}commit${c.reset} [-m "message"] (git add tasks/ + .kandown/kandown.json + git commit)
1148
+
1149
+ ${c.bold}Examples:${c.reset}
1150
+ ${c.dim}$${c.reset} kandown list --json | jq '.[] | select(.priority=="P1")'
1151
+ ${c.dim}$${c.reset} kandown create "Refactor auth middleware" -p P1 -t backend
1152
+ ${c.dim}$${c.reset} kandown move t42 Done
1153
+ ${c.dim}$${c.reset} kandown assign t42 alice
1154
+ ${c.dim}$${c.reset} kandown commit -m "tasks: add auth refactor"
1155
+ `);
1156
+ return;
1157
+ default:
1158
+ err(`Unknown shell command: ${subcmd}`);
1159
+ log(` Run ${c.cyan}kandown shell help${c.reset} for the list.`);
1160
+ process.exit(1);
1161
+ }
1162
+ }
1163
+
547
1164
  function parsePort(value) {
548
1165
  if (value === null) return null;
549
1166
  const port = Number(value);
@@ -733,6 +1350,105 @@ function readBody(req) {
733
1350
  });
734
1351
  }
735
1352
 
1353
+ /**
1354
+ * 📖 Resolves the agent hook configuration from environment variables.
1355
+ *
1356
+ * Strictly opt-in: if KANDOWN_AGENT_HOOK_URL is not set, returns null and no
1357
+ * agent-related UI surfaces in the web app. This is the only check the UI
1358
+ * uses to decide whether to show the "Send to Agent" button.
1359
+ *
1360
+ * Env vars (all optional except URL):
1361
+ * - KANDOWN_AGENT_HOOK_URL target URL (POST receives `{action, task, context}`)
1362
+ * - KANDOWN_AGENT_HOOK_LABEL button label (default: "Agent")
1363
+ * - KANDOWN_AGENT_HOOK_HEADERS JSON object of extra headers (default: {})
1364
+ *
1365
+ * Malformed HEADERS are silently ignored so a typo never disables the hook.
1366
+ */
1367
+ function loadAgentHook() {
1368
+ const url = process.env.KANDOWN_AGENT_HOOK_URL;
1369
+ if (!url) return null;
1370
+ const label = process.env.KANDOWN_AGENT_HOOK_LABEL || 'Agent';
1371
+ const headers = {};
1372
+ const raw = process.env.KANDOWN_AGENT_HOOK_HEADERS;
1373
+ if (raw) {
1374
+ try {
1375
+ const parsed = JSON.parse(raw);
1376
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1377
+ for (const [k, v] of Object.entries(parsed)) {
1378
+ if (typeof v === 'string') headers[k] = v;
1379
+ }
1380
+ }
1381
+ } catch {
1382
+ // Malformed JSON — ignore and ship with the default empty headers.
1383
+ }
1384
+ }
1385
+ return { url, label, headers };
1386
+ }
1387
+
1388
+ /**
1389
+ * 📖 Forwards a task to the configured agent hook. Returns a status object
1390
+ * the HTTP handler converts to a JSON response. Failures (network, non-2xx,
1391
+ * timeout) are surfaced as 502 so the UI can show a useful error.
1392
+ */
1393
+ async function postAgentTask(hook, taskMarkdown, id, kandownDir) {
1394
+ const controller = new AbortController();
1395
+ const timeout = setTimeout(() => controller.abort(), 8000);
1396
+ try {
1397
+ const res = await fetch(hook.url, {
1398
+ method: 'POST',
1399
+ headers: {
1400
+ 'Content-Type': 'application/json',
1401
+ ...hook.headers,
1402
+ },
1403
+ body: JSON.stringify({
1404
+ action: 'agent',
1405
+ task: { id, content: taskMarkdown },
1406
+ context: {
1407
+ tasksDir: getTasksDir(kandownDir),
1408
+ cwd: getProjectRoot(kandownDir),
1409
+ schema: 'kandown',
1410
+ },
1411
+ }),
1412
+ signal: controller.signal,
1413
+ });
1414
+ const body = await res.text().catch(() => '');
1415
+ return { ok: res.ok, status: res.status, body };
1416
+ } catch (e) {
1417
+ const message = e && e.name === 'AbortError' ? 'agent hook timed out (8s)' : `agent hook failed: ${e.message}`;
1418
+ return { ok: false, status: 502, body: message };
1419
+ } finally {
1420
+ clearTimeout(timeout);
1421
+ }
1422
+ }
1423
+
1424
+ function postTaskToAgent(req, res, kandownDir, id) {
1425
+ if (!isValidTaskId(id)) {
1426
+ return writeText(res, 400, 'Invalid task id');
1427
+ }
1428
+ const hook = loadAgentHook();
1429
+ if (!hook) {
1430
+ return writeJson(res, 501, { error: 'agent hook not configured (set KANDOWN_AGENT_HOOK_URL)' });
1431
+ }
1432
+ const taskPath = findTaskPath(kandownDir, id);
1433
+ if (!taskPath) {
1434
+ return writeJson(res, 404, { error: 'task not found' });
1435
+ }
1436
+ let taskMarkdown;
1437
+ try {
1438
+ taskMarkdown = readFileSync(taskPath, 'utf8');
1439
+ } catch (e) {
1440
+ return writeJson(res, 500, { error: `failed to read task: ${e.message}` });
1441
+ }
1442
+ postAgentTask(hook, taskMarkdown, id, kandownDir).then(result => {
1443
+ if (!result.ok) {
1444
+ return writeJson(res, 502, { error: result.body || `agent hook returned ${result.status}` });
1445
+ }
1446
+ return writeJson(res, 200, { ok: true });
1447
+ }).catch(e => {
1448
+ writeJson(res, 500, { error: e.message });
1449
+ });
1450
+ }
1451
+
736
1452
  function isValidTaskId(id) {
737
1453
  return /^[a-zA-Z0-9_-]+$/.test(id);
738
1454
  }
@@ -796,17 +1512,21 @@ function putBoard(req, res, kandownDir) {
796
1512
  * first, then the archive subfolder. Returns null when the id exists in
797
1513
  * neither location. Used by every CRUD handler so archived tasks stay
798
1514
  * reachable at their real location.
1515
+ *
1516
+ * Tasks live at the project root in `./tasks/` (sibling of `.kandown/`),
1517
+ * not inside `.kandown/tasks/`.
799
1518
  */
800
1519
  function findTaskPath(kandownDir, id) {
801
- const inTasks = join(kandownDir, 'tasks', `${id}.md`);
1520
+ const tasksDir = getTasksDir(kandownDir);
1521
+ const inTasks = join(tasksDir, `${id}.md`);
802
1522
  if (existsSync(inTasks)) return inTasks;
803
- const inArchive = join(kandownDir, 'tasks', 'archive', `${id}.md`);
1523
+ const inArchive = join(tasksDir, 'archive', `${id}.md`);
804
1524
  if (existsSync(inArchive)) return inArchive;
805
1525
  return null;
806
1526
  }
807
1527
 
808
1528
  function getTasks(res, kandownDir) {
809
- const tasksDir = join(kandownDir, 'tasks');
1529
+ const tasksDir = getTasksDir(kandownDir);
810
1530
  const archiveDir = join(tasksDir, 'archive');
811
1531
  const ids = new Set();
812
1532
  try {
@@ -853,7 +1573,7 @@ function putTask(req, res, kandownDir, id) {
853
1573
  return;
854
1574
  }
855
1575
  readBody(req).then(body => {
856
- const tasksDir = join(kandownDir, 'tasks');
1576
+ const tasksDir = getTasksDir(kandownDir);
857
1577
  if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
858
1578
  // 📖 Write in place: keep an archived task inside archive/ on save so the
859
1579
  // file location never drifts from its archived flag.
@@ -887,6 +1607,66 @@ function deleteTask(res, kandownDir, id) {
887
1607
  }
888
1608
  }
889
1609
 
1610
+ /**
1611
+ * 📖 Archives a task: writes the (already flag-updated) content into
1612
+ * `tasks/archive/<id>.md` and removes the active `tasks/<id>.md` copy.
1613
+ * The body comes pre-flagged from the web client (which knows frontmatter).
1614
+ */
1615
+ function archiveTask(req, res, kandownDir, id) {
1616
+ if (!isValidTaskId(id)) {
1617
+ writeText(res, 400, 'Invalid task id');
1618
+ return;
1619
+ }
1620
+ readBody(req).then(body => {
1621
+ const tasksDir = getTasksDir(kandownDir);
1622
+ const archiveDir = join(tasksDir, 'archive');
1623
+ if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1624
+ writeFileSync(join(archiveDir, `${id}.md`), body, 'utf8');
1625
+ try {
1626
+ unlinkSync(join(tasksDir, `${id}.md`));
1627
+ } catch { /* already absent */ }
1628
+ writeJson(res, 200, { ok: true });
1629
+ }).catch(e => {
1630
+ writeJson(res, 500, { error: `Failed to archive task: ${e.message}` });
1631
+ });
1632
+ }
1633
+
1634
+ /**
1635
+ * 📖 Unarchives a task: writes the content back into `tasks/<id>.md` and
1636
+ * removes the archived copy. Mirror of archiveTask.
1637
+ */
1638
+ function unarchiveTask(req, res, kandownDir, id) {
1639
+ if (!isValidTaskId(id)) {
1640
+ writeText(res, 400, 'Invalid task id');
1641
+ return;
1642
+ }
1643
+ readBody(req).then(body => {
1644
+ const tasksDir = getTasksDir(kandownDir);
1645
+ writeFileSync(join(tasksDir, `${id}.md`), body, 'utf8');
1646
+ try {
1647
+ unlinkSync(join(tasksDir, 'archive', `${id}.md`));
1648
+ } catch { /* already absent */ }
1649
+ writeJson(res, 200, { ok: true });
1650
+ }).catch(e => {
1651
+ writeJson(res, 500, { error: `Failed to unarchive task: ${e.message}` });
1652
+ });
1653
+ }
1654
+
1655
+ /**
1656
+ * 📖 REST endpoint to trigger the silent one-time task migration. The web
1657
+ * client (server mode) can call this on startup to perform the same
1658
+ * `.kandown/tasks/` → `./tasks/` move the CLI does on first access.
1659
+ * Safe to call multiple times — idempotent.
1660
+ */
1661
+ function postMigrateTasks(res, kandownDir) {
1662
+ try {
1663
+ const result = migrateTasksToTopLevel(kandownDir);
1664
+ writeJson(res, 200, { ok: true, ...result });
1665
+ } catch (e) {
1666
+ writeJson(res, 500, { error: `Migration failed: ${e.message}` });
1667
+ }
1668
+ }
1669
+
890
1670
  /**
891
1671
  * 📖 The single-file Vite bundle can contain literal strings such as
892
1672
  * `</head>` from HTML parser libraries. Use the last closing head tag so the
@@ -910,12 +1690,14 @@ function handleApi(req, res, url, kandownDir) {
910
1690
 
911
1691
  if (resource === 'daemon') {
912
1692
  if (req.method === 'GET') {
1693
+ const hook = loadAgentHook();
913
1694
  return writeJson(res, 200, {
914
1695
  ok: true,
915
1696
  pid: process.pid,
916
1697
  kandownDir,
917
1698
  version: getCurrentVersion(),
918
1699
  startedAt: daemonStartedAt,
1700
+ agentHook: hook ? { enabled: true, label: hook.label } : null,
919
1701
  });
920
1702
  }
921
1703
  }
@@ -939,6 +1721,12 @@ function handleApi(req, res, url, kandownDir) {
939
1721
  // the full task file content with the archived flag already toggled.
940
1722
  if (req.method === 'POST' && id && parts[2] === 'archive') return archiveTask(req, res, kandownDir, id);
941
1723
  if (req.method === 'POST' && id && parts[2] === 'unarchive') return unarchiveTask(req, res, kandownDir, id);
1724
+ if (req.method === 'POST' && id && parts[2] === 'agent') return postTaskToAgent(req, res, kandownDir, id);
1725
+ }
1726
+
1727
+ // 📖 Migration endpoint: `POST /api/migrate-tasks` with no id. Idempotent.
1728
+ if (resource === 'migrate-tasks' && req.method === 'POST' && !id) {
1729
+ return postMigrateTasks(res, kandownDir);
942
1730
  }
943
1731
 
944
1732
  writeJson(res, 404, { error: 'Not found' });
@@ -1307,6 +2095,14 @@ switch (cmd) {
1307
2095
  cmdUpdate(rest);
1308
2096
  break;
1309
2097
 
2098
+ case 'shell': {
2099
+ // 📖 Two-level command: `kandown shell <subcmd> [...args]`. Pull the
2100
+ // first non-flag arg as the subcommand and forward the rest.
2101
+ const [shellSubcmd, ...shellRest] = rest;
2102
+ cmdShell(shellSubcmd, shellRest);
2103
+ break;
2104
+ }
2105
+
1310
2106
  case 'help':
1311
2107
  case '--help':
1312
2108
  case '-h':