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 +584 -0
- package/bin/tui.js +109 -5
- package/dist/android-chrome-192x192.png +0 -0
- package/dist/android-chrome-512x512.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/favicon-16x16.png +0 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon-48x48.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +39 -7
- package/dist/index.html +129 -122
- package/dist/kandownlogo.png +0 -0
- package/dist/manifest.json +19 -1
- package/dist/mstile-150x150.png +0 -0
- package/dist/og-image.png +0 -0
- package/package.json +1 -1
- package/templates/AGENT_KANDOWN.md +1 -0
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':
|