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,349 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import { generateKeyBetween } from 'fractional-indexing'
4
+ import type { Feature, FeatureStatus, KanbanColumn } from '../shared/types'
5
+ import { getTitleFromContent, generateFeatureFilename, extractNumericId, DEFAULT_COLUMNS } from '../shared/types'
6
+ import { allocateCardId, syncCardIdCounter } from '../shared/config'
7
+ import { parseFeatureFile, serializeFeature } from './parser'
8
+ import { ensureDirectories, getFeatureFilePath, moveFeatureFile, renameFeatureFile } from './fileUtils'
9
+ import type { CreateCardInput, BoardConfig } from './types'
10
+
11
+ const BOARD_CONFIG_FILE = 'board.json'
12
+
13
+ export class KanbanSDK {
14
+ constructor(public readonly featuresDir: string) {}
15
+
16
+ async init(): Promise<void> {
17
+ await ensureDirectories(this.featuresDir)
18
+ }
19
+
20
+ // --- Card CRUD ---
21
+
22
+ async listCards(): Promise<Feature[]> {
23
+ await ensureDirectories(this.featuresDir)
24
+ const cards: Feature[] = []
25
+
26
+ // Scan all subdirectories for .md files
27
+ const entries = await fs.readdir(this.featuresDir, { withFileTypes: true })
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
30
+ const subdir = path.join(this.featuresDir, entry.name)
31
+ try {
32
+ const mdFiles = await this._readMdFiles(subdir)
33
+ for (const filePath of mdFiles) {
34
+ const card = await this._loadCard(filePath)
35
+ if (card) cards.push(card)
36
+ }
37
+ } catch {
38
+ // Skip unreadable directories
39
+ }
40
+ }
41
+
42
+ // Also load any orphaned root-level .md files (backward compat)
43
+ try {
44
+ const rootFiles = await this._readMdFiles(this.featuresDir)
45
+ for (const filePath of rootFiles) {
46
+ const card = await this._loadCard(filePath)
47
+ if (card) cards.push(card)
48
+ }
49
+ } catch {
50
+ // Skip
51
+ }
52
+
53
+ // Sync ID counter with existing cards
54
+ const numericIds = cards
55
+ .map(c => parseInt(c.id, 10))
56
+ .filter(n => !Number.isNaN(n))
57
+ if (numericIds.length > 0) {
58
+ syncCardIdCounter(path.dirname(this.featuresDir), numericIds)
59
+ }
60
+
61
+ return cards.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
62
+ }
63
+
64
+ async getCard(cardId: string): Promise<Feature | null> {
65
+ const cards = await this.listCards()
66
+ return cards.find(c => c.id === cardId) || null
67
+ }
68
+
69
+ async createCard(data: CreateCardInput): Promise<Feature> {
70
+ await ensureDirectories(this.featuresDir)
71
+
72
+ const status = data.status || 'backlog'
73
+ const priority = data.priority || 'medium'
74
+ const title = getTitleFromContent(data.content)
75
+ const workspaceRoot = path.dirname(this.featuresDir)
76
+ const numericId = allocateCardId(workspaceRoot)
77
+ const filename = generateFeatureFilename(numericId, title)
78
+ const now = new Date().toISOString()
79
+
80
+ // Compute order: place at end of target column
81
+ const cards = await this.listCards()
82
+ const cardsInStatus = cards
83
+ .filter(c => c.status === status)
84
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
85
+ const lastOrder = cardsInStatus.length > 0
86
+ ? cardsInStatus[cardsInStatus.length - 1].order
87
+ : null
88
+
89
+ const card: Feature = {
90
+ id: String(numericId),
91
+ status,
92
+ priority,
93
+ assignee: data.assignee ?? null,
94
+ dueDate: data.dueDate ?? null,
95
+ created: now,
96
+ modified: now,
97
+ completedAt: status === 'done' ? now : null,
98
+ labels: data.labels || [],
99
+ attachments: data.attachments || [],
100
+ order: generateKeyBetween(lastOrder, null),
101
+ content: data.content,
102
+ filePath: getFeatureFilePath(this.featuresDir, status, filename)
103
+ }
104
+
105
+ await fs.mkdir(path.dirname(card.filePath), { recursive: true })
106
+ await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
107
+
108
+ return card
109
+ }
110
+
111
+ async updateCard(cardId: string, updates: Partial<Feature>): Promise<Feature> {
112
+ const card = await this.getCard(cardId)
113
+ if (!card) throw new Error(`Card not found: ${cardId}`)
114
+
115
+ const oldStatus = card.status
116
+ const oldTitle = getTitleFromContent(card.content)
117
+
118
+ // Merge updates (exclude filePath/id from being overwritten)
119
+ const { filePath: _fp, id: _id, ...safeUpdates } = updates
120
+ Object.assign(card, safeUpdates)
121
+ card.modified = new Date().toISOString()
122
+
123
+ if (oldStatus !== card.status) {
124
+ card.completedAt = card.status === 'done' ? new Date().toISOString() : null
125
+ }
126
+
127
+ // Write updated content
128
+ await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
129
+
130
+ // Rename file if title changed (numeric-ID cards only)
131
+ const newTitle = getTitleFromContent(card.content)
132
+ const numericId = extractNumericId(card.id)
133
+ if (numericId !== null && newTitle !== oldTitle) {
134
+ const newFilename = generateFeatureFilename(numericId, newTitle)
135
+ card.filePath = await renameFeatureFile(card.filePath, newFilename)
136
+ }
137
+
138
+ // Move file if status changed
139
+ if (oldStatus !== card.status) {
140
+ const newPath = await moveFeatureFile(card.filePath, this.featuresDir, card.status, card.attachments)
141
+ card.filePath = newPath
142
+ }
143
+
144
+ return card
145
+ }
146
+
147
+ async moveCard(cardId: string, newStatus: FeatureStatus, position?: number): Promise<Feature> {
148
+ const cards = await this.listCards()
149
+ const card = cards.find(c => c.id === cardId)
150
+ if (!card) throw new Error(`Card not found: ${cardId}`)
151
+
152
+ const oldStatus = card.status
153
+ card.status = newStatus
154
+ card.modified = new Date().toISOString()
155
+
156
+ if (oldStatus !== newStatus) {
157
+ card.completedAt = newStatus === 'done' ? new Date().toISOString() : null
158
+ }
159
+
160
+ // Compute new fractional order
161
+ const targetColumnCards = cards
162
+ .filter(c => c.status === newStatus && c.id !== cardId)
163
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
164
+
165
+ const pos = position !== undefined
166
+ ? Math.max(0, Math.min(position, targetColumnCards.length))
167
+ : targetColumnCards.length
168
+ const before = pos > 0 ? targetColumnCards[pos - 1].order : null
169
+ const after = pos < targetColumnCards.length ? targetColumnCards[pos].order : null
170
+ card.order = generateKeyBetween(before, after)
171
+
172
+ // Write updated content
173
+ await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
174
+
175
+ // Move file if status changed
176
+ if (oldStatus !== newStatus) {
177
+ const newPath = await moveFeatureFile(card.filePath, this.featuresDir, newStatus, card.attachments)
178
+ card.filePath = newPath
179
+ }
180
+
181
+ return card
182
+ }
183
+
184
+ async deleteCard(cardId: string): Promise<void> {
185
+ const card = await this.getCard(cardId)
186
+ if (!card) throw new Error(`Card not found: ${cardId}`)
187
+ await fs.unlink(card.filePath)
188
+ }
189
+
190
+ async getCardsByStatus(status: FeatureStatus): Promise<Feature[]> {
191
+ const cards = await this.listCards()
192
+ return cards.filter(c => c.status === status)
193
+ }
194
+
195
+ async getUniqueAssignees(): Promise<string[]> {
196
+ const cards = await this.listCards()
197
+ const assignees = new Set<string>()
198
+ for (const c of cards) {
199
+ if (c.assignee) assignees.add(c.assignee)
200
+ }
201
+ return [...assignees].sort()
202
+ }
203
+
204
+ async getUniqueLabels(): Promise<string[]> {
205
+ const cards = await this.listCards()
206
+ const labels = new Set<string>()
207
+ for (const c of cards) {
208
+ for (const l of c.labels) labels.add(l)
209
+ }
210
+ return [...labels].sort()
211
+ }
212
+
213
+ // --- Attachment management ---
214
+
215
+ async addAttachment(cardId: string, sourcePath: string): Promise<Feature> {
216
+ const card = await this.getCard(cardId)
217
+ if (!card) throw new Error(`Card not found: ${cardId}`)
218
+
219
+ const fileName = path.basename(sourcePath)
220
+ const cardDir = path.dirname(card.filePath)
221
+ const destPath = path.join(cardDir, fileName)
222
+
223
+ // Copy file if it's not already in the card's directory
224
+ const sourceDir = path.dirname(path.resolve(sourcePath))
225
+ if (sourceDir !== cardDir) {
226
+ await fs.copyFile(path.resolve(sourcePath), destPath)
227
+ }
228
+
229
+ // Add to attachments if not already present
230
+ if (!card.attachments.includes(fileName)) {
231
+ card.attachments.push(fileName)
232
+ }
233
+
234
+ card.modified = new Date().toISOString()
235
+ await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
236
+
237
+ return card
238
+ }
239
+
240
+ async removeAttachment(cardId: string, attachment: string): Promise<Feature> {
241
+ const card = await this.getCard(cardId)
242
+ if (!card) throw new Error(`Card not found: ${cardId}`)
243
+
244
+ card.attachments = card.attachments.filter(a => a !== attachment)
245
+ card.modified = new Date().toISOString()
246
+ await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
247
+
248
+ return card
249
+ }
250
+
251
+ async listAttachments(cardId: string): Promise<string[]> {
252
+ const card = await this.getCard(cardId)
253
+ if (!card) throw new Error(`Card not found: ${cardId}`)
254
+ return card.attachments
255
+ }
256
+
257
+ // --- Column management ---
258
+
259
+ async listColumns(): Promise<KanbanColumn[]> {
260
+ const config = await this._readBoardConfig()
261
+ return config.columns
262
+ }
263
+
264
+ async addColumn(column: KanbanColumn): Promise<KanbanColumn[]> {
265
+ const config = await this._readBoardConfig()
266
+ if (config.columns.some(c => c.id === column.id)) {
267
+ throw new Error(`Column already exists: ${column.id}`)
268
+ }
269
+ config.columns.push(column)
270
+ await this._writeBoardConfig(config)
271
+ return config.columns
272
+ }
273
+
274
+ async updateColumn(columnId: string, updates: Partial<Omit<KanbanColumn, 'id'>>): Promise<KanbanColumn[]> {
275
+ const config = await this._readBoardConfig()
276
+ const col = config.columns.find(c => c.id === columnId)
277
+ if (!col) throw new Error(`Column not found: ${columnId}`)
278
+ if (updates.name !== undefined) col.name = updates.name
279
+ if (updates.color !== undefined) col.color = updates.color
280
+ await this._writeBoardConfig(config)
281
+ return config.columns
282
+ }
283
+
284
+ async removeColumn(columnId: string): Promise<KanbanColumn[]> {
285
+ const config = await this._readBoardConfig()
286
+ const idx = config.columns.findIndex(c => c.id === columnId)
287
+ if (idx === -1) throw new Error(`Column not found: ${columnId}`)
288
+
289
+ // Check if any cards use this column
290
+ const cards = await this.listCards()
291
+ const cardsInColumn = cards.filter(c => c.status === columnId)
292
+ if (cardsInColumn.length > 0) {
293
+ throw new Error(`Cannot remove column "${columnId}": ${cardsInColumn.length} card(s) still in this column`)
294
+ }
295
+
296
+ config.columns.splice(idx, 1)
297
+ await this._writeBoardConfig(config)
298
+ return config.columns
299
+ }
300
+
301
+ async reorderColumns(columnIds: string[]): Promise<KanbanColumn[]> {
302
+ const config = await this._readBoardConfig()
303
+ const colMap = new Map(config.columns.map(c => [c.id, c]))
304
+
305
+ // Validate all IDs exist
306
+ for (const id of columnIds) {
307
+ if (!colMap.has(id)) throw new Error(`Column not found: ${id}`)
308
+ }
309
+ if (columnIds.length !== config.columns.length) {
310
+ throw new Error('Must include all column IDs when reordering')
311
+ }
312
+
313
+ config.columns = columnIds.map(id => colMap.get(id) as KanbanColumn)
314
+ await this._writeBoardConfig(config)
315
+ return config.columns
316
+ }
317
+
318
+ // --- Private helpers ---
319
+
320
+ private async _readMdFiles(dir: string): Promise<string[]> {
321
+ const entries = await fs.readdir(dir, { withFileTypes: true })
322
+ return entries
323
+ .filter(e => e.isFile() && e.name.endsWith('.md'))
324
+ .map(e => path.join(dir, e.name))
325
+ }
326
+
327
+ private async _loadCard(filePath: string): Promise<Feature | null> {
328
+ const content = await fs.readFile(filePath, 'utf-8')
329
+ return parseFeatureFile(content, filePath)
330
+ }
331
+
332
+ private _boardConfigPath(): string {
333
+ return path.join(this.featuresDir, BOARD_CONFIG_FILE)
334
+ }
335
+
336
+ private async _readBoardConfig(): Promise<BoardConfig> {
337
+ try {
338
+ const raw = await fs.readFile(this._boardConfigPath(), 'utf-8')
339
+ return JSON.parse(raw) as BoardConfig
340
+ } catch {
341
+ return { columns: [...DEFAULT_COLUMNS] }
342
+ }
343
+ }
344
+
345
+ private async _writeBoardConfig(config: BoardConfig): Promise<void> {
346
+ await ensureDirectories(this.featuresDir)
347
+ await fs.writeFile(this._boardConfigPath(), JSON.stringify(config, null, 2), 'utf-8')
348
+ }
349
+ }