kanbango 2.0.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/kanban.js ADDED
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+
3
+ const kanban = require('../kanban.js');
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const BACKLOG = path.join(process.cwd(), 'backlog');
9
+ const COLS = kanban.COLS;
10
+
11
+ function shortId(taskId) {
12
+ const match = taskId.match(/^(PI-\d+[\w.-]*|BUG-\d+|CHORE-\d+)/);
13
+ return match ? match[1] : taskId;
14
+ }
15
+
16
+ function displayTitle(task) {
17
+ return task.title.replace(/^[\w.-]+:\s*/, '');
18
+ }
19
+
20
+ function taskFilePath(task) {
21
+ return path.join(BACKLOG, task.column, `${task.id}.json`);
22
+ }
23
+
24
+ function mcpCommand(useNpx) {
25
+ if (useNpx) {
26
+ return { command: 'npx', args: ['-y', 'kanbango', 'mcp'] };
27
+ }
28
+ return { command: 'node', args: ['./node_modules/kanbango/mcp-server.js'] };
29
+ }
30
+
31
+ function claudeMcpConfig(useNpx) {
32
+ const cmd = mcpCommand(useNpx);
33
+ return {
34
+ mcpServers: {
35
+ 'kanbango': cmd
36
+ }
37
+ };
38
+ }
39
+
40
+ function openCodeMcpConfig(useNpx) {
41
+ const cmd = useNpx
42
+ ? ['npx', '-y', 'kanbango', 'mcp']
43
+ : ['node', './node_modules/kanbango/mcp-server.js'];
44
+
45
+ return {
46
+ '$schema': 'https://opencode.ai/config.json',
47
+ mcp: {
48
+ 'kanbango': {
49
+ type: 'local',
50
+ command: cmd,
51
+ enabled: true
52
+ }
53
+ }
54
+ };
55
+ }
56
+
57
+ async function writeJsonConfig(filePath, data, force) {
58
+ if (fs.existsSync(filePath) && !force) {
59
+ return { status: 'skipped' };
60
+ }
61
+
62
+ await fs.promises.writeFile(
63
+ filePath,
64
+ JSON.stringify(data, null, 2) + '\n',
65
+ 'utf-8'
66
+ );
67
+
68
+ return { status: 'written' };
69
+ }
70
+
71
+ function sendJson(res, statusCode, payload) {
72
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
73
+ res.end(JSON.stringify(payload));
74
+ }
75
+
76
+ function sendError(res, error) {
77
+ const statusCode = error.status || 500;
78
+ sendJson(res, statusCode, {
79
+ error: {
80
+ code: error.code || 'INTERNAL_ERROR',
81
+ message: error.message,
82
+ hint: error.hint || 'Inspect the request payload and try again',
83
+ details: error.details || {},
84
+ retryable: Boolean(error.retryable)
85
+ }
86
+ });
87
+ }
88
+
89
+ async function cliInit() {
90
+ await kanban.ensureBacklogDir();
91
+ const readme = path.join(BACKLOG, 'README.md');
92
+
93
+ if (!fs.existsSync(readme)) {
94
+ await fs.promises.writeFile(
95
+ readme,
96
+ '# Backlog\n\nUtworzony przez kanban.js\n\n'
97
+ + '## Struktura\n'
98
+ + '- `active/` — w trakcie (max 1-2)\n'
99
+ + '- `planned/` — zaplanowane\n'
100
+ + '- `icebox/` — zamrozone / nice-to-have\n'
101
+ + '- `done/` — ukonczone\n',
102
+ 'utf-8'
103
+ );
104
+ }
105
+
106
+ console.log(`✓ Backlog w: ${BACKLOG}`);
107
+ }
108
+
109
+ async function cliMcpInit(options) {
110
+ const useNpx = options.useNpx;
111
+ const force = options.force;
112
+ const onlyClaude = options.onlyClaude;
113
+ const onlyOpenCode = options.onlyOpenCode;
114
+ const cwd = process.cwd();
115
+
116
+ const targets = [];
117
+ if (!onlyOpenCode) {
118
+ targets.push({
119
+ label: '.mcp.json (Claude Code)',
120
+ filePath: path.join(cwd, '.mcp.json'),
121
+ data: claudeMcpConfig(useNpx)
122
+ });
123
+ }
124
+ if (!onlyClaude) {
125
+ targets.push({
126
+ label: 'opencode.json (OpenCode)',
127
+ filePath: path.join(cwd, 'opencode.json'),
128
+ data: openCodeMcpConfig(useNpx)
129
+ });
130
+ }
131
+
132
+ for (const target of targets) {
133
+ const result = await writeJsonConfig(target.filePath, target.data, force);
134
+ if (result.status === 'skipped') {
135
+ console.log(`• Pominięto ${target.label} (już istnieje)`);
136
+ } else {
137
+ console.log(`✓ Utworzono ${target.label}`);
138
+ }
139
+ }
140
+
141
+ if (!force) {
142
+ console.log(' (użyj --force, aby nadpisać istniejące pliki)');
143
+ }
144
+ }
145
+
146
+ async function cliList(colFilter, epicFilter, asJson) {
147
+ let tasks = await kanban.allEpics();
148
+
149
+ if (colFilter) {
150
+ tasks = tasks.filter((task) => task.column === colFilter);
151
+ }
152
+ if (epicFilter) {
153
+ tasks = tasks.filter((task) => task.epic_group === epicFilter);
154
+ }
155
+
156
+ if (asJson) {
157
+ console.log(JSON.stringify(tasks, null, 2));
158
+ return;
159
+ }
160
+
161
+ if (tasks.length === 0) {
162
+ console.log('(brak)');
163
+ return;
164
+ }
165
+
166
+ for (const task of tasks) {
167
+ const progress = kanban.getProgress(task);
168
+ const prog = progress.total ? `${progress.done}/${progress.total}` : '—';
169
+ const title = displayTitle(task).substring(0, 42);
170
+ console.log(
171
+ ` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${task.epic_group}]`
172
+ );
173
+ }
174
+ }
175
+
176
+ async function cliShow(taskId) {
177
+ try {
178
+ const task = await kanban.getTask(taskId);
179
+ console.log(`ID: ${shortId(task.id)}`);
180
+ console.log(`Plik: ${taskFilePath(task)}`);
181
+ console.log(`Tytuł: ${displayTitle(task)}`);
182
+ console.log(`Kolumna: ${task.column}`);
183
+ console.log(`Epik: ${task.epic_group}`);
184
+ console.log(`Worzono: ${task.created || '—'}`);
185
+
186
+ if (task.description) {
187
+ console.log(`Opis: ${task.description}`);
188
+ }
189
+
190
+ if (task.subtasks.length > 0) {
191
+ console.log('Subtaski:');
192
+ for (let i = 0; i < task.subtasks.length; i++) {
193
+ const subtask = task.subtasks[i];
194
+ const mark = subtask.done ? 'x' : ' ';
195
+ console.log(` [${i}] [${mark}] ${subtask.text}`);
196
+ }
197
+ }
198
+ } catch (error) {
199
+ console.error(`✗ ${error.message}`);
200
+ process.exit(1);
201
+ }
202
+ }
203
+
204
+ async function cliMove(taskId, column) {
205
+ const success = await kanban.doMove(taskId, column);
206
+ if (success) {
207
+ console.log(`✓ ${shortId(taskId)} → ${column}`);
208
+ } else {
209
+ console.error(`✗ Nie znaleziono: ${taskId}`);
210
+ process.exit(1);
211
+ }
212
+ }
213
+
214
+ async function cliAdd(title, column, epicGroup) {
215
+ try {
216
+ const task = await kanban.doCreate(title, column, epicGroup);
217
+ console.log(`✓ Utworzono ${shortId(task.id)} w ${column} [${task.epic_group}]`);
218
+ console.log(` Plik: ${taskFilePath(task)}`);
219
+ } catch (error) {
220
+ console.error(`✗ ${error.message}`);
221
+ process.exit(1);
222
+ }
223
+ }
224
+
225
+ async function cliToggle(taskId, idx) {
226
+ const success = await kanban.doToggle(taskId, idx);
227
+ if (!success) {
228
+ console.error(`✗ Nie znaleziono: ${taskId} subtask ${idx}`);
229
+ process.exit(1);
230
+ }
231
+
232
+ const task = await kanban.getTask(taskId);
233
+ if (idx < task.subtasks.length) {
234
+ const subtask = task.subtasks[idx];
235
+ const mark = subtask.done ? '✓' : '○';
236
+ console.log(` [${idx}] ${mark} ${subtask.text}`);
237
+ }
238
+ }
239
+
240
+ async function serveWeb(port) {
241
+ const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
242
+
243
+ const server = http.createServer(async (req, res) => {
244
+ const url = new URL(req.url, `http://localhost:${port}`);
245
+ const requestPath = decodeURIComponent(url.pathname);
246
+
247
+ try {
248
+ if (requestPath === '/' || requestPath === '/index.html') {
249
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
250
+ res.end(html);
251
+ return;
252
+ }
253
+
254
+ if (requestPath === '/api/board') {
255
+ const tasks = await kanban.allEpics();
256
+ sendJson(res, 200, tasks);
257
+ return;
258
+ }
259
+
260
+ if (requestPath === '/api/epics' && req.method === 'POST') {
261
+ const body = await readBody(req);
262
+ const task = await kanban.doCreate(body.title || '', body.column || 'planned', body.epic_group || '—', {
263
+ description: body.description,
264
+ specs: body.specs,
265
+ acceptance_criteria: body.acceptance_criteria,
266
+ subtasks: body.subtasks,
267
+ notes: body.notes
268
+ });
269
+ sendJson(res, 201, task);
270
+ return;
271
+ }
272
+
273
+ if (req.method === 'PATCH') {
274
+ const moveMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/move$/);
275
+ if (moveMatch) {
276
+ const taskId = moveMatch[1];
277
+ const body = await readBody(req);
278
+ const task = await kanban.updateTask(taskId, { column: body.column || '' });
279
+ sendJson(res, 200, task);
280
+ return;
281
+ }
282
+
283
+ const toggleMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/tasks\/(\d+)$/);
284
+ if (toggleMatch) {
285
+ const taskId = toggleMatch[1];
286
+ const idx = parseInt(toggleMatch[2], 10);
287
+ const current = await kanban.getTask(taskId);
288
+ if (idx < 0 || idx >= current.subtasks.length) {
289
+ throw kanban.createKanbanError(
290
+ 'INVALID_SUBTASK_INDEX',
291
+ `Subtask index ${idx} is not valid for task ${taskId}`,
292
+ 'Read the task first and use an index between 0 and subtasks.length - 1',
293
+ { task_id: taskId, idx, total_subtasks: current.subtasks.length },
294
+ false,
295
+ 400
296
+ );
297
+ }
298
+
299
+ const subtasks = current.subtasks.map((subtask, subtaskIdx) => ({
300
+ ...subtask,
301
+ done: subtaskIdx === idx ? !subtask.done : subtask.done
302
+ }));
303
+ const task = await kanban.updateTask(taskId, { subtasks });
304
+ sendJson(res, 200, task);
305
+ return;
306
+ }
307
+
308
+ const updateMatch = requestPath.match(/^\/api\/epics\/([^/]+)$/);
309
+ if (updateMatch) {
310
+ const taskId = updateMatch[1];
311
+ const body = await readBody(req);
312
+ const patch = body.patch ? { ...body.patch } : {};
313
+
314
+ if (body.title !== undefined) patch.title = body.title;
315
+ if (body.tasks !== undefined) patch.subtasks = body.tasks;
316
+ if (body.description !== undefined) patch.description = body.description;
317
+ if (body.specs !== undefined) patch.specs = body.specs;
318
+ if (body.acceptance_criteria !== undefined) patch.acceptance_criteria = body.acceptance_criteria;
319
+ if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
320
+ if (body.notes !== undefined) patch.notes = body.notes;
321
+ if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
322
+
323
+ const task = await kanban.updateTask(taskId, patch);
324
+ sendJson(res, 200, task);
325
+ return;
326
+ }
327
+ }
328
+
329
+ sendJson(res, 404, { error: { code: 'NOT_FOUND', message: 'Route not found' } });
330
+ } catch (error) {
331
+ sendError(res, error);
332
+ }
333
+ });
334
+
335
+ server.listen(port, 'localhost', () => {
336
+ console.log(`\x1b[1;32m→ Kanban GUI: http://localhost:${port}\x1b[0m`);
337
+ console.log(` Backlog: ${BACKLOG}`);
338
+ console.log(' Ctrl+C żeby zamknąć');
339
+ });
340
+ }
341
+
342
+ function readBody(req) {
343
+ return new Promise((resolve) => {
344
+ let body = '';
345
+ req.on('data', (chunk) => {
346
+ body += chunk.toString();
347
+ });
348
+ req.on('end', () => {
349
+ try {
350
+ resolve(JSON.parse(body));
351
+ } catch {
352
+ resolve({});
353
+ }
354
+ });
355
+ });
356
+ }
357
+
358
+ async function cliMigrate(dryRun) {
359
+ const result = await kanban.migrateAll({ dryRun });
360
+
361
+ if (dryRun) {
362
+ console.log(`Found ${result.migrated.length} .md files to migrate:`);
363
+ for (const item of result.migrated) {
364
+ console.log(` ${item.id}.md → ${item.id}.json`);
365
+ }
366
+ return;
367
+ }
368
+
369
+ console.log(`✓ Migrated ${result.migrated.length} tasks from .md → .json`);
370
+ for (const item of result.migrated) {
371
+ console.log(` ${item.id}.md → ${item.id}.json`);
372
+ }
373
+
374
+ if (result.errors.length > 0) {
375
+ console.error(`✗ ${result.errors.length} errors:`);
376
+ for (const err of result.errors) {
377
+ console.error(` ${err.file}: ${err.reason}`);
378
+ }
379
+ }
380
+ }
381
+
382
+ async function main() {
383
+ const args = process.argv.slice(2);
384
+ const cmd = args[0];
385
+
386
+ if (!cmd || cmd === 'serve') {
387
+ const port = parseInt(args[1] || '5500', 10);
388
+ await serveWeb(port);
389
+ } else if (cmd === 'init') {
390
+ await cliInit();
391
+ } else if (cmd === 'mcp-init') {
392
+ let useNpx = false;
393
+ let onlyClaude = false;
394
+ let onlyOpenCode = false;
395
+ let force = false;
396
+
397
+ for (let i = 1; i < args.length; i++) {
398
+ if (args[i] === '--npx') {
399
+ useNpx = true;
400
+ } else if (args[i] === '--claude') {
401
+ onlyClaude = true;
402
+ } else if (args[i] === '--opencode') {
403
+ onlyOpenCode = true;
404
+ } else if (args[i] === '--force') {
405
+ force = true;
406
+ }
407
+ }
408
+
409
+ if (onlyClaude && onlyOpenCode) {
410
+ onlyClaude = false;
411
+ onlyOpenCode = false;
412
+ }
413
+
414
+ await cliMcpInit({ useNpx, onlyClaude, onlyOpenCode, force });
415
+ } else if (cmd === 'migrate') {
416
+ const dryRun = args.includes('--dry-run');
417
+ await cliMigrate(dryRun);
418
+ } else if (cmd === 'list') {
419
+ let colFilter = null;
420
+ let epicFilter = null;
421
+ let asJson = false;
422
+
423
+ for (let i = 1; i < args.length; i++) {
424
+ if (args[i] === '--col' && args[i + 1]) {
425
+ colFilter = args[++i];
426
+ } else if (args[i] === '--epic' && args[i + 1]) {
427
+ epicFilter = args[++i];
428
+ } else if (args[i] === '--json') {
429
+ asJson = true;
430
+ }
431
+ }
432
+
433
+ await cliList(colFilter, epicFilter, asJson);
434
+ } else if (cmd === 'show' && args[1]) {
435
+ await cliShow(args[1]);
436
+ } else if (cmd === 'move' && args[1] && args[2]) {
437
+ await cliMove(args[1], args[2]);
438
+ } else if (cmd === 'add' && args[1]) {
439
+ let column = 'planned';
440
+ let epicGroup = '—';
441
+
442
+ for (let i = 2; i < args.length; i++) {
443
+ if (args[i] === '--col' && args[i + 1]) {
444
+ column = args[++i];
445
+ } else if (args[i] === '--epic' && args[i + 1]) {
446
+ epicGroup = args[++i];
447
+ }
448
+ }
449
+
450
+ await cliAdd(args[1], column, epicGroup);
451
+ } else if (cmd === 'toggle' && args[1] && args[2]) {
452
+ await cliToggle(args[1], parseInt(args[2], 10));
453
+ } else {
454
+ console.error('Unknown command:', cmd);
455
+ process.exit(1);
456
+ }
457
+ }
458
+
459
+ main().catch((err) => {
460
+ console.error('Error:', err);
461
+ process.exit(1);
462
+ });