kanban-lite 1.0.4

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.
Files changed (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,846 @@
1
+ import * as path from 'path'
2
+ import * as fs from 'fs/promises'
3
+ import { KanbanSDK } from '../sdk/KanbanSDK'
4
+ import type { Feature, FeatureStatus, Priority } from '../shared/types'
5
+ import { loadWebhooks, createWebhook, deleteWebhook } from '../standalone/webhooks'
6
+ import { readConfig, writeConfig, configToSettings, settingsToConfig } from '../shared/config'
7
+ import type { CardDisplaySettings } from '../shared/types'
8
+
9
+ const VALID_STATUSES: FeatureStatus[] = ['backlog', 'todo', 'in-progress', 'review', 'done']
10
+ const VALID_PRIORITIES: Priority[] = ['critical', 'high', 'medium', 'low']
11
+
12
+ // --- Arg parsing ---
13
+
14
+ function parseArgs(argv: string[]): { command: string; positional: string[]; flags: Record<string, string | true> } {
15
+ const args = argv.slice(2)
16
+ const command = args[0] || 'help'
17
+ const positional: string[] = []
18
+ const flags: Record<string, string | true> = {}
19
+
20
+ for (let i = 1; i < args.length; i++) {
21
+ const arg = args[i]
22
+ if (arg.startsWith('--')) {
23
+ const key = arg.slice(2)
24
+ const next = args[i + 1]
25
+ if (next && !next.startsWith('--')) {
26
+ flags[key] = next
27
+ i++
28
+ } else {
29
+ flags[key] = true
30
+ }
31
+ } else {
32
+ positional.push(arg)
33
+ }
34
+ }
35
+
36
+ return { command, positional, flags }
37
+ }
38
+
39
+ // --- Resolve features directory ---
40
+
41
+ async function findWorkspaceRoot(startDir: string): Promise<string> {
42
+ let dir = startDir
43
+ while (true) {
44
+ try {
45
+ await fs.access(path.join(dir, '.git'))
46
+ return dir
47
+ } catch {
48
+ // try package.json
49
+ }
50
+ try {
51
+ await fs.access(path.join(dir, 'package.json'))
52
+ return dir
53
+ } catch {
54
+ // continue up
55
+ }
56
+ const parent = path.dirname(dir)
57
+ if (parent === dir) return startDir // reached filesystem root
58
+ dir = parent
59
+ }
60
+ }
61
+
62
+ async function resolveFeaturesDir(flags: Record<string, string | true>): Promise<string> {
63
+ if (typeof flags.dir === 'string') {
64
+ return path.resolve(flags.dir)
65
+ }
66
+ const root = await findWorkspaceRoot(process.cwd())
67
+ return path.join(root, '.kanban')
68
+ }
69
+
70
+ // --- Colors ---
71
+
72
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`
73
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
74
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`
75
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`
76
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`
77
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`
78
+ const magenta = (s: string) => `\x1b[35m${s}\x1b[0m`
79
+
80
+ function colorStatus(status: string): string {
81
+ switch (status) {
82
+ case 'backlog': return dim(status)
83
+ case 'todo': return cyan(status)
84
+ case 'in-progress': return yellow(status)
85
+ case 'review': return magenta(status)
86
+ case 'done': return green(status)
87
+ default: return status
88
+ }
89
+ }
90
+
91
+ function colorPriority(priority: string): string {
92
+ switch (priority) {
93
+ case 'critical': return red(priority)
94
+ case 'high': return yellow(priority)
95
+ case 'medium': return priority
96
+ case 'low': return dim(priority)
97
+ default: return priority
98
+ }
99
+ }
100
+
101
+ // --- Formatters ---
102
+
103
+ function getTitleFromContent(content: string): string {
104
+ const match = content.match(/^#\s+(.+)$/m)
105
+ if (match) return match[1].trim()
106
+ const firstLine = content.split('\n').map(l => l.trim()).find(l => l.length > 0)
107
+ return firstLine || 'Untitled'
108
+ }
109
+
110
+ function formatCardRow(c: Feature): string {
111
+ const title = getTitleFromContent(c.content)
112
+ const truncTitle = title.length > 40 ? title.slice(0, 37) + '...' : title
113
+ const assignee = c.assignee || '-'
114
+ return ` ${bold(c.id.slice(0, 30).padEnd(30))} ${colorStatus(c.status.padEnd(12))} ${colorPriority(c.priority.padEnd(8))} ${assignee.padEnd(12)} ${truncTitle}`
115
+ }
116
+
117
+ function formatCardDetail(c: Feature): string {
118
+ const title = getTitleFromContent(c.content)
119
+ const lines = [
120
+ `${bold(title)}`,
121
+ '',
122
+ ` ID: ${c.id}`,
123
+ ` Status: ${colorStatus(c.status)}`,
124
+ ` Priority: ${colorPriority(c.priority)}`,
125
+ ` Assignee: ${c.assignee || '-'}`,
126
+ ` Due: ${c.dueDate || '-'}`,
127
+ ` Labels: ${c.labels.length > 0 ? c.labels.join(', ') : '-'}`,
128
+ ` Created: ${c.created}`,
129
+ ` Modified: ${c.modified}`,
130
+ ` File: ${c.filePath}`,
131
+ ]
132
+ if (c.completedAt) {
133
+ lines.push(` Completed: ${c.completedAt}`)
134
+ }
135
+ // Show body content (minus the title heading)
136
+ const body = c.content.replace(/^#\s+.+\n?/, '').trim()
137
+ if (body) {
138
+ lines.push('', dim(' --- Content ---'), '', ' ' + body.split('\n').join('\n '))
139
+ }
140
+ return lines.join('\n')
141
+ }
142
+
143
+ // --- Card Commands ---
144
+
145
+ async function cmdList(sdk: KanbanSDK, flags: Record<string, string | true>): Promise<void> {
146
+ let cards = await sdk.listCards()
147
+
148
+ if (typeof flags.status === 'string') {
149
+ cards = cards.filter(c => c.status === flags.status)
150
+ }
151
+ if (typeof flags.priority === 'string') {
152
+ cards = cards.filter(c => c.priority === flags.priority)
153
+ }
154
+ if (typeof flags.assignee === 'string') {
155
+ cards = cards.filter(c => c.assignee === flags.assignee)
156
+ }
157
+ if (typeof flags.label === 'string') {
158
+ cards = cards.filter(c => c.labels.includes(flags.label as string))
159
+ }
160
+
161
+ if (flags.json) {
162
+ console.log(JSON.stringify(cards, null, 2))
163
+ return
164
+ }
165
+
166
+ if (cards.length === 0) {
167
+ console.log(dim(' No cards found.'))
168
+ return
169
+ }
170
+
171
+ console.log(` ${dim('ID'.padEnd(30))} ${dim('STATUS'.padEnd(12))} ${dim('PRIORITY'.padEnd(8))} ${dim('ASSIGNEE'.padEnd(12))} ${dim('TITLE')}`)
172
+ console.log(dim(' ' + '-'.repeat(90)))
173
+ for (const c of cards) {
174
+ console.log(formatCardRow(c))
175
+ }
176
+ console.log(dim(`\n ${cards.length} card(s)`))
177
+ }
178
+
179
+ async function cmdShow(sdk: KanbanSDK, positional: string[], flags: Record<string, string | true>): Promise<void> {
180
+ const cardId = positional[0]
181
+ if (!cardId) {
182
+ console.error(red('Error: card ID required. Usage: kl show <id>'))
183
+ process.exit(1)
184
+ }
185
+
186
+ const card = await sdk.getCard(cardId)
187
+ if (!card) {
188
+ // Try partial match
189
+ const all = await sdk.listCards()
190
+ const matches = all.filter(c => c.id.includes(cardId))
191
+ if (matches.length === 1) {
192
+ if (flags.json) {
193
+ console.log(JSON.stringify(matches[0], null, 2))
194
+ } else {
195
+ console.log(formatCardDetail(matches[0]))
196
+ }
197
+ return
198
+ } else if (matches.length > 1) {
199
+ console.error(red(`Multiple cards match "${cardId}":`))
200
+ for (const m of matches) console.error(` ${m.id}`)
201
+ process.exit(1)
202
+ }
203
+ console.error(red(`Card not found: ${cardId}`))
204
+ process.exit(1)
205
+ }
206
+
207
+ if (flags.json) {
208
+ console.log(JSON.stringify(card, null, 2))
209
+ } else {
210
+ console.log(formatCardDetail(card))
211
+ }
212
+ }
213
+
214
+ async function cmdAdd(sdk: KanbanSDK, flags: Record<string, string | true>): Promise<void> {
215
+ const title = typeof flags.title === 'string' ? flags.title : ''
216
+ if (!title) {
217
+ console.error(red('Error: --title is required. Usage: kl add --title "My card"'))
218
+ process.exit(1)
219
+ }
220
+
221
+ const status = (typeof flags.status === 'string' ? flags.status : 'backlog') as FeatureStatus
222
+ if (!VALID_STATUSES.includes(status)) {
223
+ console.error(red(`Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}`))
224
+ process.exit(1)
225
+ }
226
+
227
+ const priority = (typeof flags.priority === 'string' ? flags.priority : 'medium') as Priority
228
+ if (!VALID_PRIORITIES.includes(priority)) {
229
+ console.error(red(`Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}`))
230
+ process.exit(1)
231
+ }
232
+
233
+ const assignee = typeof flags.assignee === 'string' ? flags.assignee : null
234
+ const dueDate = typeof flags.due === 'string' ? flags.due : null
235
+ const labels = typeof flags.label === 'string' ? flags.label.split(',').map(l => l.trim()) : []
236
+ const body = typeof flags.body === 'string' ? flags.body : ''
237
+
238
+ const content = `# ${title}${body ? '\n\n' + body : ''}`
239
+
240
+ const card = await sdk.createCard({ content, status, priority, assignee, dueDate, labels })
241
+
242
+ if (flags.json) {
243
+ console.log(JSON.stringify(card, null, 2))
244
+ } else {
245
+ console.log(green(`Created: ${card.id}`))
246
+ console.log(` Status: ${colorStatus(card.status)}, Priority: ${colorPriority(card.priority)}`)
247
+ console.log(` File: ${dim(card.filePath)}`)
248
+ }
249
+ }
250
+
251
+ async function cmdMove(sdk: KanbanSDK, positional: string[], flags: Record<string, string | true>): Promise<void> {
252
+ const cardId = positional[0]
253
+ const newStatus = positional[1] as FeatureStatus
254
+
255
+ if (!cardId || !newStatus) {
256
+ console.error(red('Usage: kl move <id> <status> [--position <n>]'))
257
+ process.exit(1)
258
+ }
259
+ if (!VALID_STATUSES.includes(newStatus)) {
260
+ console.error(red(`Invalid status: ${newStatus}. Must be one of: ${VALID_STATUSES.join(', ')}`))
261
+ process.exit(1)
262
+ }
263
+
264
+ // Support partial ID match
265
+ let resolvedId = cardId
266
+ const card = await sdk.getCard(cardId)
267
+ if (!card) {
268
+ const all = await sdk.listCards()
269
+ const matches = all.filter(c => c.id.includes(cardId))
270
+ if (matches.length === 1) {
271
+ resolvedId = matches[0].id
272
+ } else if (matches.length > 1) {
273
+ console.error(red(`Multiple cards match "${cardId}":`))
274
+ for (const m of matches) console.error(` ${m.id}`)
275
+ process.exit(1)
276
+ } else {
277
+ console.error(red(`Card not found: ${cardId}`))
278
+ process.exit(1)
279
+ }
280
+ }
281
+
282
+ const position = typeof flags.position === 'string' ? parseInt(flags.position, 10) : undefined
283
+ const updated = await sdk.moveCard(resolvedId, newStatus, position)
284
+ console.log(green(`Moved ${updated.id} → ${colorStatus(newStatus)}`))
285
+ }
286
+
287
+ async function cmdEdit(sdk: KanbanSDK, positional: string[], flags: Record<string, string | true>): Promise<void> {
288
+ const cardId = positional[0]
289
+ if (!cardId) {
290
+ console.error(red('Usage: kl edit <id> [--status ...] [--priority ...] [--assignee ...] [--due ...] [--label ...]'))
291
+ process.exit(1)
292
+ }
293
+
294
+ // Support partial ID match
295
+ let resolvedId = cardId
296
+ const card = await sdk.getCard(cardId)
297
+ if (!card) {
298
+ const all = await sdk.listCards()
299
+ const matches = all.filter(c => c.id.includes(cardId))
300
+ if (matches.length === 1) {
301
+ resolvedId = matches[0].id
302
+ } else if (matches.length > 1) {
303
+ console.error(red(`Multiple cards match "${cardId}":`))
304
+ for (const m of matches) console.error(` ${m.id}`)
305
+ process.exit(1)
306
+ } else {
307
+ console.error(red(`Card not found: ${cardId}`))
308
+ process.exit(1)
309
+ }
310
+ }
311
+
312
+ const updates: Partial<Feature> = {}
313
+ if (typeof flags.status === 'string') {
314
+ if (!VALID_STATUSES.includes(flags.status as FeatureStatus)) {
315
+ console.error(red(`Invalid status: ${flags.status}`))
316
+ process.exit(1)
317
+ }
318
+ updates.status = flags.status as FeatureStatus
319
+ }
320
+ if (typeof flags.priority === 'string') {
321
+ if (!VALID_PRIORITIES.includes(flags.priority as Priority)) {
322
+ console.error(red(`Invalid priority: ${flags.priority}`))
323
+ process.exit(1)
324
+ }
325
+ updates.priority = flags.priority as Priority
326
+ }
327
+ if (typeof flags.assignee === 'string') updates.assignee = flags.assignee
328
+ if (typeof flags.due === 'string') updates.dueDate = flags.due
329
+ if (typeof flags.label === 'string') updates.labels = flags.label.split(',').map(l => l.trim())
330
+
331
+ if (Object.keys(updates).length === 0) {
332
+ console.error(red('No updates specified. Use --status, --priority, --assignee, --due, or --label'))
333
+ process.exit(1)
334
+ }
335
+
336
+ const updated = await sdk.updateCard(resolvedId, updates)
337
+ console.log(green(`Updated: ${updated.id}`))
338
+ }
339
+
340
+ async function cmdDelete(sdk: KanbanSDK, positional: string[]): Promise<void> {
341
+ const cardId = positional[0]
342
+ if (!cardId) {
343
+ console.error(red('Usage: kl delete <id>'))
344
+ process.exit(1)
345
+ }
346
+
347
+ // Support partial ID match
348
+ let resolvedId = cardId
349
+ const card = await sdk.getCard(cardId)
350
+ if (!card) {
351
+ const all = await sdk.listCards()
352
+ const matches = all.filter(c => c.id.includes(cardId))
353
+ if (matches.length === 1) {
354
+ resolvedId = matches[0].id
355
+ } else if (matches.length > 1) {
356
+ console.error(red(`Multiple cards match "${cardId}":`))
357
+ for (const m of matches) console.error(` ${m.id}`)
358
+ process.exit(1)
359
+ } else {
360
+ console.error(red(`Card not found: ${cardId}`))
361
+ process.exit(1)
362
+ }
363
+ }
364
+
365
+ await sdk.deleteCard(resolvedId)
366
+ console.log(green(`Deleted: ${resolvedId}`))
367
+ }
368
+
369
+ async function cmdInit(sdk: KanbanSDK): Promise<void> {
370
+ await sdk.init()
371
+ console.log(green(`Initialized: ${sdk.featuresDir}`))
372
+ }
373
+
374
+ // --- Attachment Commands ---
375
+
376
+ async function cmdAttach(sdk: KanbanSDK, positional: string[], flags: Record<string, string | true>): Promise<void> {
377
+ const subcommand = positional[0] || 'list'
378
+ const cardId = positional[1]
379
+
380
+ if (subcommand !== 'list' && subcommand !== 'add' && subcommand !== 'rm' && subcommand !== 'remove') {
381
+ // If first positional looks like a card ID, treat it as "list <cardId>"
382
+ const resolvedId = await resolveCardId(sdk, subcommand)
383
+ const attachments = await sdk.listAttachments(resolvedId)
384
+ if (flags.json) {
385
+ console.log(JSON.stringify(attachments, null, 2))
386
+ } else if (attachments.length === 0) {
387
+ console.log(dim(' No attachments.'))
388
+ } else {
389
+ for (const a of attachments) console.log(` ${a}`)
390
+ }
391
+ return
392
+ }
393
+
394
+ switch (subcommand) {
395
+ case 'list': {
396
+ if (!cardId) {
397
+ console.error(red('Usage: kl attach list <card-id>'))
398
+ process.exit(1)
399
+ }
400
+ const resolvedId = await resolveCardId(sdk, cardId)
401
+ const attachments = await sdk.listAttachments(resolvedId)
402
+ if (flags.json) {
403
+ console.log(JSON.stringify(attachments, null, 2))
404
+ } else if (attachments.length === 0) {
405
+ console.log(dim(' No attachments.'))
406
+ } else {
407
+ for (const a of attachments) console.log(` ${a}`)
408
+ }
409
+ break
410
+ }
411
+ case 'add': {
412
+ if (!cardId) {
413
+ console.error(red('Usage: kl attach add <card-id> <file-path>'))
414
+ process.exit(1)
415
+ }
416
+ const filePath = positional[2]
417
+ if (!filePath) {
418
+ console.error(red('Usage: kl attach add <card-id> <file-path>'))
419
+ process.exit(1)
420
+ }
421
+ const resolvedId = await resolveCardId(sdk, cardId)
422
+ const updated = await sdk.addAttachment(resolvedId, filePath)
423
+ console.log(green(`Attached to ${updated.id}: ${path.basename(filePath)}`))
424
+ break
425
+ }
426
+ case 'remove':
427
+ case 'rm': {
428
+ if (!cardId) {
429
+ console.error(red('Usage: kl attach remove <card-id> <filename>'))
430
+ process.exit(1)
431
+ }
432
+ const filename = positional[2]
433
+ if (!filename) {
434
+ console.error(red('Usage: kl attach remove <card-id> <filename>'))
435
+ process.exit(1)
436
+ }
437
+ const resolvedId = await resolveCardId(sdk, cardId)
438
+ const updated = await sdk.removeAttachment(resolvedId, filename)
439
+ console.log(green(`Removed from ${updated.id}: ${filename}`))
440
+ break
441
+ }
442
+ }
443
+ }
444
+
445
+ async function resolveCardId(sdk: KanbanSDK, cardId: string): Promise<string> {
446
+ const card = await sdk.getCard(cardId)
447
+ if (card) return cardId
448
+
449
+ const all = await sdk.listCards()
450
+ const matches = all.filter(c => c.id.includes(cardId))
451
+ if (matches.length === 1) return matches[0].id
452
+ if (matches.length > 1) {
453
+ console.error(red(`Multiple cards match "${cardId}":`))
454
+ for (const m of matches) console.error(` ${m.id}`)
455
+ process.exit(1)
456
+ }
457
+ console.error(red(`Card not found: ${cardId}`))
458
+ process.exit(1)
459
+ }
460
+
461
+ // --- Column Commands ---
462
+
463
+ async function cmdColumns(sdk: KanbanSDK, positional: string[], flags: Record<string, string | true>): Promise<void> {
464
+ const subcommand = positional[0] || 'list'
465
+
466
+ switch (subcommand) {
467
+ case 'list': {
468
+ const columns = await sdk.listColumns()
469
+ if (flags.json) {
470
+ console.log(JSON.stringify(columns, null, 2))
471
+ } else {
472
+ console.log(` ${dim('ID'.padEnd(20))} ${dim('NAME'.padEnd(20))} ${dim('COLOR')}`)
473
+ console.log(dim(' ' + '-'.repeat(50)))
474
+ for (const col of columns) {
475
+ console.log(` ${bold(col.id.padEnd(20))} ${col.name.padEnd(20)} ${col.color}`)
476
+ }
477
+ }
478
+ break
479
+ }
480
+ case 'add': {
481
+ const id = typeof flags.id === 'string' ? flags.id : ''
482
+ const name = typeof flags.name === 'string' ? flags.name : ''
483
+ const color = typeof flags.color === 'string' ? flags.color : '#6b7280'
484
+ if (!id || !name) {
485
+ console.error(red('Usage: kl columns add --id <id> --name <name> [--color <hex>]'))
486
+ process.exit(1)
487
+ }
488
+ const columns = await sdk.addColumn({ id, name, color })
489
+ console.log(green(`Added column: ${id} (${name})`))
490
+ if (flags.json) console.log(JSON.stringify(columns, null, 2))
491
+ break
492
+ }
493
+ case 'update': {
494
+ const columnId = positional[1]
495
+ if (!columnId) {
496
+ console.error(red('Usage: kl columns update <id> [--name <name>] [--color <hex>]'))
497
+ process.exit(1)
498
+ }
499
+ const updates: Record<string, string> = {}
500
+ if (typeof flags.name === 'string') updates.name = flags.name
501
+ if (typeof flags.color === 'string') updates.color = flags.color
502
+ if (Object.keys(updates).length === 0) {
503
+ console.error(red('No updates specified. Use --name or --color'))
504
+ process.exit(1)
505
+ }
506
+ const columns = await sdk.updateColumn(columnId, updates)
507
+ console.log(green(`Updated column: ${columnId}`))
508
+ if (flags.json) console.log(JSON.stringify(columns, null, 2))
509
+ break
510
+ }
511
+ case 'remove':
512
+ case 'rm': {
513
+ const columnId = positional[1]
514
+ if (!columnId) {
515
+ console.error(red('Usage: kl columns remove <id>'))
516
+ process.exit(1)
517
+ }
518
+ const columns = await sdk.removeColumn(columnId)
519
+ console.log(green(`Removed column: ${columnId}`))
520
+ if (flags.json) console.log(JSON.stringify(columns, null, 2))
521
+ break
522
+ }
523
+ default:
524
+ console.error(red(`Unknown columns subcommand: ${subcommand}`))
525
+ console.error('Available: list, add, update, remove')
526
+ process.exit(1)
527
+ }
528
+ }
529
+
530
+ // --- Webhook Commands ---
531
+
532
+ async function cmdWebhooks(positional: string[], flags: Record<string, string | true>, workspaceRoot: string): Promise<void> {
533
+ const subcommand = positional[0] || 'list'
534
+
535
+ switch (subcommand) {
536
+ case 'list': {
537
+ const webhooks = loadWebhooks(workspaceRoot)
538
+ if (flags.json) {
539
+ console.log(JSON.stringify(webhooks, null, 2))
540
+ } else if (webhooks.length === 0) {
541
+ console.log(dim(' No webhooks registered.'))
542
+ } else {
543
+ console.log(` ${dim('ID'.padEnd(22))} ${dim('URL'.padEnd(40))} ${dim('EVENTS'.padEnd(20))} ${dim('ACTIVE')}`)
544
+ console.log(dim(' ' + '-'.repeat(90)))
545
+ for (const w of webhooks) {
546
+ const events = w.events.join(', ')
547
+ const active = w.active ? green('yes') : red('no')
548
+ console.log(` ${bold(w.id.padEnd(22))} ${w.url.padEnd(40)} ${events.padEnd(20)} ${active}`)
549
+ }
550
+ }
551
+ break
552
+ }
553
+ case 'add': {
554
+ const url = typeof flags.url === 'string' ? flags.url : ''
555
+ if (!url) {
556
+ console.error(red('Usage: kl webhooks add --url <url> [--events <event1,event2>] [--secret <key>]'))
557
+ process.exit(1)
558
+ }
559
+ const events = typeof flags.events === 'string' ? flags.events.split(',').map(e => e.trim()) : ['*']
560
+ const secret = typeof flags.secret === 'string' ? flags.secret : undefined
561
+ const webhook = createWebhook(workspaceRoot, { url, events, secret })
562
+ if (flags.json) {
563
+ console.log(JSON.stringify(webhook, null, 2))
564
+ } else {
565
+ console.log(green(`Created webhook: ${webhook.id}`))
566
+ console.log(` URL: ${webhook.url}`)
567
+ console.log(` Events: ${webhook.events.join(', ')}`)
568
+ if (webhook.secret) console.log(` Secret: ${dim('(configured)')}`)
569
+ }
570
+ break
571
+ }
572
+ case 'remove':
573
+ case 'rm': {
574
+ const webhookId = positional[1]
575
+ if (!webhookId) {
576
+ console.error(red('Usage: kl webhooks remove <id>'))
577
+ process.exit(1)
578
+ }
579
+ const removed = deleteWebhook(workspaceRoot, webhookId)
580
+ if (removed) {
581
+ console.log(green(`Removed webhook: ${webhookId}`))
582
+ } else {
583
+ console.error(red(`Webhook not found: ${webhookId}`))
584
+ process.exit(1)
585
+ }
586
+ break
587
+ }
588
+ default:
589
+ console.error(red(`Unknown webhooks subcommand: ${subcommand}`))
590
+ console.error('Available: list, add, remove')
591
+ process.exit(1)
592
+ }
593
+ }
594
+
595
+ // --- Settings Commands ---
596
+
597
+ const SETTINGS_KEYS = [
598
+ 'showPriorityBadges', 'showAssignee', 'showDueDate', 'showLabels',
599
+ 'showFileName', 'compactMode', 'defaultPriority', 'defaultStatus'
600
+ ] as const
601
+
602
+ async function cmdSettings(positional: string[], flags: Record<string, string | true>, workspaceRoot: string): Promise<void> {
603
+ const subcommand = positional[0] || 'show'
604
+
605
+ switch (subcommand) {
606
+ case 'show':
607
+ case 'list': {
608
+ const config = readConfig(workspaceRoot)
609
+ const settings = configToSettings(config)
610
+ if (flags.json) {
611
+ console.log(JSON.stringify(settings, null, 2))
612
+ } else {
613
+ console.log(` ${dim('SETTING'.padEnd(24))} ${dim('VALUE')}`)
614
+ console.log(dim(' ' + '-'.repeat(40)))
615
+ for (const key of SETTINGS_KEYS) {
616
+ console.log(` ${bold(key.padEnd(24))} ${String(settings[key as keyof CardDisplaySettings])}`)
617
+ }
618
+ }
619
+ break
620
+ }
621
+ case 'update':
622
+ case 'set': {
623
+ const config = readConfig(workspaceRoot)
624
+ const settings = configToSettings(config)
625
+ let changed = false
626
+ const settingsAny = settings as unknown as Record<string, unknown>
627
+ for (const key of SETTINGS_KEYS) {
628
+ if (typeof flags[key] === 'string') {
629
+ const val = flags[key] as string
630
+ if (val === 'true') {
631
+ settingsAny[key] = true
632
+ } else if (val === 'false') {
633
+ settingsAny[key] = false
634
+ } else {
635
+ settingsAny[key] = val
636
+ }
637
+ changed = true
638
+ }
639
+ }
640
+ if (!changed) {
641
+ console.error(red('No settings specified. Use --<setting> <value>'))
642
+ console.error(`Available: ${SETTINGS_KEYS.join(', ')}`)
643
+ process.exit(1)
644
+ }
645
+ writeConfig(workspaceRoot, settingsToConfig(config, settings))
646
+ console.log(green('Settings updated.'))
647
+ if (flags.json) {
648
+ console.log(JSON.stringify(configToSettings(readConfig(workspaceRoot)), null, 2))
649
+ }
650
+ break
651
+ }
652
+ default:
653
+ console.error(red(`Unknown settings subcommand: ${subcommand}`))
654
+ console.error('Available: show, update')
655
+ process.exit(1)
656
+ }
657
+ }
658
+
659
+ // --- Serve Command ---
660
+
661
+ async function cmdServe(flags: Record<string, string | true>): Promise<void> {
662
+ const dir = typeof flags.dir === 'string' ? flags.dir : '.kanban'
663
+ const port = typeof flags.port === 'string' ? parseInt(flags.port, 10) : 3000
664
+ const noBrowser = !!flags['no-browser']
665
+
666
+ // Dynamically import the standalone server
667
+ const { startServer } = await import('../standalone/server')
668
+ const server = startServer(dir, port)
669
+
670
+ if (!noBrowser) {
671
+ server.on('listening', async () => {
672
+ try {
673
+ const open = (await import('open')).default
674
+ open(`http://localhost:${port}`)
675
+ } catch {
676
+ // open is optional
677
+ }
678
+ })
679
+ }
680
+
681
+ process.on('SIGINT', () => {
682
+ console.log('\nShutting down...')
683
+ server.close()
684
+ process.exit(0)
685
+ })
686
+ process.on('SIGTERM', () => {
687
+ server.close()
688
+ process.exit(0)
689
+ })
690
+ }
691
+
692
+ function showHelp(): void {
693
+ console.log(`
694
+ ${bold('kanban-lite')} (${bold('kl')}) - Manage your kanban board from the command line
695
+
696
+ ${bold('Usage:')}
697
+ kanban-lite <command> [options]
698
+ kl <command> [options]
699
+
700
+ ${bold('Card Commands:')}
701
+ list List cards
702
+ show <id> Show card details
703
+ add --title "..." Create a new card
704
+ move <id> <status> Move card to a new status (--position <n>)
705
+ edit <id> [--field value] Update card fields
706
+ delete <id> Delete a card
707
+
708
+ ${bold('Attachment Commands:')}
709
+ attach <id> List attachments on a card
710
+ attach add <id> <path> Attach a file to a card
711
+ attach remove <id> <name> Remove an attachment from a card
712
+
713
+ ${bold('Column Commands:')}
714
+ columns List columns
715
+ columns add Add a column (--id, --name, --color)
716
+ columns update <id> Update a column (--name, --color)
717
+ columns remove <id> Remove a column
718
+
719
+ ${bold('Webhook Commands:')}
720
+ webhooks List registered webhooks
721
+ webhooks add Register a webhook (--url, --events, --secret)
722
+ webhooks remove <id> Remove a webhook
723
+
724
+ ${bold('Settings Commands:')}
725
+ settings Show current settings
726
+ settings update Update settings (--<key> <value>)
727
+
728
+ ${bold('Server:')}
729
+ serve Start standalone web server with REST API
730
+
731
+ ${bold('Other:')}
732
+ init Initialize features directory
733
+ pwd Print workspace root path
734
+
735
+ ${bold('Global Options:')}
736
+ --dir <path> Features directory (default: .kanban)
737
+ --json Output as JSON
738
+
739
+ ${bold('List Filters:')}
740
+ --status <status> Filter by status
741
+ --priority <priority> Filter by priority (critical, high, medium, low)
742
+ --assignee <name> Filter by assignee
743
+ --label <label> Filter by label
744
+
745
+ ${bold('Add/Edit Options:')}
746
+ --title <title> Card title (required for add)
747
+ --body <text> Card body content
748
+ --status <status> Status
749
+ --priority <priority> Priority
750
+ --assignee <name> Assignee
751
+ --due <date> Due date
752
+ --label <l1,l2> Labels (comma-separated)
753
+
754
+ ${bold('Webhook Options:')}
755
+ --url <url> Webhook target URL (required for add)
756
+ --events <e1,e2> Events to subscribe to (default: *)
757
+ --secret <key> HMAC-SHA256 signing secret
758
+
759
+ ${bold('Serve Options:')}
760
+ --port <number> Port to listen on (default: 3000)
761
+ --no-browser Don't open browser automatically
762
+ `)
763
+ }
764
+
765
+ // --- Main ---
766
+
767
+ async function main(): Promise<void> {
768
+ const { command, positional, flags } = parseArgs(process.argv)
769
+
770
+ if (command === 'help' || flags.help) {
771
+ showHelp()
772
+ return
773
+ }
774
+
775
+ // Serve doesn't need SDK
776
+ if (command === 'serve') {
777
+ await cmdServe(flags)
778
+ return
779
+ }
780
+
781
+ const featuresDir = await resolveFeaturesDir(flags)
782
+ const workspaceRoot = path.dirname(featuresDir)
783
+ const sdk = new KanbanSDK(featuresDir)
784
+
785
+ switch (command) {
786
+ case 'list':
787
+ case 'ls':
788
+ await cmdList(sdk, flags)
789
+ break
790
+ case 'show':
791
+ case 'view':
792
+ await cmdShow(sdk, positional, flags)
793
+ break
794
+ case 'add':
795
+ case 'create':
796
+ case 'new':
797
+ await cmdAdd(sdk, flags)
798
+ break
799
+ case 'move':
800
+ case 'mv':
801
+ await cmdMove(sdk, positional, flags)
802
+ break
803
+ case 'edit':
804
+ case 'update':
805
+ await cmdEdit(sdk, positional, flags)
806
+ break
807
+ case 'delete':
808
+ case 'rm':
809
+ await cmdDelete(sdk, positional)
810
+ break
811
+ case 'attach':
812
+ await cmdAttach(sdk, positional, flags)
813
+ break
814
+ case 'columns':
815
+ case 'cols':
816
+ await cmdColumns(sdk, positional, flags)
817
+ break
818
+ case 'webhooks':
819
+ case 'webhook':
820
+ case 'wh':
821
+ await cmdWebhooks(positional, flags, workspaceRoot)
822
+ break
823
+ case 'settings':
824
+ await cmdSettings(positional, flags, workspaceRoot)
825
+ break
826
+ case 'pwd':
827
+ if (flags.json) {
828
+ console.log(JSON.stringify({ path: workspaceRoot }))
829
+ } else {
830
+ console.log(workspaceRoot)
831
+ }
832
+ break
833
+ case 'init':
834
+ await cmdInit(sdk)
835
+ break
836
+ default:
837
+ console.error(red(`Unknown command: ${command}`))
838
+ showHelp()
839
+ process.exit(1)
840
+ }
841
+ }
842
+
843
+ main().catch(err => {
844
+ console.error(red(`Error: ${err.message}`))
845
+ process.exit(1)
846
+ })