@andypai/agent-kanban 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/LICENSE +21 -0
- package/README.md +306 -0
- package/package.json +80 -0
- package/src/__tests__/activity.test.ts +139 -0
- package/src/__tests__/api.test.ts +74 -0
- package/src/__tests__/commands/board.test.ts +51 -0
- package/src/__tests__/commands/bulk.test.ts +51 -0
- package/src/__tests__/commands/column.test.ts +78 -0
- package/src/__tests__/commands/task.test.ts +144 -0
- package/src/__tests__/db.test.ts +327 -0
- package/src/__tests__/id.test.ts +19 -0
- package/src/__tests__/index.test.ts +75 -0
- package/src/__tests__/metrics.test.ts +64 -0
- package/src/__tests__/output.test.ts +39 -0
- package/src/activity.ts +73 -0
- package/src/api.ts +209 -0
- package/src/commands/board.ts +29 -0
- package/src/commands/bulk.ts +19 -0
- package/src/commands/column.ts +60 -0
- package/src/commands/task.ts +117 -0
- package/src/config.ts +29 -0
- package/src/db.ts +587 -0
- package/src/errors.ts +32 -0
- package/src/fixtures.ts +128 -0
- package/src/id.ts +8 -0
- package/src/index.ts +413 -0
- package/src/metrics.ts +98 -0
- package/src/output.ts +105 -0
- package/src/providers/capabilities.ts +25 -0
- package/src/providers/errors.ts +16 -0
- package/src/providers/index.ts +24 -0
- package/src/providers/linear-cache.ts +385 -0
- package/src/providers/linear-client.ts +329 -0
- package/src/providers/linear.ts +305 -0
- package/src/providers/local.ts +135 -0
- package/src/providers/types.ts +65 -0
- package/src/server.ts +91 -0
- package/src/types.ts +123 -0
- package/ui/dist/assets/index-DEnUD0fq.css +1 -0
- package/ui/dist/assets/index-DMRjw1nI.js +40 -0
- package/ui/dist/index.html +13 -0
package/src/fixtures.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { Priority } from './types.ts'
|
|
3
|
+
import { addTask, moveTask, updateTask, listTasks } from './db.ts'
|
|
4
|
+
|
|
5
|
+
interface FixtureTask {
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
column: string
|
|
9
|
+
priority: Priority
|
|
10
|
+
assignee?: string
|
|
11
|
+
project?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const FIXTURE_TASKS: FixtureTask[] = [
|
|
15
|
+
// recurring
|
|
16
|
+
{
|
|
17
|
+
title: 'Daily standup notes',
|
|
18
|
+
description: 'Capture blockers and progress each morning',
|
|
19
|
+
column: 'recurring',
|
|
20
|
+
priority: 'medium',
|
|
21
|
+
assignee: 'Alex',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: 'Weekly metrics review',
|
|
25
|
+
description: 'Review board throughput and cycle time every Friday',
|
|
26
|
+
column: 'recurring',
|
|
27
|
+
priority: 'low',
|
|
28
|
+
assignee: 'BuildBot',
|
|
29
|
+
project: 'Platform',
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// backlog
|
|
33
|
+
{
|
|
34
|
+
title: 'Add search functionality',
|
|
35
|
+
description: 'Full-text search across task titles and descriptions',
|
|
36
|
+
column: 'backlog',
|
|
37
|
+
priority: 'high',
|
|
38
|
+
assignee: 'Alex',
|
|
39
|
+
project: 'Platform',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: 'Write API docs',
|
|
43
|
+
description: 'Document all CLI commands with examples',
|
|
44
|
+
column: 'backlog',
|
|
45
|
+
priority: 'medium',
|
|
46
|
+
assignee: 'BuildBot',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
title: 'Refactor error handling',
|
|
50
|
+
description: 'Consolidate error codes and improve user-facing messages',
|
|
51
|
+
column: 'backlog',
|
|
52
|
+
priority: 'low',
|
|
53
|
+
project: 'Platform',
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// in-progress
|
|
57
|
+
{
|
|
58
|
+
title: 'Implement board export',
|
|
59
|
+
description: 'Export board state to JSON and CSV formats',
|
|
60
|
+
column: 'in-progress',
|
|
61
|
+
priority: 'high',
|
|
62
|
+
assignee: 'Alex',
|
|
63
|
+
project: 'Platform',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'Fix column reorder bug',
|
|
67
|
+
description: 'Columns shift incorrectly when moving to position 0',
|
|
68
|
+
column: 'in-progress',
|
|
69
|
+
priority: 'high',
|
|
70
|
+
assignee: 'BuildBot',
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// review
|
|
74
|
+
{
|
|
75
|
+
title: 'Add bulk delete command',
|
|
76
|
+
description: 'Allow deleting multiple tasks by ID or filter',
|
|
77
|
+
column: 'review',
|
|
78
|
+
priority: 'medium',
|
|
79
|
+
assignee: 'Alex',
|
|
80
|
+
project: 'Platform',
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// done
|
|
84
|
+
{
|
|
85
|
+
title: 'Set up CI pipeline',
|
|
86
|
+
description: 'GitHub Actions for lint, typecheck, and test on every PR',
|
|
87
|
+
column: 'done',
|
|
88
|
+
priority: 'high',
|
|
89
|
+
assignee: 'BuildBot',
|
|
90
|
+
project: 'Platform',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
title: 'Add activity logging',
|
|
94
|
+
description: 'Track task creates, moves, updates, and deletes',
|
|
95
|
+
column: 'done',
|
|
96
|
+
priority: 'medium',
|
|
97
|
+
assignee: 'Alex',
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
export function seedFixtures(db: Database): { taskCount: number; movedCount: number } {
|
|
102
|
+
let movedCount = 0
|
|
103
|
+
|
|
104
|
+
for (const fixture of FIXTURE_TASKS) {
|
|
105
|
+
// Create all tasks in backlog first (addTask defaults to backlog)
|
|
106
|
+
const task = addTask(db, fixture.title, {
|
|
107
|
+
description: fixture.description,
|
|
108
|
+
priority: fixture.priority,
|
|
109
|
+
assignee: fixture.assignee,
|
|
110
|
+
project: fixture.project,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Move to target column if not already in backlog
|
|
114
|
+
if (fixture.column !== 'backlog') {
|
|
115
|
+
moveTask(db, task.id, fixture.column)
|
|
116
|
+
movedCount++
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Escalate the bug to urgent — generates a realistic "prioritized" activity entry
|
|
121
|
+
const inProgress = listTasks(db, { column: 'in-progress' })
|
|
122
|
+
const bug = inProgress.find((t) => t.title === 'Fix column reorder bug')
|
|
123
|
+
if (bug) {
|
|
124
|
+
updateTask(db, bug.id, { priority: 'urgent' })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { taskCount: FIXTURE_TASKS.length, movedCount }
|
|
128
|
+
}
|
package/src/id.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function generateId(prefix: 't' | 'c' | 'a' | 'ct'): string {
|
|
2
|
+
const bytes = new Uint8Array(5)
|
|
3
|
+
crypto.getRandomValues(bytes)
|
|
4
|
+
let num = 0n
|
|
5
|
+
for (const b of bytes) num = (num << 8n) | BigInt(b)
|
|
6
|
+
const chars = num.toString(36).slice(0, 8).padStart(8, '0')
|
|
7
|
+
return `${prefix}_${chars}`
|
|
8
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { Database } from 'bun:sqlite'
|
|
5
|
+
import { KanbanError, ErrorCode } from './errors.ts'
|
|
6
|
+
import { formatOutput, error, success } from './output.ts'
|
|
7
|
+
import { openDb, getDbPath, initSchema, migrateSchema, seedDefaultColumns } from './db.ts'
|
|
8
|
+
import { boardInit, boardReset } from './commands/board.ts'
|
|
9
|
+
import {
|
|
10
|
+
columnAdd,
|
|
11
|
+
columnDelete,
|
|
12
|
+
columnList,
|
|
13
|
+
columnRename,
|
|
14
|
+
columnReorder,
|
|
15
|
+
} from './commands/column.ts'
|
|
16
|
+
import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk.ts'
|
|
17
|
+
import { getConfigPath, loadConfig, saveConfig } from './config.ts'
|
|
18
|
+
import type { CliOutput, Priority } from './types.ts'
|
|
19
|
+
import { createProvider } from './providers/index.ts'
|
|
20
|
+
import { unsupportedOperation } from './providers/errors.ts'
|
|
21
|
+
|
|
22
|
+
interface ParsedArgs {
|
|
23
|
+
values: Record<string, unknown>
|
|
24
|
+
positionals: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCliArgs(argv: string[]): ParsedArgs {
|
|
28
|
+
return parseArgs({
|
|
29
|
+
args: argv,
|
|
30
|
+
options: {
|
|
31
|
+
pretty: { type: 'boolean', default: false },
|
|
32
|
+
db: { type: 'string' },
|
|
33
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
34
|
+
d: { type: 'string' },
|
|
35
|
+
c: { type: 'string' },
|
|
36
|
+
p: { type: 'string' },
|
|
37
|
+
a: { type: 'string' },
|
|
38
|
+
m: { type: 'string' },
|
|
39
|
+
l: { type: 'string' },
|
|
40
|
+
sort: { type: 'string' },
|
|
41
|
+
title: { type: 'string' },
|
|
42
|
+
position: { type: 'string' },
|
|
43
|
+
color: { type: 'string' },
|
|
44
|
+
project: { type: 'string' },
|
|
45
|
+
role: { type: 'string' },
|
|
46
|
+
},
|
|
47
|
+
strict: false,
|
|
48
|
+
allowPositionals: true,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireLocalProvider(providerType: string, feature: string): void {
|
|
53
|
+
if (providerType === 'linear') unsupportedOperation(`${feature} is only available in local mode`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function routeTask(
|
|
57
|
+
provider: ReturnType<typeof createProvider>,
|
|
58
|
+
action: string | undefined,
|
|
59
|
+
positionals: string[],
|
|
60
|
+
values: Record<string, unknown>,
|
|
61
|
+
): Promise<CliOutput> {
|
|
62
|
+
switch (action) {
|
|
63
|
+
case 'add': {
|
|
64
|
+
const title = positionals[2]
|
|
65
|
+
if (!title) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task title is required')
|
|
66
|
+
return success(
|
|
67
|
+
await provider.createTask({
|
|
68
|
+
title,
|
|
69
|
+
description: values.d as string | undefined,
|
|
70
|
+
column: values.c as string | undefined,
|
|
71
|
+
priority: values.p as Priority | undefined,
|
|
72
|
+
assignee: values.a as string | undefined,
|
|
73
|
+
project: values.project as string | undefined,
|
|
74
|
+
metadata: values.m as string | undefined,
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
case 'list':
|
|
79
|
+
return success(
|
|
80
|
+
await provider.listTasks({
|
|
81
|
+
column: values.c as string | undefined,
|
|
82
|
+
priority: values.p as string | undefined,
|
|
83
|
+
assignee: values.a as string | undefined,
|
|
84
|
+
project: values.project as string | undefined,
|
|
85
|
+
limit: values.l ? parseInt(values.l as string, 10) : undefined,
|
|
86
|
+
sort: values.sort as string | undefined,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
case 'view': {
|
|
90
|
+
const id = positionals[2]
|
|
91
|
+
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
92
|
+
return success(await provider.getTask(id))
|
|
93
|
+
}
|
|
94
|
+
case 'update': {
|
|
95
|
+
const id = positionals[2]
|
|
96
|
+
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
97
|
+
return success(
|
|
98
|
+
await provider.updateTask(id, {
|
|
99
|
+
title: values.title as string | undefined,
|
|
100
|
+
description: values.d as string | undefined,
|
|
101
|
+
priority: values.p as Priority | undefined,
|
|
102
|
+
assignee: values.a as string | undefined,
|
|
103
|
+
project: values.project as string | undefined,
|
|
104
|
+
metadata: values.m as string | undefined,
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
case 'delete': {
|
|
109
|
+
const id = positionals[2]
|
|
110
|
+
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
111
|
+
return success(await provider.deleteTask(id))
|
|
112
|
+
}
|
|
113
|
+
case 'move': {
|
|
114
|
+
const id = positionals[2]
|
|
115
|
+
const column = positionals[3]
|
|
116
|
+
if (!id || !column) {
|
|
117
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task move <id> <column>')
|
|
118
|
+
}
|
|
119
|
+
return success(await provider.moveTask(id, column))
|
|
120
|
+
}
|
|
121
|
+
case 'assign': {
|
|
122
|
+
const id = positionals[2]
|
|
123
|
+
const assignee = positionals[3]
|
|
124
|
+
if (!id || assignee === undefined) {
|
|
125
|
+
throw new KanbanError(
|
|
126
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
127
|
+
'Usage: kanban task assign <id> <assignee>',
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return success(await provider.updateTask(id, { assignee }))
|
|
131
|
+
}
|
|
132
|
+
case 'prioritize': {
|
|
133
|
+
const id = positionals[2]
|
|
134
|
+
const priority = positionals[3]
|
|
135
|
+
if (!id || !priority) {
|
|
136
|
+
throw new KanbanError(
|
|
137
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
138
|
+
'Usage: kanban task prioritize <id> <level>',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return success(await provider.updateTask(id, { priority: priority as Priority }))
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown task command '${action}'`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function routeColumn(
|
|
149
|
+
db: Database,
|
|
150
|
+
providerType: string,
|
|
151
|
+
action: string | undefined,
|
|
152
|
+
positionals: string[],
|
|
153
|
+
values: Record<string, unknown>,
|
|
154
|
+
): CliOutput {
|
|
155
|
+
requireLocalProvider(providerType, 'Column commands')
|
|
156
|
+
switch (action) {
|
|
157
|
+
case 'add':
|
|
158
|
+
return columnAdd(db, {
|
|
159
|
+
name: positionals[2],
|
|
160
|
+
position: values.position as string | undefined,
|
|
161
|
+
color: values.color as string | undefined,
|
|
162
|
+
})
|
|
163
|
+
case 'list':
|
|
164
|
+
return columnList(db)
|
|
165
|
+
case 'rename':
|
|
166
|
+
return columnRename(db, { idOrName: positionals[2], newName: positionals[3] })
|
|
167
|
+
case 'reorder':
|
|
168
|
+
return columnReorder(db, { idOrName: positionals[2], position: positionals[3] })
|
|
169
|
+
case 'delete':
|
|
170
|
+
return columnDelete(db, { idOrName: positionals[2] })
|
|
171
|
+
default:
|
|
172
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown column command '${action}'`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function routeBulk(
|
|
177
|
+
db: Database,
|
|
178
|
+
providerType: string,
|
|
179
|
+
action: string | undefined,
|
|
180
|
+
positionals: string[],
|
|
181
|
+
): CliOutput {
|
|
182
|
+
requireLocalProvider(providerType, 'Bulk commands')
|
|
183
|
+
switch (action) {
|
|
184
|
+
case 'move-all':
|
|
185
|
+
return bulkMoveAllCmd(db, { from: positionals[2], to: positionals[3] })
|
|
186
|
+
case 'clear-done':
|
|
187
|
+
return bulkClearDoneCmd(db)
|
|
188
|
+
default:
|
|
189
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown bulk command '${action}'`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function routeConfig(
|
|
194
|
+
provider: ReturnType<typeof createProvider>,
|
|
195
|
+
dbPath: string,
|
|
196
|
+
action: string | undefined,
|
|
197
|
+
positionals: string[],
|
|
198
|
+
values: Record<string, unknown>,
|
|
199
|
+
): Promise<CliOutput> {
|
|
200
|
+
if (provider.type === 'linear') {
|
|
201
|
+
if (action === 'show' || action === undefined) {
|
|
202
|
+
return success(await provider.getConfig())
|
|
203
|
+
}
|
|
204
|
+
unsupportedOperation('Config mutation is only available in local mode')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const configPath = getConfigPath(dbPath)
|
|
208
|
+
const config = loadConfig(dbPath)
|
|
209
|
+
|
|
210
|
+
switch (action) {
|
|
211
|
+
case 'show':
|
|
212
|
+
case undefined:
|
|
213
|
+
return success(await provider.getConfig())
|
|
214
|
+
case 'set-member': {
|
|
215
|
+
const name = positionals[2]
|
|
216
|
+
if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Member name is required')
|
|
217
|
+
const role =
|
|
218
|
+
(values.role as string | undefined) === 'agent' ? ('agent' as const) : ('human' as const)
|
|
219
|
+
const existing = config.members.findIndex((member) => member.name === name)
|
|
220
|
+
if (existing >= 0) {
|
|
221
|
+
config.members[existing] = { name, role }
|
|
222
|
+
} else {
|
|
223
|
+
config.members.push({ name, role })
|
|
224
|
+
}
|
|
225
|
+
saveConfig(configPath, config)
|
|
226
|
+
return success({ message: `Member '${name}' set as ${role}` })
|
|
227
|
+
}
|
|
228
|
+
case 'remove-member': {
|
|
229
|
+
const name = positionals[2]
|
|
230
|
+
if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Member name is required')
|
|
231
|
+
config.members = config.members.filter((member) => member.name !== name)
|
|
232
|
+
saveConfig(configPath, config)
|
|
233
|
+
return success({ message: `Member '${name}' removed` })
|
|
234
|
+
}
|
|
235
|
+
case 'add-project': {
|
|
236
|
+
const name = positionals[2]
|
|
237
|
+
if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Project name is required')
|
|
238
|
+
if (!config.projects.includes(name)) {
|
|
239
|
+
config.projects.push(name)
|
|
240
|
+
saveConfig(configPath, config)
|
|
241
|
+
}
|
|
242
|
+
return success({ message: `Project '${name}' added` })
|
|
243
|
+
}
|
|
244
|
+
case 'remove-project': {
|
|
245
|
+
const name = positionals[2]
|
|
246
|
+
if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Project name is required')
|
|
247
|
+
config.projects = config.projects.filter((project) => project !== name)
|
|
248
|
+
saveConfig(configPath, config)
|
|
249
|
+
return success({ message: `Project '${name}' removed` })
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown config command '${action}'`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function routeBoard(
|
|
257
|
+
db: Database,
|
|
258
|
+
provider: ReturnType<typeof createProvider>,
|
|
259
|
+
action: string | undefined,
|
|
260
|
+
): Promise<CliOutput> {
|
|
261
|
+
switch (action) {
|
|
262
|
+
case 'init':
|
|
263
|
+
requireLocalProvider(provider.type, 'Board initialization')
|
|
264
|
+
return boardInit(db)
|
|
265
|
+
case 'view':
|
|
266
|
+
case undefined:
|
|
267
|
+
if (provider.type === 'local') {
|
|
268
|
+
initSchema(db)
|
|
269
|
+
seedDefaultColumns(db)
|
|
270
|
+
}
|
|
271
|
+
return success(await provider.getBoard())
|
|
272
|
+
case 'reset':
|
|
273
|
+
requireLocalProvider(provider.type, 'Board reset')
|
|
274
|
+
return boardReset(db)
|
|
275
|
+
default:
|
|
276
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown board command '${action}'`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: number }> {
|
|
281
|
+
const { values, positionals } = parseCliArgs(argv)
|
|
282
|
+
if (values.help) {
|
|
283
|
+
return { output: { ok: true, data: { message: HELP_TEXT } }, exitCode: 0 }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const dbPath = (values.db as string | undefined) ?? getDbPath()
|
|
287
|
+
const db = openDb(dbPath)
|
|
288
|
+
migrateSchema(db)
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const provider = createProvider(db, dbPath)
|
|
292
|
+
const group = positionals[0]
|
|
293
|
+
const action = positionals[1]
|
|
294
|
+
|
|
295
|
+
if (!group) {
|
|
296
|
+
return { output: await routeBoard(db, provider, undefined), exitCode: 0 }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let output: CliOutput
|
|
300
|
+
switch (group) {
|
|
301
|
+
case 'board':
|
|
302
|
+
output = await routeBoard(db, provider, action)
|
|
303
|
+
break
|
|
304
|
+
case 'task':
|
|
305
|
+
output = await routeTask(provider, action, positionals, values)
|
|
306
|
+
break
|
|
307
|
+
case 'column':
|
|
308
|
+
output = routeColumn(db, provider.type, action, positionals, values)
|
|
309
|
+
break
|
|
310
|
+
case 'bulk':
|
|
311
|
+
output = routeBulk(db, provider.type, action, positionals)
|
|
312
|
+
break
|
|
313
|
+
case 'config':
|
|
314
|
+
output = await routeConfig(provider, dbPath, action, positionals, values)
|
|
315
|
+
break
|
|
316
|
+
default:
|
|
317
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown command group '${group}'`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { output, exitCode: 0 }
|
|
321
|
+
} finally {
|
|
322
|
+
db.close()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const HELP_TEXT = `kanban - Agent-friendly kanban board CLI
|
|
327
|
+
|
|
328
|
+
Usage: kanban [command] [options]
|
|
329
|
+
|
|
330
|
+
Commands:
|
|
331
|
+
board init Initialize a new board
|
|
332
|
+
board view View full board (default)
|
|
333
|
+
board reset Reset board to defaults
|
|
334
|
+
|
|
335
|
+
task add <title> Add a task [-d desc] [-c column] [-p priority] [-a assignee] [--project name] [-m json]
|
|
336
|
+
task list List tasks [-c column] [-p priority] [-a assignee] [--project name] [-l limit] [--sort field]
|
|
337
|
+
task view <id> View task details
|
|
338
|
+
task update <id> Update task [--title] [-d] [-p] [-a] [--project name] [-m]
|
|
339
|
+
task delete <id> Delete a task
|
|
340
|
+
task move <id> <column> Move task to column
|
|
341
|
+
task assign <id> <user> Assign task
|
|
342
|
+
task prioritize <id> <lvl> Set priority
|
|
343
|
+
|
|
344
|
+
column add <name> Add column [--position n] [--color hex]
|
|
345
|
+
column list List columns
|
|
346
|
+
column rename <id> <name> Rename column
|
|
347
|
+
column reorder <id> <pos> Reorder column
|
|
348
|
+
column delete <id> Delete empty column
|
|
349
|
+
|
|
350
|
+
bulk move-all <from> <to> Move all tasks between columns
|
|
351
|
+
bulk clear-done Delete all tasks in 'done'
|
|
352
|
+
|
|
353
|
+
config show Show board config
|
|
354
|
+
config set-member <name> Add/update member [--role human|agent]
|
|
355
|
+
config remove-member <name> Remove member
|
|
356
|
+
config add-project <name> Add project
|
|
357
|
+
config remove-project <name> Remove project
|
|
358
|
+
|
|
359
|
+
serve Start web dashboard [--port 3000]
|
|
360
|
+
|
|
361
|
+
Options:
|
|
362
|
+
--pretty Human-readable output (default: JSON)
|
|
363
|
+
--db <path> Database path (default: local ./.kanban if present, else ~/.kanban if present, else create ./.kanban)
|
|
364
|
+
--project <n> Filter/set project
|
|
365
|
+
-h, --help Show this help`
|
|
366
|
+
|
|
367
|
+
if (import.meta.main) {
|
|
368
|
+
const argv = process.argv.slice(2)
|
|
369
|
+
|
|
370
|
+
if (argv[0] === 'serve') {
|
|
371
|
+
const portIdx = argv.indexOf('--port')
|
|
372
|
+
const port =
|
|
373
|
+
portIdx !== -1
|
|
374
|
+
? parseInt(argv[portIdx + 1]!, 10)
|
|
375
|
+
: parseInt(process.env['PORT'] || '3000', 10)
|
|
376
|
+
|
|
377
|
+
const { values } = parseArgs({
|
|
378
|
+
args: argv,
|
|
379
|
+
options: { db: { type: 'string' }, port: { type: 'string' } },
|
|
380
|
+
strict: false,
|
|
381
|
+
allowPositionals: true,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
const dbPath = (values.db as string | undefined) ?? getDbPath()
|
|
385
|
+
const db = openDb(dbPath)
|
|
386
|
+
migrateSchema(db)
|
|
387
|
+
const provider = createProvider(db, dbPath)
|
|
388
|
+
const { startServer } = await import('./server.ts')
|
|
389
|
+
startServer(provider, port)
|
|
390
|
+
} else {
|
|
391
|
+
let exitCode = 0
|
|
392
|
+
const pretty = argv.includes('--pretty')
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const result = await run(argv)
|
|
396
|
+
exitCode = result.exitCode
|
|
397
|
+
console.info(formatOutput(result.output, pretty))
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (err instanceof KanbanError) {
|
|
400
|
+
exitCode = 1
|
|
401
|
+
console.error(formatOutput(error(err.code, err.message), pretty))
|
|
402
|
+
} else {
|
|
403
|
+
exitCode = 2
|
|
404
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
405
|
+
console.error(formatOutput(error(ErrorCode.INTERNAL_ERROR, msg), pretty))
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
process.exit(exitCode)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export { run }
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import type { ActivityEntry, BoardMetrics } from './types.ts'
|
|
3
|
+
|
|
4
|
+
function getDistinctTaskFieldValues(db: Database, field: 'assignee' | 'project'): string[] {
|
|
5
|
+
return (
|
|
6
|
+
db
|
|
7
|
+
.query(`SELECT DISTINCT ${field} as value FROM tasks WHERE ${field} != '' ORDER BY ${field}`)
|
|
8
|
+
.all() as { value: string }[]
|
|
9
|
+
).map((row) => row.value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getCount(db: Database, sql: string): number {
|
|
13
|
+
return (db.query(sql).get() as { count: number }).count
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getDiscoveredAssignees(db: Database): string[] {
|
|
17
|
+
return getDistinctTaskFieldValues(db, 'assignee')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getDiscoveredProjects(db: Database): string[] {
|
|
21
|
+
return getDistinctTaskFieldValues(db, 'project')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getBoardMetrics(db: Database): BoardMetrics {
|
|
25
|
+
const tasksByColumn = db
|
|
26
|
+
.query(
|
|
27
|
+
`SELECT c.name as column_name, COUNT(t.id) as count
|
|
28
|
+
FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
|
|
29
|
+
GROUP BY c.id ORDER BY c.position`,
|
|
30
|
+
)
|
|
31
|
+
.all() as { column_name: string; count: number }[]
|
|
32
|
+
|
|
33
|
+
const tasksByPriority = db
|
|
34
|
+
.query(
|
|
35
|
+
`SELECT priority, COUNT(*) as count FROM tasks
|
|
36
|
+
GROUP BY priority ORDER BY CASE priority
|
|
37
|
+
WHEN 'urgent' THEN 0 WHEN 'high' THEN 1
|
|
38
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`,
|
|
39
|
+
)
|
|
40
|
+
.all() as { priority: string; count: number }[]
|
|
41
|
+
|
|
42
|
+
const totalTasks = getCount(db, 'SELECT COUNT(*) as count FROM tasks')
|
|
43
|
+
|
|
44
|
+
const completedTasks = getCount(
|
|
45
|
+
db,
|
|
46
|
+
`SELECT COUNT(*) as count FROM tasks t
|
|
47
|
+
JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'done'`,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const avgResult = db
|
|
51
|
+
.query(
|
|
52
|
+
`SELECT AVG(
|
|
53
|
+
(julianday(ct.exited_at) - julianday(first_enter.entered_at)) * 24
|
|
54
|
+
) as avg_hours
|
|
55
|
+
FROM column_time_tracking ct
|
|
56
|
+
JOIN columns c ON ct.column_id = c.id
|
|
57
|
+
JOIN (
|
|
58
|
+
SELECT task_id, MIN(entered_at) as entered_at
|
|
59
|
+
FROM column_time_tracking GROUP BY task_id
|
|
60
|
+
) first_enter ON first_enter.task_id = ct.task_id
|
|
61
|
+
WHERE LOWER(c.name) = 'done' AND ct.exited_at IS NOT NULL`,
|
|
62
|
+
)
|
|
63
|
+
.get() as { avg_hours: number | null }
|
|
64
|
+
|
|
65
|
+
const recentActivity = db
|
|
66
|
+
.query('SELECT * FROM activity_log ORDER BY timestamp DESC, rowid DESC LIMIT 20')
|
|
67
|
+
.all() as ActivityEntry[]
|
|
68
|
+
|
|
69
|
+
const tasksCreatedThisWeek = getCount(
|
|
70
|
+
db,
|
|
71
|
+
"SELECT COUNT(*) as count FROM tasks WHERE created_at >= datetime('now', '-7 days')",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const inProgressCount = getCount(
|
|
75
|
+
db,
|
|
76
|
+
`SELECT COUNT(*) as count FROM tasks t
|
|
77
|
+
JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'in-progress'`,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const completionPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
81
|
+
|
|
82
|
+
const assignees = getDiscoveredAssignees(db)
|
|
83
|
+
const projects = getDiscoveredProjects(db)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
tasksByColumn,
|
|
87
|
+
tasksByPriority,
|
|
88
|
+
totalTasks,
|
|
89
|
+
completedTasks,
|
|
90
|
+
avgCompletionHours: avgResult.avg_hours,
|
|
91
|
+
recentActivity,
|
|
92
|
+
tasksCreatedThisWeek,
|
|
93
|
+
inProgressCount,
|
|
94
|
+
completionPercent,
|
|
95
|
+
assignees,
|
|
96
|
+
projects,
|
|
97
|
+
}
|
|
98
|
+
}
|