pylonite 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,638 @@
1
+ require "io/console"
2
+
3
+ module Pylonite
4
+ module TUI
5
+ BOARD_COLORS = {
6
+ "backlog" => "\e[37m",
7
+ "todo" => "\e[33m",
8
+ "in_progress" => "\e[36m",
9
+ "done" => "\e[32m",
10
+ "archived" => "\e[90m"
11
+ }.freeze
12
+
13
+ BOLD = "\e[1m"
14
+ RESET = "\e[0m"
15
+ REVERSE = "\e[7m"
16
+ DIM = "\e[2m"
17
+
18
+ def self.run(project_path = Dir.pwd)
19
+ db = Database.new(project_path)
20
+ state = {
21
+ db: db,
22
+ view: :board,
23
+ col_index: 0,
24
+ row_index: 0,
25
+ show_archived: false,
26
+ detail_task_id: nil,
27
+ detail_scroll: 0,
28
+ show_help: false,
29
+ moving: false,
30
+ move_task_id: nil,
31
+ log_scroll: 0
32
+ }
33
+
34
+ setup_terminal
35
+ trap("WINCH") { render(state) }
36
+
37
+ begin
38
+ render(state)
39
+ loop do
40
+ break unless handle_input(state)
41
+ render(state)
42
+ end
43
+ ensure
44
+ restore_terminal
45
+ db.close
46
+ end
47
+ end
48
+
49
+ def self.setup_terminal
50
+ print "\e[?25l" # hide cursor
51
+ print "\e[?1049h" # alternate screen buffer
52
+ $stdout.flush
53
+ end
54
+
55
+ def self.restore_terminal
56
+ print "\e[?1049l" # restore screen buffer
57
+ print "\e[?25h" # show cursor
58
+ $stdout.flush
59
+ end
60
+
61
+ def self.handle_input(state)
62
+ char = read_key
63
+ return true unless char
64
+
65
+ if state[:show_help]
66
+ state[:show_help] = false
67
+ return true
68
+ end
69
+
70
+ if state[:moving]
71
+ return handle_move_input(state, char)
72
+ end
73
+
74
+ case state[:view]
75
+ when :board
76
+ handle_board_input(state, char)
77
+ when :detail
78
+ handle_detail_input(state, char)
79
+ when :log
80
+ handle_log_input(state, char)
81
+ end
82
+ end
83
+
84
+ def self.read_key
85
+ $stdin.raw do |io|
86
+ c = io.getc
87
+ return nil unless c
88
+
89
+ if c == "\e"
90
+ return "\e" unless IO.select([io], nil, nil, 0.05)
91
+ seq = io.getc
92
+ if seq == "["
93
+ code = io.getc
94
+ case code
95
+ when "A" then return :up
96
+ when "B" then return :down
97
+ when "C" then return :right
98
+ when "D" then return :left
99
+ end
100
+ end
101
+ return "\e"
102
+ end
103
+
104
+ c
105
+ end
106
+ end
107
+
108
+ def self.handle_board_input(state, key)
109
+ boards = visible_boards(state)
110
+ tasks_by_board = load_board_tasks(state)
111
+
112
+ case key
113
+ when "q", "\e"
114
+ return false
115
+ when :up, "k"
116
+ state[:row_index] = [state[:row_index] - 1, 0].max
117
+ when :down, "j"
118
+ col_tasks = tasks_by_board[boards[state[:col_index]]] || []
119
+ max_row = [(col_tasks.length - 1), 0].max
120
+ state[:row_index] = [state[:row_index] + 1, max_row].min
121
+ when :left, "h"
122
+ state[:col_index] = [state[:col_index] - 1, 0].max
123
+ col_tasks = tasks_by_board[boards[state[:col_index]]] || []
124
+ max_row = [(col_tasks.length - 1), 0].max
125
+ state[:row_index] = [state[:row_index], max_row].min
126
+ when :right, "l"
127
+ state[:col_index] = [state[:col_index] + 1, boards.length - 1].min
128
+ col_tasks = tasks_by_board[boards[state[:col_index]]] || []
129
+ max_row = [(col_tasks.length - 1), 0].max
130
+ state[:row_index] = [state[:row_index], max_row].min
131
+ state[:row_index] = 0 if col_tasks.empty?
132
+ when "a"
133
+ state[:show_archived] = !state[:show_archived]
134
+ state[:col_index] = [state[:col_index], visible_boards(state).length - 1].min
135
+ when "\r"
136
+ col_tasks = tasks_by_board[boards[state[:col_index]]] || []
137
+ if col_tasks[state[:row_index]]
138
+ state[:detail_task_id] = col_tasks[state[:row_index]]["id"]
139
+ state[:detail_scroll] = 0
140
+ state[:view] = :detail
141
+ end
142
+ when "m"
143
+ col_tasks = tasks_by_board[boards[state[:col_index]]] || []
144
+ if col_tasks[state[:row_index]]
145
+ state[:moving] = true
146
+ state[:move_task_id] = col_tasks[state[:row_index]]["id"]
147
+ end
148
+ when "c"
149
+ col_tasks = tasks_by_board[boards[state[:col_index]]] || []
150
+ if col_tasks[state[:row_index]]
151
+ prompt_comment(state, col_tasks[state[:row_index]]["id"])
152
+ end
153
+ when "L"
154
+ state[:view] = :log
155
+ state[:log_scroll] = 0
156
+ when "?"
157
+ state[:show_help] = true
158
+ end
159
+
160
+ true
161
+ end
162
+
163
+ def self.handle_detail_input(state, key)
164
+ case key
165
+ when "q"
166
+ return false
167
+ when "b", "\e"
168
+ state[:view] = :board
169
+ when :up, "k"
170
+ state[:detail_scroll] = [state[:detail_scroll] - 1, 0].max
171
+ when :down, "j"
172
+ state[:detail_scroll] += 1
173
+ when "m"
174
+ state[:moving] = true
175
+ state[:move_task_id] = state[:detail_task_id]
176
+ when "c"
177
+ prompt_comment(state, state[:detail_task_id])
178
+ when "?"
179
+ state[:show_help] = true
180
+ end
181
+
182
+ true
183
+ end
184
+
185
+ def self.handle_log_input(state, key)
186
+ case key
187
+ when "q"
188
+ return false
189
+ when "b", "\e"
190
+ state[:view] = :board
191
+ when :up, "k"
192
+ state[:log_scroll] = [state[:log_scroll] - 1, 0].max
193
+ when :down, "j"
194
+ state[:log_scroll] += 1
195
+ when "?"
196
+ state[:show_help] = true
197
+ end
198
+
199
+ true
200
+ end
201
+
202
+ def self.handle_move_input(state, key)
203
+ case key
204
+ when "1" then do_move(state, "backlog")
205
+ when "2" then do_move(state, "todo")
206
+ when "3" then do_move(state, "in_progress")
207
+ when "4" then do_move(state, "done")
208
+ when "5" then do_move(state, "archived")
209
+ else
210
+ state[:moving] = false
211
+ state[:move_task_id] = nil
212
+ end
213
+ true
214
+ end
215
+
216
+ def self.do_move(state, board)
217
+ state[:db].move_task(state[:move_task_id], board)
218
+ state[:moving] = false
219
+ state[:move_task_id] = nil
220
+ end
221
+
222
+ def self.prompt_comment(state, task_id)
223
+ _, rows = terminal_size
224
+ # Show prompt on the last row
225
+ print "\e[#{rows};1H\e[2K" # move to last row, clear it
226
+ print "#{BOLD}Comment:#{RESET} "
227
+ # Restore normal terminal mode for text input
228
+ print "\e[?25h" # show cursor
229
+ $stdout.flush
230
+
231
+ text = nil
232
+ if $stdin.respond_to?(:cooked)
233
+ $stdin.cooked { text = $stdin.gets }
234
+ else
235
+ text = $stdin.gets
236
+ end
237
+ print "\e[?25l" # hide cursor again
238
+
239
+ if text && !(text = text.strip).empty?
240
+ state[:db].add_comment(task_id, text)
241
+ end
242
+ end
243
+
244
+ def self.visible_boards(state)
245
+ if state[:show_archived]
246
+ Database::BOARDS.dup
247
+ else
248
+ Database::BOARDS.reject { |b| b == "archived" }
249
+ end
250
+ end
251
+
252
+ def self.load_board_tasks(state)
253
+ tasks = state[:db].list_tasks(include_archived: state[:show_archived])
254
+ tasks.group_by { |t| t["board"] }
255
+ end
256
+
257
+ def self.render(state)
258
+ cols, rows = terminal_size
259
+ buf = +""
260
+ buf << "\e[2J\e[H" # clear screen, cursor home
261
+
262
+ case state[:view]
263
+ when :board
264
+ buf << render_board(state, cols, rows)
265
+ when :detail
266
+ buf << render_detail(state, cols, rows)
267
+ when :log
268
+ buf << render_log(state, cols, rows)
269
+ end
270
+
271
+ if state[:show_help]
272
+ buf << render_help_overlay(cols, rows)
273
+ elsif state[:moving]
274
+ buf << render_move_overlay(state, cols, rows)
275
+ end
276
+
277
+ print buf
278
+ $stdout.flush
279
+ end
280
+
281
+ def self.terminal_size
282
+ IO.console.winsize.reverse
283
+ rescue
284
+ [80, 24]
285
+ end
286
+
287
+ def self.render_board(state, cols, rows)
288
+ boards = visible_boards(state)
289
+ tasks_by_board = load_board_tasks(state)
290
+ buf = +""
291
+
292
+ # Title bar
293
+ title = " PYLONITE - Task Board "
294
+ pad = [(cols - title.length) / 2, 0].max
295
+ buf << "#{REVERSE}#{BOLD}#{' ' * pad}#{title}#{' ' * (cols - pad - title.length)}#{RESET}\n"
296
+
297
+ # Column layout
298
+ num_cols = boards.length
299
+ col_width = cols / num_cols
300
+ available_rows = rows - 4 # title + header + status bar + bottom padding
301
+
302
+ # Column headers
303
+ boards.each_with_index do |board, i|
304
+ color = BOARD_COLORS[board] || RESET
305
+ label = board_label(board)
306
+ task_count = (tasks_by_board[board] || []).length
307
+ header = " #{label} (#{task_count})"
308
+ header = truncate(header, col_width - 1)
309
+ if i == state[:col_index]
310
+ buf << "#{REVERSE}#{color}#{BOLD}#{header.ljust(col_width)}#{RESET}"
311
+ else
312
+ buf << "#{color}#{BOLD}#{header.ljust(col_width)}#{RESET}"
313
+ end
314
+ end
315
+ buf << "\n"
316
+
317
+ # Separator
318
+ boards.each do |_board|
319
+ buf << "#{'─' * col_width}"
320
+ end
321
+ buf << "\n"
322
+
323
+ # Task rows
324
+ (0...available_rows).each do |row_i|
325
+ boards.each_with_index do |board, col_i|
326
+ color = BOARD_COLORS[board] || RESET
327
+ col_tasks = tasks_by_board[board] || []
328
+ task = col_tasks[row_i]
329
+
330
+ if task
331
+ id_str = "##{task['id']}"
332
+ max_title_len = col_width - id_str.length - 3
333
+ title = truncate(task["title"], [max_title_len, 4].max)
334
+ cell = " #{id_str} #{title}"
335
+ cell = truncate(cell, col_width - 1)
336
+
337
+ if col_i == state[:col_index] && row_i == state[:row_index]
338
+ buf << "#{REVERSE}#{color}#{cell.ljust(col_width)}#{RESET}"
339
+ else
340
+ buf << "#{color}#{cell.ljust(col_width)}#{RESET}"
341
+ end
342
+ else
343
+ buf << "#{' ' * col_width}"
344
+ end
345
+ end
346
+ buf << "\n"
347
+ end
348
+
349
+ # Status bar
350
+ bar_text = " q:quit hjkl:navigate enter:detail m:move c:comment L:log a:archived ?:help"
351
+ archived_status = state[:show_archived] ? " [archived:on]" : ""
352
+ bar_text += archived_status
353
+ buf << "\e[#{rows};1H" # move to last row
354
+ buf << "#{REVERSE}#{DIM}#{bar_text.ljust(cols)}#{RESET}"
355
+
356
+ buf
357
+ end
358
+
359
+ def self.render_detail(state, cols, rows)
360
+ task = state[:db].get_task(state[:detail_task_id])
361
+ return render_board(state, cols, rows) unless task
362
+
363
+ lines = build_detail_lines(task, cols)
364
+ buf = +""
365
+
366
+ # Title bar
367
+ title = " Task ##{task['id']} - Detail View "
368
+ pad = [(cols - title.length) / 2, 0].max
369
+ buf << "#{REVERSE}#{BOLD}#{' ' * pad}#{title}#{' ' * (cols - pad - title.length)}#{RESET}\n"
370
+
371
+ available = rows - 3 # title bar + status bar + padding
372
+ scroll = [state[:detail_scroll], [lines.length - available, 0].max].min
373
+ state[:detail_scroll] = scroll
374
+
375
+ visible = lines[scroll, available] || []
376
+ visible.each { |line| buf << "#{line}\n" }
377
+
378
+ # Fill remaining lines
379
+ remaining = available - visible.length
380
+ remaining.times { buf << "\n" }
381
+
382
+ # Status bar
383
+ bar_text = " b:back q:quit j/k:scroll m:move c:comment ?:help"
384
+ buf << "\e[#{rows};1H"
385
+ buf << "#{REVERSE}#{DIM}#{bar_text.ljust(cols)}#{RESET}"
386
+
387
+ buf
388
+ end
389
+
390
+ def self.render_log(state, cols, rows)
391
+ entries = state[:db].activity_log
392
+ buf = +""
393
+
394
+ # Title bar
395
+ title = " PYLONITE - Activity Log "
396
+ pad = [(cols - title.length) / 2, 0].max
397
+ buf << "#{REVERSE}#{BOLD}#{' ' * pad}#{title}#{' ' * (cols - pad - title.length)}#{RESET}\n"
398
+
399
+ lines = build_log_lines(entries, cols)
400
+ available = rows - 3
401
+
402
+ scroll = [state[:log_scroll], [lines.length - available, 0].max].min
403
+ state[:log_scroll] = scroll
404
+
405
+ visible = lines[scroll, available] || []
406
+ visible.each { |line| buf << "#{line}\n" }
407
+
408
+ remaining = available - visible.length
409
+ remaining.times { buf << "\n" }
410
+
411
+ bar_text = " b:back q:quit j/k:scroll ?:help"
412
+ buf << "\e[#{rows};1H"
413
+ buf << "#{REVERSE}#{DIM}#{bar_text.ljust(cols)}#{RESET}"
414
+
415
+ buf
416
+ end
417
+
418
+ def self.build_log_lines(entries, cols)
419
+ return ["", " #{DIM}No activity yet.#{RESET}"] if entries.empty?
420
+
421
+ lines = []
422
+ entries.each do |e|
423
+ lines << " #{DIM}#{e["created_at"]}#{RESET} #{e["actor"]} #{format_log_action(e["action"])} #{BOLD}##{e["task_id"]}#{RESET} #{truncate(e["title"], cols - 40)}"
424
+ lines << " #{DIM}#{e["detail"]}#{RESET}"
425
+ lines << ""
426
+ end
427
+ lines
428
+ end
429
+
430
+ def self.format_log_action(action)
431
+ case action
432
+ when "created" then "created"
433
+ when "moved" then "moved"
434
+ when "assigned" then "assigned"
435
+ when "commented" then "commented on"
436
+ when "updated" then "updated"
437
+ when "blocker_added" then "added blocker to"
438
+ when "blocker_removed" then "removed blocker from"
439
+ when "subtask_added" then "added subtask to"
440
+ when "subtask_created" then "created subtask"
441
+ else action.tr("_", " ")
442
+ end
443
+ end
444
+
445
+ def self.render_overlay(lines, cols, rows)
446
+ box_width = lines.map { |l| l.gsub(/\e\[[0-9;]*m/, "").length }.max + 4
447
+ box_width = [box_width, cols - 4].min
448
+ box_height = lines.length + 2
449
+ start_col = [(cols - box_width) / 2, 1].max
450
+ start_row = [(rows - box_height) / 2, 1].max
451
+
452
+ buf = +""
453
+ buf << "\e[#{start_row};#{start_col}H"
454
+ buf << "#{REVERSE}#{' ' * box_width}#{RESET}"
455
+ lines.each_with_index do |line, i|
456
+ buf << "\e[#{start_row + 1 + i};#{start_col}H"
457
+ stripped = line.gsub(/\e\[[0-9;]*m/, "")
458
+ padding = box_width - stripped.length - 4
459
+ buf << "#{REVERSE} #{RESET} #{line}#{' ' * [padding, 0].max} #{REVERSE} #{RESET}"
460
+ end
461
+ buf << "\e[#{start_row + box_height - 1};#{start_col}H"
462
+ buf << "#{REVERSE}#{' ' * box_width}#{RESET}"
463
+ buf
464
+ end
465
+
466
+ def self.render_help_overlay(cols, rows)
467
+ lines = [
468
+ "#{BOLD}Keyboard Shortcuts#{RESET}",
469
+ "",
470
+ "#{BOLD}Board View#{RESET}",
471
+ " h/l, left/right Switch columns",
472
+ " j/k, up/down Move between tasks",
473
+ " Enter View task detail",
474
+ " m Move selected task to another board",
475
+ " c Add comment to selected task",
476
+ " L Activity log",
477
+ " a Toggle archived column",
478
+ " q, Esc Quit",
479
+ "",
480
+ "#{BOLD}Detail View#{RESET}",
481
+ " j/k, up/down Scroll",
482
+ " m Move task to another board",
483
+ " c Add comment",
484
+ " b, Esc Back to board view",
485
+ " q Quit",
486
+ "",
487
+ "#{BOLD}Log View#{RESET}",
488
+ " j/k, up/down Scroll",
489
+ " b, Esc Back to board view",
490
+ " q Quit",
491
+ "",
492
+ "#{BOLD}Move Overlay#{RESET}",
493
+ " 1 Backlog",
494
+ " 2 Todo",
495
+ " 3 In Progress",
496
+ " 4 Done",
497
+ " 5 Archived",
498
+ " Any other key Cancel",
499
+ "",
500
+ "#{DIM}Press any key to close#{RESET}"
501
+ ]
502
+ render_overlay(lines, cols, rows)
503
+ end
504
+
505
+ def self.render_move_overlay(state, cols, rows)
506
+ task = state[:db].get_task(state[:move_task_id])
507
+ title = task ? truncate(task["title"], 30) : "?"
508
+ current = task ? task["board"] : "?"
509
+ lines = [
510
+ "#{BOLD}Move task ##{state[:move_task_id]}#{RESET} #{DIM}#{title}#{RESET}",
511
+ "#{DIM}Currently: #{board_label(current)}#{RESET}",
512
+ "",
513
+ " #{BOARD_COLORS["backlog"]}1#{RESET} Backlog",
514
+ " #{BOARD_COLORS["todo"]}2#{RESET} Todo",
515
+ " #{BOARD_COLORS["in_progress"]}3#{RESET} In Progress",
516
+ " #{BOARD_COLORS["done"]}4#{RESET} Done",
517
+ " #{BOARD_COLORS["archived"]}5#{RESET} Archived",
518
+ "",
519
+ "#{DIM}Press 1-5 to move, any other key to cancel#{RESET}"
520
+ ]
521
+ render_overlay(lines, cols, rows)
522
+ end
523
+
524
+ def self.build_detail_lines(task, cols)
525
+ lines = []
526
+ color = BOARD_COLORS[task["board"]] || RESET
527
+
528
+ lines << ""
529
+ lines << " #{BOLD}#{task['title']}#{RESET}"
530
+ lines << ""
531
+ lines << " #{DIM}Board:#{RESET} #{color}#{board_label(task['board'])}#{RESET}"
532
+ lines << " #{DIM}Author:#{RESET} #{task['author']}"
533
+ lines << " #{DIM}Assignee:#{RESET} #{task['assignee'] || '(none)'}"
534
+ lines << " #{DIM}Created:#{RESET} #{task['created_at']}"
535
+ lines << " #{DIM}Updated:#{RESET} #{task['updated_at']}"
536
+
537
+ if task["description"] && !task["description"].empty?
538
+ lines << ""
539
+ lines << " #{BOLD}Description#{RESET}"
540
+ wrap_text(task["description"], cols - 4).each { |l| lines << " #{l}" }
541
+ end
542
+
543
+ parent = task["parent"]
544
+ if parent
545
+ lines << ""
546
+ lines << " #{BOLD}Parent#{RESET}"
547
+ lines << " ##{parent['id']} #{parent['title']}"
548
+ end
549
+
550
+ subtasks = task["subtasks"] || []
551
+ unless subtasks.empty?
552
+ lines << ""
553
+ lines << " #{BOLD}Subtasks#{RESET}"
554
+ subtasks.each do |st|
555
+ st_color = BOARD_COLORS[st["board"]] || RESET
556
+ lines << " ##{st['id']} #{st_color}[#{board_label(st['board'])}]#{RESET} #{st['title']}"
557
+ end
558
+ end
559
+
560
+ blockers = task["blockers"] || []
561
+ unless blockers.empty?
562
+ lines << ""
563
+ lines << " #{BOLD}Blocked by#{RESET}"
564
+ blockers.each do |b|
565
+ b_color = BOARD_COLORS[b["board"]] || RESET
566
+ lines << " ##{b['id']} #{b_color}[#{board_label(b['board'])}]#{RESET} #{b['title']}"
567
+ end
568
+ end
569
+
570
+ blocked_by_this = task["blocked_by_this"] || []
571
+ unless blocked_by_this.empty?
572
+ lines << ""
573
+ lines << " #{BOLD}Blocking#{RESET}"
574
+ blocked_by_this.each do |b|
575
+ b_color = BOARD_COLORS[b["board"]] || RESET
576
+ lines << " ##{b['id']} #{b_color}[#{board_label(b['board'])}]#{RESET} #{b['title']}"
577
+ end
578
+ end
579
+
580
+ comments = task["comments"] || []
581
+ unless comments.empty?
582
+ lines << ""
583
+ lines << " #{BOLD}Comments#{RESET}"
584
+ comments.each do |c|
585
+ lines << ""
586
+ lines << " #{DIM}#{c['author']} at #{c['created_at']}#{RESET}"
587
+ wrap_text(c["text"], cols - 6).each { |l| lines << " #{l}" }
588
+ end
589
+ end
590
+
591
+ history = task["history"] || []
592
+ unless history.empty?
593
+ lines << ""
594
+ lines << " #{BOLD}History#{RESET}"
595
+ history.each do |h|
596
+ lines << " #{DIM}#{h['created_at']}#{RESET} #{h['actor']} - #{h['detail']}"
597
+ end
598
+ end
599
+
600
+ lines
601
+ end
602
+
603
+ def self.board_label(board)
604
+ board.gsub("_", " ").split.map(&:capitalize).join(" ")
605
+ end
606
+
607
+ def self.truncate(str, max)
608
+ return str if str.length <= max
609
+ return str[0, max] if max <= 3
610
+ str[0, max - 3] + "..."
611
+ end
612
+
613
+ def self.wrap_text(text, width)
614
+ return [""] if text.nil? || text.empty?
615
+ text.split("\n").flat_map do |paragraph|
616
+ if paragraph.empty?
617
+ [""]
618
+ else
619
+ words = paragraph.split
620
+ lines = []
621
+ current = +""
622
+ words.each do |word|
623
+ if current.empty?
624
+ current = word
625
+ elsif current.length + 1 + word.length <= width
626
+ current << " " << word
627
+ else
628
+ lines << current
629
+ current = +word
630
+ end
631
+ end
632
+ lines << current unless current.empty?
633
+ lines.empty? ? [""] : lines
634
+ end
635
+ end
636
+ end
637
+ end
638
+ end
@@ -0,0 +1,3 @@
1
+ module Pylonite
2
+ VERSION = "1.0.0"
3
+ end
data/lib/pylonite.rb ADDED
@@ -0,0 +1,8 @@
1
+ require_relative "pylonite/version"
2
+ require_relative "pylonite/database"
3
+ require_relative "pylonite/cli"
4
+ require_relative "pylonite/tui"
5
+ require_relative "pylonite/help"
6
+
7
+ module Pylonite
8
+ end
data/pylonite.gemspec ADDED
@@ -0,0 +1,20 @@
1
+ require_relative "lib/pylonite/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "pylonite"
5
+ spec.version = Pylonite::VERSION
6
+ spec.authors = ["Chris Maximin"]
7
+ spec.summary = "SQLite-backed kanban board for agents and humans"
8
+ spec.description = "A simple, local kanban board backed by SQLite. Designed for AI agents and humans to manage tasks, track progress, and collaborate via CLI and TUI."
9
+ spec.homepage = "https://github.com/chrismaximin/pylonite"
10
+ spec.license = "MIT"
11
+ spec.required_ruby_version = ">= 3.1"
12
+
13
+ spec.files = Dir["lib/**/*.rb", "bin/*", "*.gemspec"]
14
+ spec.bindir = "bin"
15
+ spec.executables = ["pylonite"]
16
+
17
+ spec.add_dependency "sqlite3", "~> 2.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ end