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/README.md +13 -4
- package/bin/kandown.js +810 -14
- package/bin/tui.js +121 -11
- 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 +25 -7
- package/dist/index.html +134 -127
- 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.md +11 -8
- package/templates/AGENT_KANDOWN.md +16 -8
- package/templates/README.md +24 -20
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
|
-
* →
|
|
22
|
+
* → getProjectRoot — returns 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
|
|
359
|
-
- **
|
|
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
|
|
380
|
-
- **
|
|
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))
|
|
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 =
|
|
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}
|
|
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
|
|
1520
|
+
const tasksDir = getTasksDir(kandownDir);
|
|
1521
|
+
const inTasks = join(tasksDir, `${id}.md`);
|
|
802
1522
|
if (existsSync(inTasks)) return inTasks;
|
|
803
|
-
const inArchive = join(
|
|
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 =
|
|
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 =
|
|
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':
|