@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/package.json +80 -0
  4. package/src/__tests__/activity.test.ts +139 -0
  5. package/src/__tests__/api.test.ts +74 -0
  6. package/src/__tests__/commands/board.test.ts +51 -0
  7. package/src/__tests__/commands/bulk.test.ts +51 -0
  8. package/src/__tests__/commands/column.test.ts +78 -0
  9. package/src/__tests__/commands/task.test.ts +144 -0
  10. package/src/__tests__/db.test.ts +327 -0
  11. package/src/__tests__/id.test.ts +19 -0
  12. package/src/__tests__/index.test.ts +75 -0
  13. package/src/__tests__/metrics.test.ts +64 -0
  14. package/src/__tests__/output.test.ts +39 -0
  15. package/src/activity.ts +73 -0
  16. package/src/api.ts +209 -0
  17. package/src/commands/board.ts +29 -0
  18. package/src/commands/bulk.ts +19 -0
  19. package/src/commands/column.ts +60 -0
  20. package/src/commands/task.ts +117 -0
  21. package/src/config.ts +29 -0
  22. package/src/db.ts +587 -0
  23. package/src/errors.ts +32 -0
  24. package/src/fixtures.ts +128 -0
  25. package/src/id.ts +8 -0
  26. package/src/index.ts +413 -0
  27. package/src/metrics.ts +98 -0
  28. package/src/output.ts +105 -0
  29. package/src/providers/capabilities.ts +25 -0
  30. package/src/providers/errors.ts +16 -0
  31. package/src/providers/index.ts +24 -0
  32. package/src/providers/linear-cache.ts +385 -0
  33. package/src/providers/linear-client.ts +329 -0
  34. package/src/providers/linear.ts +305 -0
  35. package/src/providers/local.ts +135 -0
  36. package/src/providers/types.ts +65 -0
  37. package/src/server.ts +91 -0
  38. package/src/types.ts +123 -0
  39. package/ui/dist/assets/index-DEnUD0fq.css +1 -0
  40. package/ui/dist/assets/index-DMRjw1nI.js +40 -0
  41. package/ui/dist/index.html +13 -0
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { initSchema, seedDefaultColumns, addTask } from '../db.ts'
4
+ import { getBoardMetrics } from '../metrics.ts'
5
+
6
+ let db: Database
7
+
8
+ beforeEach(() => {
9
+ db = new Database(':memory:')
10
+ db.run('PRAGMA foreign_keys = ON')
11
+ initSchema(db)
12
+ seedDefaultColumns(db)
13
+ })
14
+
15
+ describe('getBoardMetrics', () => {
16
+ test('returns tasks per column', () => {
17
+ addTask(db, 'A', { column: 'recurring' })
18
+ addTask(db, 'B', { column: 'recurring' })
19
+ addTask(db, 'C', { column: 'backlog' })
20
+ const metrics = getBoardMetrics(db)
21
+ expect(metrics.tasksByColumn.find((c) => c.column_name === 'recurring')!.count).toBe(2)
22
+ expect(metrics.tasksByColumn.find((c) => c.column_name === 'backlog')!.count).toBe(1)
23
+ expect(metrics.tasksByColumn.find((c) => c.column_name === 'done')!.count).toBe(0)
24
+ })
25
+
26
+ test('returns tasks by priority', () => {
27
+ addTask(db, 'Urgent', { priority: 'urgent' })
28
+ addTask(db, 'High', { priority: 'high' })
29
+ addTask(db, 'Low', { priority: 'low' })
30
+ const metrics = getBoardMetrics(db)
31
+ expect(metrics.tasksByPriority.find((p) => p.priority === 'urgent')!.count).toBe(1)
32
+ expect(metrics.tasksByPriority.find((p) => p.priority === 'high')!.count).toBe(1)
33
+ })
34
+
35
+ test('returns total and completed counts', () => {
36
+ addTask(db, 'A', { column: 'recurring' })
37
+ addTask(db, 'B', { column: 'done' })
38
+ addTask(db, 'C', { column: 'done' })
39
+ const metrics = getBoardMetrics(db)
40
+ expect(metrics.totalTasks).toBe(3)
41
+ expect(metrics.completedTasks).toBe(2)
42
+ })
43
+
44
+ test('returns null avg completion when no data', () => {
45
+ const metrics = getBoardMetrics(db)
46
+ expect(metrics.avgCompletionHours).toBeNull()
47
+ })
48
+
49
+ test('returns recent activity', () => {
50
+ addTask(db, 'A')
51
+ addTask(db, 'B')
52
+ const metrics = getBoardMetrics(db)
53
+ expect(metrics.recentActivity.length).toBeGreaterThanOrEqual(2)
54
+ expect(metrics.recentActivity[0]!.action).toBe('created')
55
+ })
56
+
57
+ test('works with empty board', () => {
58
+ const metrics = getBoardMetrics(db)
59
+ expect(metrics.totalTasks).toBe(0)
60
+ expect(metrics.completedTasks).toBe(0)
61
+ expect(metrics.tasksByColumn).toHaveLength(5)
62
+ expect(metrics.tasksByPriority).toHaveLength(0)
63
+ })
64
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { success, error, formatOutput } from '../output.ts'
3
+
4
+ describe('success', () => {
5
+ test('wraps data in ok envelope', () => {
6
+ const result = success({ id: '123' })
7
+ expect(result).toEqual({ ok: true, data: { id: '123' } })
8
+ })
9
+ })
10
+
11
+ describe('error', () => {
12
+ test('wraps error in envelope', () => {
13
+ const result = error('NOT_FOUND', 'Task not found')
14
+ expect(result).toEqual({
15
+ ok: false,
16
+ error: { code: 'NOT_FOUND', message: 'Task not found' },
17
+ })
18
+ })
19
+ })
20
+
21
+ describe('formatOutput', () => {
22
+ test('returns compact JSON when pretty=false', () => {
23
+ const result = success({ x: 1 })
24
+ const output = formatOutput(result, false)
25
+ expect(output).toBe('{"ok":true,"data":{"x":1}}')
26
+ })
27
+
28
+ test('returns error text when pretty=true', () => {
29
+ const result = error('CODE', 'Something failed')
30
+ const output = formatOutput(result, true)
31
+ expect(output).toBe('Error [CODE]: Something failed')
32
+ })
33
+
34
+ test('formats message data in pretty mode', () => {
35
+ const result = success({ message: 'Board initialized.' })
36
+ const output = formatOutput(result, true)
37
+ expect(output).toBe('Board initialized.')
38
+ })
39
+ })
@@ -0,0 +1,73 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { generateId } from './id.ts'
3
+ import type { ActivityEntry, ActivityAction, ColumnTimeEntry } from './types.ts'
4
+
5
+ export function logActivity(
6
+ db: Database,
7
+ taskId: string,
8
+ action: ActivityAction,
9
+ opts: { field?: string; old_value?: string | null; new_value?: string | null } = {},
10
+ ): void {
11
+ db.query(
12
+ `INSERT INTO activity_log (id, task_id, action, field_changed, old_value, new_value)
13
+ VALUES ($id, $task_id, $action, $field, $old, $new)`,
14
+ ).run({
15
+ $id: generateId('a'),
16
+ $task_id: taskId,
17
+ $action: action,
18
+ $field: opts.field ?? null,
19
+ $old: opts.old_value ?? null,
20
+ $new: opts.new_value ?? null,
21
+ })
22
+ }
23
+
24
+ export function listActivity(
25
+ db: Database,
26
+ opts: { limit?: number; taskId?: string } = {},
27
+ ): ActivityEntry[] {
28
+ const conditions: string[] = []
29
+ const params: Record<string, string | number> = {}
30
+
31
+ if (opts.taskId) {
32
+ conditions.push('task_id = $task_id')
33
+ params['$task_id'] = opts.taskId
34
+ }
35
+
36
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
37
+ const limit = opts.limit ? `LIMIT ${opts.limit}` : 'LIMIT 50'
38
+
39
+ return db
40
+ .query(`SELECT * FROM activity_log ${where} ORDER BY timestamp DESC, rowid DESC ${limit}`)
41
+ .all(params as Record<string, string>) as ActivityEntry[]
42
+ }
43
+
44
+ export function getTaskActivity(db: Database, taskId: string): ActivityEntry[] {
45
+ return listActivity(db, { taskId })
46
+ }
47
+
48
+ export function enterColumn(db: Database, taskId: string, columnId: string): void {
49
+ db.query(
50
+ `INSERT INTO column_time_tracking (id, task_id, column_id)
51
+ VALUES ($id, $task_id, $col)`,
52
+ ).run({
53
+ $id: generateId('ct'),
54
+ $task_id: taskId,
55
+ $col: columnId,
56
+ })
57
+ }
58
+
59
+ export function exitColumn(db: Database, taskId: string, columnId: string): void {
60
+ db.query(
61
+ `UPDATE column_time_tracking SET exited_at = datetime('now')
62
+ WHERE task_id = $task_id AND column_id = $col AND exited_at IS NULL`,
63
+ ).run({
64
+ $task_id: taskId,
65
+ $col: columnId,
66
+ })
67
+ }
68
+
69
+ export function getColumnTimeEntries(db: Database, taskId: string): ColumnTimeEntry[] {
70
+ return db
71
+ .query(`SELECT * FROM column_time_tracking WHERE task_id = $task_id ORDER BY entered_at`)
72
+ .all({ $task_id: taskId }) as ColumnTimeEntry[]
73
+ }
package/src/api.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { KanbanError, ErrorCode } from './errors.ts'
2
+ import type { BoardConfig, CliOutput } from './types.ts'
3
+ import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types.ts'
4
+
5
+ interface MoveTaskBody {
6
+ column?: string
7
+ }
8
+
9
+ function json(data: unknown, status = 200): Response {
10
+ return Response.json(data, { status })
11
+ }
12
+
13
+ function parseOptionalInt(value: string | null): number | undefined {
14
+ return value ? parseInt(value, 10) : undefined
15
+ }
16
+
17
+ function missingArgument(field: string): Response {
18
+ return json(
19
+ { ok: false, error: { code: 'MISSING_ARGUMENT', message: `${field} is required` } },
20
+ 400,
21
+ )
22
+ }
23
+
24
+ function statusForCode(code: string): number {
25
+ if (code === ErrorCode.TASK_NOT_FOUND || code === ErrorCode.COLUMN_NOT_FOUND) return 404
26
+ if (code === ErrorCode.PROVIDER_AUTH_FAILED) return 401
27
+ if (code === ErrorCode.PROVIDER_RATE_LIMITED) return 429
28
+ if (code === ErrorCode.UNSUPPORTED_OPERATION) return 400
29
+ if (code === ErrorCode.PROVIDER_NOT_CONFIGURED) return 500
30
+ return 400
31
+ }
32
+
33
+ function toResponse(result: CliOutput): Response {
34
+ if (result.ok) return json(result)
35
+ return json(result, statusForCode(result.error.code))
36
+ }
37
+
38
+ async function wrapHandler(fn: () => Promise<CliOutput> | CliOutput): Promise<Response> {
39
+ try {
40
+ return toResponse(await fn())
41
+ } catch (err) {
42
+ if (err instanceof KanbanError) {
43
+ return json(
44
+ { ok: false, error: { code: err.code, message: err.message } },
45
+ statusForCode(err.code),
46
+ )
47
+ }
48
+ const msg = err instanceof Error ? err.message : String(err)
49
+ return json({ ok: false, error: { code: 'INTERNAL_ERROR', message: msg } }, 500)
50
+ }
51
+ }
52
+
53
+ export interface ApiResult {
54
+ response: Response
55
+ mutated: boolean
56
+ }
57
+
58
+ export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
59
+ const url = new URL(req.url)
60
+ const path = url.pathname
61
+ const method = req.method
62
+
63
+ if (path === '/api/bootstrap' && method === 'GET') {
64
+ return {
65
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.getBootstrap() })),
66
+ mutated: false,
67
+ }
68
+ }
69
+
70
+ if (path === '/api/provider' && method === 'GET') {
71
+ return {
72
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.getContext() })),
73
+ mutated: false,
74
+ }
75
+ }
76
+
77
+ if (path === '/api/board' && method === 'GET') {
78
+ return {
79
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.getBoard() })),
80
+ mutated: false,
81
+ }
82
+ }
83
+
84
+ if (path === '/api/columns' && method === 'GET') {
85
+ return {
86
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.listColumns() })),
87
+ mutated: false,
88
+ }
89
+ }
90
+
91
+ if (path === '/api/tasks' && method === 'GET') {
92
+ return {
93
+ response: await wrapHandler(async () => {
94
+ const column = url.searchParams.get('column') ?? undefined
95
+ const priority = url.searchParams.get('priority') ?? undefined
96
+ const assignee = url.searchParams.get('assignee') ?? undefined
97
+ const project = url.searchParams.get('project') ?? undefined
98
+ const sort = url.searchParams.get('sort') ?? undefined
99
+ const limit = parseOptionalInt(url.searchParams.get('limit'))
100
+ return {
101
+ ok: true,
102
+ data: await provider.listTasks({ column, priority, assignee, project, sort, limit }),
103
+ }
104
+ }),
105
+ mutated: false,
106
+ }
107
+ }
108
+
109
+ if (path === '/api/tasks' && method === 'POST') {
110
+ const body = (await req.json()) as Partial<CreateTaskInput>
111
+ if (!body.title) return { response: missingArgument('title'), mutated: false }
112
+ const response = await wrapHandler(async () => ({
113
+ ok: true,
114
+ data: await provider.createTask({
115
+ title: body.title!,
116
+ description: body.description,
117
+ column: body.column,
118
+ priority: body.priority,
119
+ assignee: body.assignee,
120
+ project: body.project,
121
+ metadata: body.metadata,
122
+ }),
123
+ }))
124
+ return { response, mutated: response.ok }
125
+ }
126
+
127
+ const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/)
128
+ if (taskMatch) {
129
+ const id = decodeURIComponent(taskMatch[1]!)
130
+
131
+ if (method === 'GET') {
132
+ return {
133
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.getTask(id) })),
134
+ mutated: false,
135
+ }
136
+ }
137
+
138
+ if (method === 'PATCH') {
139
+ const body = (await req.json()) as UpdateTaskInput
140
+ const response = await wrapHandler(async () => ({
141
+ ok: true,
142
+ data: await provider.updateTask(id, body),
143
+ }))
144
+ return { response, mutated: response.ok }
145
+ }
146
+
147
+ if (method === 'DELETE') {
148
+ const response = await wrapHandler(async () => ({
149
+ ok: true,
150
+ data: await provider.deleteTask(id),
151
+ }))
152
+ return { response, mutated: response.ok }
153
+ }
154
+ }
155
+
156
+ const moveMatch = path.match(/^\/api\/tasks\/([^/]+)\/move$/)
157
+ if (moveMatch && method === 'PATCH') {
158
+ const id = decodeURIComponent(moveMatch[1]!)
159
+ const body = (await req.json()) as MoveTaskBody
160
+ if (!body.column) return { response: missingArgument('column'), mutated: false }
161
+ const response = await wrapHandler(async () => ({
162
+ ok: true,
163
+ data: await provider.moveTask(id, body.column!),
164
+ }))
165
+ return { response, mutated: response.ok }
166
+ }
167
+
168
+ if (path === '/api/activity' && method === 'GET') {
169
+ return {
170
+ response: await wrapHandler(async () => {
171
+ const taskId = url.searchParams.get('taskId') ?? undefined
172
+ const limit = parseOptionalInt(url.searchParams.get('limit'))
173
+ return { ok: true, data: await provider.getActivity(limit, taskId) }
174
+ }),
175
+ mutated: false,
176
+ }
177
+ }
178
+
179
+ if (path === '/api/metrics' && method === 'GET') {
180
+ return {
181
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.getMetrics() })),
182
+ mutated: false,
183
+ }
184
+ }
185
+
186
+ if (path === '/api/config' && method === 'GET') {
187
+ return {
188
+ response: await wrapHandler(async () => ({ ok: true, data: await provider.getConfig() })),
189
+ mutated: false,
190
+ }
191
+ }
192
+
193
+ if (path === '/api/config' && method === 'PATCH') {
194
+ const body = (await req.json()) as Partial<BoardConfig>
195
+ const response = await wrapHandler(async () => ({
196
+ ok: true,
197
+ data: await provider.patchConfig(body),
198
+ }))
199
+ return { response, mutated: response.ok }
200
+ }
201
+
202
+ return {
203
+ response: json(
204
+ { ok: false, error: { code: 'NOT_FOUND', message: `No route: ${method} ${path}` } },
205
+ 404,
206
+ ),
207
+ mutated: false,
208
+ }
209
+ }
@@ -0,0 +1,29 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { initSchema, seedDefaultColumns, isInitialized, getBoardView, resetBoard } from '../db.ts'
3
+ import { ErrorCode, KanbanError } from '../errors.ts'
4
+ import { success } from '../output.ts'
5
+ import type { CliOutput } from '../types.ts'
6
+
7
+ export function boardInit(db: Database): CliOutput {
8
+ if (isInitialized(db)) {
9
+ throw new KanbanError(ErrorCode.BOARD_ALREADY_INITIALIZED, 'Board is already initialized')
10
+ }
11
+ initSchema(db)
12
+ seedDefaultColumns(db)
13
+ return success({ message: 'Board initialized with default columns.' })
14
+ }
15
+
16
+ export function boardView(db: Database): CliOutput {
17
+ if (!isInitialized(db)) {
18
+ throw new KanbanError(
19
+ ErrorCode.BOARD_NOT_INITIALIZED,
20
+ 'Board not initialized. Run: kanban board init',
21
+ )
22
+ }
23
+ return success(getBoardView(db))
24
+ }
25
+
26
+ export function boardReset(db: Database): CliOutput {
27
+ resetBoard(db)
28
+ return success({ message: 'Board reset. All data cleared and defaults restored.' })
29
+ }
@@ -0,0 +1,19 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { bulkMoveAll, bulkClearDone } from '../db.ts'
3
+ import { ErrorCode, KanbanError } from '../errors.ts'
4
+ import { success } from '../output.ts'
5
+ import type { CliOutput } from '../types.ts'
6
+
7
+ export function bulkMoveAllCmd(db: Database, args: { from?: string; to?: string }): CliOutput {
8
+ if (!args.from || !args.to) {
9
+ throw new KanbanError(
10
+ ErrorCode.MISSING_ARGUMENT,
11
+ 'Usage: kanban bulk move-all <from-col> <to-col>',
12
+ )
13
+ }
14
+ return success(bulkMoveAll(db, args.from, args.to))
15
+ }
16
+
17
+ export function bulkClearDoneCmd(db: Database): CliOutput {
18
+ return success(bulkClearDone(db))
19
+ }
@@ -0,0 +1,60 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { addColumn, listColumns, renameColumn, reorderColumn, deleteColumn } from '../db.ts'
3
+ import { ErrorCode, KanbanError } from '../errors.ts'
4
+ import { success } from '../output.ts'
5
+ import type { CliOutput } from '../types.ts'
6
+
7
+ export function columnAdd(
8
+ db: Database,
9
+ args: { name?: string; position?: string; color?: string },
10
+ ): CliOutput {
11
+ if (!args.name) {
12
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Column name is required')
13
+ }
14
+ const pos = args.position !== undefined ? parseInt(args.position, 10) : undefined
15
+ if (pos !== undefined && isNaN(pos)) {
16
+ throw new KanbanError(ErrorCode.INVALID_POSITION, 'Position must be a number')
17
+ }
18
+ return success(addColumn(db, args.name, { position: pos, color: args.color }))
19
+ }
20
+
21
+ export function columnList(db: Database): CliOutput {
22
+ return success(listColumns(db))
23
+ }
24
+
25
+ export function columnRename(
26
+ db: Database,
27
+ args: { idOrName?: string; newName?: string },
28
+ ): CliOutput {
29
+ if (!args.idOrName || !args.newName) {
30
+ throw new KanbanError(
31
+ ErrorCode.MISSING_ARGUMENT,
32
+ 'Usage: kanban column rename <id|name> <new-name>',
33
+ )
34
+ }
35
+ return success(renameColumn(db, args.idOrName, args.newName))
36
+ }
37
+
38
+ export function columnReorder(
39
+ db: Database,
40
+ args: { idOrName?: string; position?: string },
41
+ ): CliOutput {
42
+ if (!args.idOrName || args.position === undefined) {
43
+ throw new KanbanError(
44
+ ErrorCode.MISSING_ARGUMENT,
45
+ 'Usage: kanban column reorder <id|name> <position>',
46
+ )
47
+ }
48
+ const pos = parseInt(args.position, 10)
49
+ if (isNaN(pos)) {
50
+ throw new KanbanError(ErrorCode.INVALID_POSITION, 'Position must be a number')
51
+ }
52
+ return success(reorderColumn(db, args.idOrName, pos))
53
+ }
54
+
55
+ export function columnDelete(db: Database, args: { idOrName?: string }): CliOutput {
56
+ if (!args.idOrName) {
57
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban column delete <id|name>')
58
+ }
59
+ return success(deleteColumn(db, args.idOrName))
60
+ }
@@ -0,0 +1,117 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { addTask, getTask, listTasks, updateTask, deleteTask, moveTask } from '../db.ts'
3
+ import { ErrorCode, KanbanError } from '../errors.ts'
4
+ import { success } from '../output.ts'
5
+ import type { CliOutput, Priority } from '../types.ts'
6
+
7
+ export function taskAdd(
8
+ db: Database,
9
+ args: {
10
+ title?: string
11
+ description?: string
12
+ column?: string
13
+ priority?: string
14
+ assignee?: string
15
+ project?: string
16
+ metadata?: string
17
+ },
18
+ ): CliOutput {
19
+ if (!args.title) {
20
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task title is required')
21
+ }
22
+ return success(
23
+ addTask(db, args.title, {
24
+ description: args.description,
25
+ column: args.column,
26
+ priority: args.priority as Priority | undefined,
27
+ assignee: args.assignee,
28
+ project: args.project,
29
+ metadata: args.metadata,
30
+ }),
31
+ )
32
+ }
33
+
34
+ export function taskList(
35
+ db: Database,
36
+ opts: {
37
+ column?: string
38
+ priority?: string
39
+ assignee?: string
40
+ project?: string
41
+ limit?: string
42
+ sort?: string
43
+ },
44
+ ): CliOutput {
45
+ return success(
46
+ listTasks(db, {
47
+ column: opts.column,
48
+ priority: opts.priority,
49
+ assignee: opts.assignee,
50
+ project: opts.project,
51
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
52
+ sort: opts.sort,
53
+ }),
54
+ )
55
+ }
56
+
57
+ export function taskView(db: Database, args: { id?: string }): CliOutput {
58
+ if (!args.id) {
59
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
60
+ }
61
+ return success(getTask(db, args.id))
62
+ }
63
+
64
+ export function taskUpdate(
65
+ db: Database,
66
+ args: {
67
+ id?: string
68
+ title?: string
69
+ description?: string
70
+ priority?: string
71
+ assignee?: string
72
+ project?: string
73
+ metadata?: string
74
+ },
75
+ ): CliOutput {
76
+ if (!args.id) {
77
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
78
+ }
79
+ return success(
80
+ updateTask(db, args.id, {
81
+ title: args.title,
82
+ description: args.description,
83
+ priority: args.priority as Priority | undefined,
84
+ assignee: args.assignee,
85
+ project: args.project,
86
+ metadata: args.metadata,
87
+ }),
88
+ )
89
+ }
90
+
91
+ export function taskDelete(db: Database, args: { id?: string }): CliOutput {
92
+ if (!args.id) {
93
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
94
+ }
95
+ return success(deleteTask(db, args.id))
96
+ }
97
+
98
+ export function taskMove(db: Database, args: { id?: string; column?: string }): CliOutput {
99
+ if (!args.id || !args.column) {
100
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task move <id> <column>')
101
+ }
102
+ return success(moveTask(db, args.id, args.column))
103
+ }
104
+
105
+ export function taskAssign(db: Database, args: { id?: string; assignee?: string }): CliOutput {
106
+ if (!args.id || !args.assignee) {
107
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task assign <id> <assignee>')
108
+ }
109
+ return success(updateTask(db, args.id, { assignee: args.assignee }))
110
+ }
111
+
112
+ export function taskPrioritize(db: Database, args: { id?: string; priority?: string }): CliOutput {
113
+ if (!args.id || !args.priority) {
114
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task prioritize <id> <level>')
115
+ }
116
+ return success(updateTask(db, args.id, { priority: args.priority as Priority }))
117
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import type { BoardConfig } from './types.ts'
4
+
5
+ const DEFAULT_CONFIG: BoardConfig = { members: [], projects: [] }
6
+
7
+ export function getConfigPath(dbPath: string): string {
8
+ return join(dirname(dbPath), 'config.json')
9
+ }
10
+
11
+ export function loadConfig(dbPath: string): BoardConfig {
12
+ const configPath = getConfigPath(dbPath)
13
+ try {
14
+ const raw = readFileSync(configPath, 'utf-8')
15
+ const parsed = JSON.parse(raw) as Partial<BoardConfig>
16
+ return {
17
+ members: Array.isArray(parsed.members) ? parsed.members : [],
18
+ projects: Array.isArray(parsed.projects) ? parsed.projects : [],
19
+ }
20
+ } catch {
21
+ return { ...DEFAULT_CONFIG, members: [], projects: [] }
22
+ }
23
+ }
24
+
25
+ export function saveConfig(configPath: string, config: BoardConfig): void {
26
+ const tmp = configPath + '.tmp'
27
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n')
28
+ renameSync(tmp, configPath)
29
+ }