kandown 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/kandown.js ADDED
@@ -0,0 +1,539 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file Kandown CLI entrypoint
4
+ * @description Implements `kandown`, `kandown board`, `kandown init`,
5
+ * `kandown update`, and `kandown settings` for serving the local web UI,
6
+ * launching the terminal board, installing templates, and managing project
7
+ * configuration.
8
+ *
9
+ * ๐Ÿ“– The CLI is intentionally dependency-light: it copies built artifacts and
10
+ * markdown templates, then wires agent docs into the host project when possible.
11
+ *
12
+ * @functions
13
+ * โ†’ help โ€” prints CLI usage
14
+ * โ†’ copyRecursive โ€” copies template directories
15
+ * โ†’ findAgentsFile โ€” finds existing AI-agent instruction files
16
+ * โ†’ appendAgentReference โ€” injects a Kandown task-management reference
17
+ * โ†’ createAgentsFileIfMissing โ€” creates AGENTS.md when none exists
18
+ * โ†’ parseArgs โ€” parses shared CLI flags
19
+ * โ†’ cmdInit โ€” installs `.kandown`
20
+ * โ†’ cmdUpdate โ€” refreshes installed kandown.html
21
+ * โ†’ createServeServer โ€” creates the local zero-dependency HTTP server
22
+ * โ†’ cmdServe โ€” opens the web UI over localhost and launches the board TUI
23
+ * โ†’ main โ€” dispatches CLI commands
24
+ *
25
+ * @exports none
26
+ */
27
+ /* eslint-disable no-console */
28
+
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createServer } from 'node:http';
31
+ import { dirname, join, resolve } from 'node:path';
32
+ import {
33
+ existsSync,
34
+ mkdirSync,
35
+ copyFileSync,
36
+ readFileSync,
37
+ writeFileSync,
38
+ readdirSync,
39
+ statSync,
40
+ } from 'node:fs';
41
+ import { spawnSync, spawn } from 'node:child_process';
42
+
43
+ const __filename = fileURLToPath(import.meta.url);
44
+ const __dirname = dirname(__filename);
45
+ const PKG_ROOT = resolve(__dirname, '..');
46
+ // ๐Ÿ“– Default localhost range for the zero-config `kandown` web UI server.
47
+ const DEFAULT_SERVE_PORT = 2048;
48
+ const MAX_SERVE_PORT = 2060;
49
+
50
+ const c = {
51
+ reset: '\x1b[0m',
52
+ bold: '\x1b[1m',
53
+ dim: '\x1b[2m',
54
+ green: '\x1b[32m',
55
+ yellow: '\x1b[33m',
56
+ blue: '\x1b[34m',
57
+ red: '\x1b[31m',
58
+ cyan: '\x1b[36m',
59
+ };
60
+
61
+ const log = (msg) => console.log(msg);
62
+ const success = (msg) => log(`${c.green}โœ“${c.reset} ${msg}`);
63
+ const info = (msg) => log(`${c.cyan}โ†’${c.reset} ${msg}`);
64
+ const warn = (msg) => log(`${c.yellow}โš ${c.reset} ${msg}`);
65
+ const err = (msg) => log(`${c.red}โœ—${c.reset} ${msg}`);
66
+
67
+ function help() {
68
+ log(`
69
+ ${c.bold}kandown${c.reset} ${c.dim}ยท file-based kanban backed by markdown${c.reset}
70
+
71
+ ${c.bold}Usage:${c.reset}
72
+ npx kandown [command]
73
+
74
+ ${c.bold}Commands:${c.reset}
75
+ ${c.cyan}(none)${c.reset} Start local web UI server + open the board TUI
76
+ ${c.cyan}board${c.reset} Open the interactive kanban board in the terminal
77
+ ${c.cyan}init${c.reset} Initialize .kandown/ in the current directory
78
+ ${c.cyan}settings${c.reset} Open the settings TUI
79
+ ${c.cyan}update${c.reset} Update kandown.html to the latest version
80
+ ${c.cyan}help${c.reset} Show this help
81
+
82
+ ${c.bold}Options:${c.reset}
83
+ ${c.cyan}--port <n>${c.reset} Preferred local HTTP port for ${c.cyan}kandown${c.reset} (default: ${DEFAULT_SERVE_PORT}-${MAX_SERVE_PORT})
84
+
85
+ ${c.bold}Examples:${c.reset}
86
+ ${c.dim}$${c.reset} npx kandown ${c.dim}# local web server + board TUI${c.reset}
87
+ ${c.dim}$${c.reset} npx kandown --port 3000 ${c.dim}# use a specific web UI port${c.reset}
88
+ ${c.dim}$${c.reset} npx kandown board ${c.dim}# board TUI only${c.reset}
89
+ ${c.dim}$${c.reset} npx kandown init
90
+ ${c.dim}$${c.reset} npx kandown init --path docs/kanban
91
+ ${c.dim}$${c.reset} npx kandown init --no-agents
92
+ `);
93
+ }
94
+
95
+ function copyRecursive(src, dest) {
96
+ if (!existsSync(src)) return;
97
+ mkdirSync(dest, { recursive: true });
98
+ for (const entry of readdirSync(src)) {
99
+ const srcPath = join(src, entry);
100
+ const destPath = join(dest, entry);
101
+ if (statSync(srcPath).isDirectory()) {
102
+ copyRecursive(srcPath, destPath);
103
+ } else {
104
+ copyFileSync(srcPath, destPath);
105
+ }
106
+ }
107
+ }
108
+
109
+ function findAgentsFile(cwd) {
110
+ const candidates = ['AGENTS.md', 'CLAUDE.md', '.cursorrules', '.github/copilot-instructions.md'];
111
+ for (const cand of candidates) {
112
+ const p = join(cwd, cand);
113
+ if (existsSync(p)) return cand;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function appendAgentReference(cwd, agentsFile, kandownPath) {
119
+ const filePath = join(cwd, agentsFile);
120
+ const marker = '<!-- kandown:agent-ref -->';
121
+ const existing = readFileSync(filePath, 'utf8');
122
+
123
+ if (existing.includes(marker)) {
124
+ info(`${agentsFile} already references the kandown (skipped)`);
125
+ return false;
126
+ }
127
+
128
+ const ref = `
129
+
130
+ ${marker}
131
+ ## Task management
132
+
133
+ **IMPORTANT:** Before touching any task files, you MUST read \`AGENT_KANDOWN.md\`.
134
+
135
+ This project uses a file-based kanban:
136
+ - **Tasks live in \`${kandownPath}/tasks/t-xxx.md\`** โ€” each task file owns its status
137
+ - **Columns live in \`${kandownPath}/kandown.json\`** under \`board.columns\`
138
+ - **Completion workflow:** set task frontmatter \`status: Done\` + write the completion report
139
+ `;
140
+
141
+ writeFileSync(filePath, existing + ref, 'utf8');
142
+ return true;
143
+ }
144
+
145
+ function createAgentsFileIfMissing(cwd, kandownPath) {
146
+ const agentsPath = join(cwd, 'AGENTS.md');
147
+ if (existsSync(agentsPath)) return false;
148
+
149
+ const content = `# Agent instructions
150
+
151
+ <!-- kandown:agent-ref -->
152
+ ## Task management
153
+
154
+ **IMPORTANT:** Before touching any task files, you MUST read \`AGENT_KANDOWN.md\`.
155
+
156
+ This project uses a file-based kandown:
157
+ - **Tasks live in \`${kandownPath}/tasks/t-xxx.md\`** โ€” each task file owns its status
158
+ - **Columns live in \`${kandownPath}/kandown.json\`** under \`board.columns\`
159
+ - **Completion workflow:** set task frontmatter \`status: Done\` + write the completion report
160
+ `;
161
+ writeFileSync(agentsPath, content, 'utf8');
162
+ return true;
163
+ }
164
+
165
+ function parseArgs(argv) {
166
+ const args = { path: '.kandown', noAgents: false, force: false, port: null };
167
+ for (let i = 0; i < argv.length; i++) {
168
+ const a = argv[i];
169
+ if (a === '--path' || a === '-p') args.path = argv[++i];
170
+ else if (a === '--port') args.port = argv[++i];
171
+ else if (a === '--no-agents') args.noAgents = true;
172
+ else if (a === '--force' || a === '-f') args.force = true;
173
+ }
174
+ return args;
175
+ }
176
+
177
+ function cmdInit(rawArgs) {
178
+ const args = parseArgs(rawArgs);
179
+ const cwd = process.cwd();
180
+ const kandownDir = resolve(cwd, args.path);
181
+ const kandownPath = args.path;
182
+
183
+ log('');
184
+ info(`Installing kandown in ${c.bold}${kandownPath}/${c.reset}`);
185
+ log('');
186
+
187
+ if (existsSync(kandownDir) && !args.force) {
188
+ err(`Directory ${c.bold}${kandownPath}/${c.reset} already exists.`);
189
+ log(` Use ${c.cyan}--force${c.reset} to overwrite or ${c.cyan}--path <dir>${c.reset} for another location.`);
190
+ process.exit(1);
191
+ }
192
+
193
+ mkdirSync(kandownDir, { recursive: true });
194
+
195
+ // Copy kandown.html from dist
196
+ const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
197
+ const htmlDest = join(kandownDir, 'kandown.html');
198
+ if (!existsSync(htmlSrc)) {
199
+ err(`Missing build output at ${htmlSrc}. Did you run 'npm run build'?`);
200
+ process.exit(1);
201
+ }
202
+ copyFileSync(htmlSrc, htmlDest);
203
+ success('kandown.html');
204
+
205
+ // Copy templates
206
+ const templatesDir = join(PKG_ROOT, 'templates');
207
+ if (!existsSync(join(kandownDir, 'AGENT.md'))) {
208
+ copyFileSync(join(templatesDir, 'AGENT.md'), join(kandownDir, 'AGENT.md'));
209
+ success('AGENT.md');
210
+ }
211
+ if (!existsSync(join(kandownDir, 'README.md'))) {
212
+ copyFileSync(join(templatesDir, 'README.md'), join(kandownDir, 'README.md'));
213
+ success('README.md');
214
+ }
215
+
216
+ const tasksSrc = join(templatesDir, 'tasks');
217
+ const tasksDest = join(kandownDir, 'tasks');
218
+ if (!existsSync(tasksDest)) {
219
+ copyRecursive(tasksSrc, tasksDest);
220
+ success('tasks/ (with welcome example)');
221
+ } else {
222
+ info('tasks/ already exists (kept)');
223
+ }
224
+
225
+ // ๐Ÿ“– Copy kandown.json config file (project preferences for UI, agent, fields)
226
+ if (!existsSync(join(kandownDir, 'kandown.json'))) {
227
+ copyFileSync(join(templatesDir, 'kandown.json'), join(kandownDir, 'kandown.json'));
228
+ success('kandown.json');
229
+ } else {
230
+ info('kandown.json already exists (kept)');
231
+ }
232
+
233
+ // Copy AGENT_KANDOWN.md to project root (not inside .kandown/)
234
+ const agentKandownSrc = join(templatesDir, 'AGENT_KANDOWN.md');
235
+ const agentKandownDest = join(cwd, 'AGENT_KANDOWN.md');
236
+ if (!existsSync(agentKandownDest)) {
237
+ copyFileSync(agentKandownSrc, agentKandownDest);
238
+ success('AGENT_KANDOWN.md (at project root)');
239
+ } else {
240
+ info('AGENT_KANDOWN.md already exists at project root (kept)');
241
+ }
242
+
243
+ // ๐Ÿ“– Copy the compact agent doc โ€” used by the CLI board launcher for system prompt injection
244
+ const compactSrc = join(templatesDir, 'AGENT_KANDOWN_COMPACT.md');
245
+ const compactDest = join(cwd, 'AGENT_KANDOWN_COMPACT.md');
246
+ if (existsSync(compactSrc) && !existsSync(compactDest)) {
247
+ copyFileSync(compactSrc, compactDest);
248
+ success('AGENT_KANDOWN_COMPACT.md (at project root)');
249
+ }
250
+
251
+ // Integrate with AGENTS.md / CLAUDE.md
252
+ if (!args.noAgents) {
253
+ log('');
254
+ const existingAgents = findAgentsFile(cwd);
255
+ if (existingAgents) {
256
+ const added = appendAgentReference(cwd, existingAgents, kandownPath);
257
+ if (added) success(`Appended kandown reference to ${c.bold}${existingAgents}${c.reset}`);
258
+ } else {
259
+ const created = createAgentsFileIfMissing(cwd, kandownPath);
260
+ if (created) success(`Created ${c.bold}AGENTS.md${c.reset} with kandown reference`);
261
+ }
262
+ }
263
+
264
+ log('');
265
+ log(`${c.green}${c.bold}Done.${c.reset}`);
266
+ log('');
267
+ log(` ${c.dim}Next steps:${c.reset}`);
268
+ log(` ${c.cyan}1.${c.reset} Open ${c.bold}${kandownPath}/kandown.html${c.reset} in Chrome/Edge/Brave`);
269
+ log(` ${c.cyan}2.${c.reset} Select the ${c.bold}${kandownPath}/${c.reset} folder when prompted`);
270
+ log(` ${c.cyan}3.${c.reset} Start creating tasks. Press ${c.cyan}โŒ˜K${c.reset} for the command palette`);
271
+ log('');
272
+ log(` ${c.dim}macOS:${c.reset} open ${kandownPath}/kandown.html`);
273
+ log(` ${c.dim}Linux:${c.reset} xdg-open ${kandownPath}/kandown.html`);
274
+ log(` ${c.dim}Windows:${c.reset} start ${kandownPath}/kandown.html`);
275
+ log('');
276
+ }
277
+
278
+ function cmdUpdate(rawArgs) {
279
+ const args = parseArgs(rawArgs);
280
+ const cwd = process.cwd();
281
+ const kandownDir = resolve(cwd, args.path);
282
+ const htmlDest = join(kandownDir, 'kandown.html');
283
+
284
+ if (!existsSync(htmlDest)) {
285
+ err(`No kandown.html found at ${c.bold}${htmlDest}${c.reset}`);
286
+ log(` Run ${c.cyan}npx kandown init${c.reset} first.`);
287
+ process.exit(1);
288
+ }
289
+
290
+ const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
291
+ if (!existsSync(htmlSrc)) {
292
+ err(`Missing build output. Did you run 'npm run build'?`);
293
+ process.exit(1);
294
+ }
295
+ copyFileSync(htmlSrc, htmlDest);
296
+ success(`Updated ${args.path}/kandown.html`);
297
+ }
298
+
299
+ function parsePort(value) {
300
+ if (value === null) return null;
301
+ const port = Number(value);
302
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
303
+ err(`Invalid port: ${c.bold}${value}${c.reset}`);
304
+ log(` Use ${c.cyan}--port <1-65535>${c.reset}.`);
305
+ process.exit(1);
306
+ }
307
+ return port;
308
+ }
309
+
310
+ function writeText(res, status, body, headers = {}) {
311
+ res.writeHead(status, {
312
+ 'Content-Type': 'text/plain; charset=utf-8',
313
+ ...headers,
314
+ });
315
+ res.end(body);
316
+ }
317
+
318
+ function apiHeaders() {
319
+ return {
320
+ 'Access-Control-Allow-Origin': '*',
321
+ 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',
322
+ 'Access-Control-Allow-Headers': 'Content-Type',
323
+ };
324
+ }
325
+
326
+ function handleCors(res) {
327
+ res.writeHead(204, apiHeaders());
328
+ res.end();
329
+ }
330
+
331
+ function handleApi(req, res) {
332
+ writeText(res, 501, 'Not Implemented', apiHeaders());
333
+ }
334
+
335
+ function serveApp(res, kandownDir) {
336
+ const htmlPath = join(kandownDir, 'kandown.html');
337
+ if (!existsSync(htmlPath)) {
338
+ writeText(res, 404, 'kandown.html not found');
339
+ return;
340
+ }
341
+
342
+ try {
343
+ const html = readFileSync(htmlPath, 'utf8');
344
+ const injected = html.replace(
345
+ '</head>',
346
+ `<script>window.__KANDOWN_ROOT__ = ${JSON.stringify(kandownDir)};</script>\n</head>`,
347
+ );
348
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
349
+ res.end(injected);
350
+ } catch (e) {
351
+ writeText(res, 500, `Failed to serve kandown.html: ${e.message}`);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * ๐Ÿ“– Creates the local HTTP server used by `kandown` with no arguments.
357
+ * It serves the single-file web app and exposes placeholder API routes for the
358
+ * follow-up REST task, keeping this refactor limited to server bootstrapping.
359
+ */
360
+ function createServeServer(kandownDir) {
361
+ return createServer((req, res) => {
362
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
363
+ if (req.method === 'OPTIONS') return handleCors(res);
364
+ if (requestUrl.pathname === '/') return serveApp(res, kandownDir);
365
+ if (requestUrl.pathname.startsWith('/api/')) return handleApi(req, res, requestUrl, kandownDir);
366
+ return writeText(res, 404, 'Not found');
367
+ });
368
+ }
369
+
370
+ function listen(server, port) {
371
+ return new Promise((resolveListen, rejectListen) => {
372
+ const onError = (e) => {
373
+ server.off('listening', onListening);
374
+ rejectListen(e);
375
+ };
376
+ const onListening = () => {
377
+ server.off('error', onError);
378
+ resolveListen();
379
+ };
380
+ server.once('error', onError);
381
+ server.once('listening', onListening);
382
+ server.listen(port, '127.0.0.1');
383
+ });
384
+ }
385
+
386
+ async function listenOnAvailablePort(kandownDir, preferredPort) {
387
+ const startPort = preferredPort ?? DEFAULT_SERVE_PORT;
388
+ const endPort = preferredPort ?? MAX_SERVE_PORT;
389
+
390
+ for (let port = startPort; port <= endPort; port++) {
391
+ const server = createServeServer(kandownDir);
392
+ try {
393
+ await listen(server, port);
394
+ return { server, port };
395
+ } catch (e) {
396
+ if (e.code !== 'EADDRINUSE') throw e;
397
+ }
398
+ }
399
+
400
+ const range = preferredPort === null
401
+ ? `${DEFAULT_SERVE_PORT}-${MAX_SERVE_PORT}`
402
+ : String(preferredPort);
403
+ err(`No free port available in ${c.bold}${range}${c.reset}.`);
404
+ process.exit(1);
405
+ }
406
+
407
+ /**
408
+ * ๐Ÿ“– Starts the local web UI server, opens it in the browser, then hands the
409
+ * terminal to the board TUI. The server intentionally stays in this process so
410
+ * the browser can keep talking to localhost while the terminal board is active.
411
+ */
412
+ async function cmdServe(rawArgs) {
413
+ const args = parseArgs(rawArgs);
414
+ const cwd = process.cwd();
415
+ const hasExplicitPath = rawArgs.includes('--path') || rawArgs.includes('-p');
416
+ const explicitKandownDir = resolve(cwd, args.path);
417
+ const kandownDir = hasExplicitPath || existsSync(explicitKandownDir)
418
+ ? explicitKandownDir
419
+ : findKandownDir(cwd);
420
+ if (!kandownDir || !existsSync(kandownDir)) {
421
+ const missingPath = hasExplicitPath ? args.path : '.kandown/';
422
+ err(`No ${c.bold}${missingPath}${c.reset} directory found.`);
423
+ log(` Run ${c.cyan}npx kandown init${c.reset} to set up kandown in this project.`);
424
+ process.exit(1);
425
+ }
426
+
427
+ const preferredPort = parsePort(args.port);
428
+ const { server, port } = await listenOnAvailablePort(kandownDir, preferredPort);
429
+ const url = `http://localhost:${port}`;
430
+
431
+ const shutdown = () => {
432
+ server.close(() => process.exit(0));
433
+ };
434
+ process.once('SIGINT', shutdown);
435
+ process.once('SIGTERM', shutdown);
436
+
437
+ success(`Web UI: ${url}`);
438
+ info(`Project: ${kandownDir}`);
439
+ openInBrowser(url);
440
+ try {
441
+ const tuiArgs = kandownDir === explicitKandownDir ? rawArgs : ['--path', kandownDir, ...rawArgs];
442
+ await cmdTui('board', tuiArgs);
443
+ } finally {
444
+ server.close();
445
+ }
446
+ }
447
+
448
+ /**
449
+ * ๐Ÿ“– Opens a file path in the system default browser/app.
450
+ * Non-blocking โ€” spawns the opener and returns immediately.
451
+ * macOS: open, Linux: xdg-open, Windows: start (via cmd.exe).
452
+ */
453
+ function openInBrowser(filePath) {
454
+ const opener = process.platform === 'darwin'
455
+ ? 'open'
456
+ : process.platform === 'win32'
457
+ ? 'cmd'
458
+ : 'xdg-open';
459
+ const args = process.platform === 'win32' ? ['/c', 'start', '', filePath] : [filePath];
460
+ const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
461
+ child.on('error', (e) => warn(`Could not open browser automatically: ${e.message}`));
462
+ child.unref();
463
+ }
464
+
465
+ /**
466
+ * ๐Ÿ“– Finds the kandown directory from cwd. Checks .kandown/ and kandown/.
467
+ * Returns the resolved absolute path or null if not found.
468
+ */
469
+ function findKandownDir(cwd) {
470
+ const candidates = ['.kandown', 'kandown'];
471
+ for (const dir of candidates) {
472
+ const p = resolve(cwd, dir);
473
+ if (existsSync(p)) return p;
474
+ }
475
+ return null;
476
+ }
477
+
478
+ // ๐Ÿ“– Launches the fullscreen TUI for a given screen (settings, board, etc.)
479
+ async function cmdTui(screen, rawArgs) {
480
+ const args = parseArgs(rawArgs);
481
+ const cwd = process.cwd();
482
+ const kandownDir = resolve(cwd, args.path);
483
+
484
+ if (!existsSync(kandownDir)) {
485
+ err(`No ${c.bold}${args.path}/${c.reset} directory found.`);
486
+ log(` Run ${c.cyan}npx kandown init${c.reset} first.`);
487
+ process.exit(1);
488
+ }
489
+
490
+ try {
491
+ const { run } = await import(new URL('./tui.js', import.meta.url).href);
492
+ await run(screen, kandownDir);
493
+ } catch (e) {
494
+ err(`Failed to launch TUI: ${e.message}`);
495
+ log(` Make sure the CLI is built: ${c.cyan}pnpm build:cli${c.reset}`);
496
+ process.exit(1);
497
+ }
498
+ }
499
+
500
+ const [cmd, ...rest] = process.argv.slice(2);
501
+
502
+ switch (cmd) {
503
+ case 'init':
504
+ cmdInit(rest);
505
+ break;
506
+
507
+ case 'board':
508
+ // ๐Ÿ“– kandown board โ€” open the interactive kanban board TUI only
509
+ await cmdTui('board', rest);
510
+ break;
511
+
512
+ case 'settings':
513
+ await cmdTui('settings', rest);
514
+ break;
515
+
516
+ case 'update':
517
+ cmdUpdate(rest);
518
+ break;
519
+
520
+ case 'help':
521
+ case '--help':
522
+ case '-h':
523
+ help();
524
+ break;
525
+
526
+ case undefined:
527
+ // ๐Ÿ“– kandown (no args) โ€” serve the web UI over localhost and open the board TUI.
528
+ await cmdServe(rest);
529
+ break;
530
+
531
+ default:
532
+ if (cmd.startsWith('-')) {
533
+ await cmdServe([cmd, ...rest]);
534
+ break;
535
+ }
536
+ err(`Unknown command: ${cmd}`);
537
+ help();
538
+ process.exit(1);
539
+ }