kandown 0.12.0 → 0.13.1

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
@@ -302,6 +302,7 @@ ${c.bold}Commands:${c.reset}
302
302
  ${c.cyan}settings${c.reset} Open the settings TUI
303
303
  ${c.cyan}daemon${c.reset} Manage the per-project web daemon (start|stop|status)
304
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)
305
306
  ${c.cyan}help${c.reset} Show this help
306
307
 
307
308
  ${c.bold}Options:${c.reset}
@@ -315,6 +316,9 @@ ${c.bold}Examples:${c.reset}
315
316
  ${c.dim}$${c.reset} npx kandown init
316
317
  ${c.dim}$${c.reset} npx kandown init --path docs/kanban
317
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"
318
322
  `);
319
323
  }
320
324
 
@@ -687,6 +691,476 @@ function cmdUpdate(rawArgs) {
687
691
  success(`Updated ${args.path}/kandown.html`);
688
692
  }
689
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
+
690
1164
  function parsePort(value) {
691
1165
  if (value === null) return null;
692
1166
  const port = Number(value);
@@ -876,6 +1350,105 @@ function readBody(req) {
876
1350
  });
877
1351
  }
878
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
+
879
1452
  function isValidTaskId(id) {
880
1453
  return /^[a-zA-Z0-9_-]+$/.test(id);
881
1454
  }
@@ -1117,12 +1690,14 @@ function handleApi(req, res, url, kandownDir) {
1117
1690
 
1118
1691
  if (resource === 'daemon') {
1119
1692
  if (req.method === 'GET') {
1693
+ const hook = loadAgentHook();
1120
1694
  return writeJson(res, 200, {
1121
1695
  ok: true,
1122
1696
  pid: process.pid,
1123
1697
  kandownDir,
1124
1698
  version: getCurrentVersion(),
1125
1699
  startedAt: daemonStartedAt,
1700
+ agentHook: hook ? { enabled: true, label: hook.label } : null,
1126
1701
  });
1127
1702
  }
1128
1703
  }
@@ -1146,6 +1721,7 @@ function handleApi(req, res, url, kandownDir) {
1146
1721
  // the full task file content with the archived flag already toggled.
1147
1722
  if (req.method === 'POST' && id && parts[2] === 'archive') return archiveTask(req, res, kandownDir, id);
1148
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);
1149
1725
  }
1150
1726
 
1151
1727
  // 📖 Migration endpoint: `POST /api/migrate-tasks` with no id. Idempotent.
@@ -1519,6 +2095,14 @@ switch (cmd) {
1519
2095
  cmdUpdate(rest);
1520
2096
  break;
1521
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
+
1522
2106
  case 'help':
1523
2107
  case '--help':
1524
2108
  case '-h':