superthread 0.7.2

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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +492 -0
  5. data/exe/suth +19 -0
  6. data/lib/superthread/cli/accounts.rb +240 -0
  7. data/lib/superthread/cli/activity.rb +210 -0
  8. data/lib/superthread/cli/base.rb +355 -0
  9. data/lib/superthread/cli/boards.rb +131 -0
  10. data/lib/superthread/cli/cards.rb +530 -0
  11. data/lib/superthread/cli/checklists.rb +223 -0
  12. data/lib/superthread/cli/comments.rb +86 -0
  13. data/lib/superthread/cli/completion.rb +306 -0
  14. data/lib/superthread/cli/concerns/board_resolvable.rb +70 -0
  15. data/lib/superthread/cli/concerns/confirmable.rb +55 -0
  16. data/lib/superthread/cli/concerns/date_parsable.rb +196 -0
  17. data/lib/superthread/cli/concerns/list_resolvable.rb +53 -0
  18. data/lib/superthread/cli/concerns/space_resolvable.rb +52 -0
  19. data/lib/superthread/cli/concerns/sprint_resolvable.rb +55 -0
  20. data/lib/superthread/cli/concerns/tag_resolvable.rb +49 -0
  21. data/lib/superthread/cli/concerns/user_resolvable.rb +52 -0
  22. data/lib/superthread/cli/concerns/workspace_resolvable.rb +83 -0
  23. data/lib/superthread/cli/config.rb +129 -0
  24. data/lib/superthread/cli/formatter.rb +388 -0
  25. data/lib/superthread/cli/lists.rb +85 -0
  26. data/lib/superthread/cli/main.rb +121 -0
  27. data/lib/superthread/cli/members.rb +19 -0
  28. data/lib/superthread/cli/notes.rb +64 -0
  29. data/lib/superthread/cli/pages.rb +128 -0
  30. data/lib/superthread/cli/projects.rb +124 -0
  31. data/lib/superthread/cli/replies.rb +94 -0
  32. data/lib/superthread/cli/search.rb +34 -0
  33. data/lib/superthread/cli/setup.rb +253 -0
  34. data/lib/superthread/cli/spaces.rb +141 -0
  35. data/lib/superthread/cli/sprints.rb +32 -0
  36. data/lib/superthread/cli/tags.rb +86 -0
  37. data/lib/superthread/cli/ui/gum_prompt.rb +58 -0
  38. data/lib/superthread/cli/ui/plain_prompt.rb +73 -0
  39. data/lib/superthread/cli/ui.rb +263 -0
  40. data/lib/superthread/cli/workspaces.rb +105 -0
  41. data/lib/superthread/cli.rb +12 -0
  42. data/lib/superthread/client.rb +207 -0
  43. data/lib/superthread/configuration.rb +354 -0
  44. data/lib/superthread/connection.rb +57 -0
  45. data/lib/superthread/error.rb +164 -0
  46. data/lib/superthread/mention_formatter.rb +96 -0
  47. data/lib/superthread/model.rb +178 -0
  48. data/lib/superthread/models/board.rb +59 -0
  49. data/lib/superthread/models/card.rb +321 -0
  50. data/lib/superthread/models/checklist.rb +91 -0
  51. data/lib/superthread/models/checklist_item.rb +69 -0
  52. data/lib/superthread/models/comment.rb +71 -0
  53. data/lib/superthread/models/concerns/archivable.rb +32 -0
  54. data/lib/superthread/models/concerns/presentable.rb +113 -0
  55. data/lib/superthread/models/concerns/timestampable.rb +91 -0
  56. data/lib/superthread/models/list.rb +67 -0
  57. data/lib/superthread/models/member.rb +40 -0
  58. data/lib/superthread/models/note.rb +56 -0
  59. data/lib/superthread/models/page.rb +70 -0
  60. data/lib/superthread/models/project.rb +83 -0
  61. data/lib/superthread/models/space.rb +71 -0
  62. data/lib/superthread/models/sprint.rb +53 -0
  63. data/lib/superthread/models/tag.rb +52 -0
  64. data/lib/superthread/models/team.rb +68 -0
  65. data/lib/superthread/models/user.rb +76 -0
  66. data/lib/superthread/models.rb +12 -0
  67. data/lib/superthread/object.rb +285 -0
  68. data/lib/superthread/objects/collection.rb +179 -0
  69. data/lib/superthread/resources/base.rb +204 -0
  70. data/lib/superthread/resources/boards.rb +150 -0
  71. data/lib/superthread/resources/cards.rb +363 -0
  72. data/lib/superthread/resources/comments.rb +163 -0
  73. data/lib/superthread/resources/notes.rb +61 -0
  74. data/lib/superthread/resources/pages.rb +110 -0
  75. data/lib/superthread/resources/projects.rb +117 -0
  76. data/lib/superthread/resources/search.rb +46 -0
  77. data/lib/superthread/resources/spaces.rb +104 -0
  78. data/lib/superthread/resources/sprints.rb +37 -0
  79. data/lib/superthread/resources/tags.rb +52 -0
  80. data/lib/superthread/resources/users.rb +29 -0
  81. data/lib/superthread/version.rb +6 -0
  82. data/lib/superthread/version_checker.rb +174 -0
  83. data/lib/superthread.rb +30 -0
  84. metadata +259 -0
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Superthread
6
+ module Cli
7
+ # CLI commands for managing Superthread configuration files.
8
+ # Handles config file creation, viewing settings, and updating values.
9
+ class Config < Base
10
+ desc "init", "Create config file at ~/.config/superthread/config.yaml"
11
+ # Creates the Superthread configuration file with default settings.
12
+ #
13
+ # @return [void]
14
+ def init
15
+ config_path = Superthread::Configuration.new.config_path
16
+ config_dir = File.dirname(config_path)
17
+
18
+ if File.exist?(config_path)
19
+ say_warning "Config file already exists at #{config_path}"
20
+ return
21
+ end
22
+
23
+ FileUtils.mkdir_p(config_dir)
24
+
25
+ File.write(config_path, <<~YAML)
26
+ # Superthread CLI Configuration
27
+ # See: https://github.com/steveclarke/superthread
28
+
29
+ # Accounts are configured via 'suth setup' or 'suth account add'
30
+ # accounts:
31
+ # personal:
32
+ # api_key: stp_xxxxxxxxxxxx
33
+ # work:
34
+ # api_key: stp_yyyyyyyyyyyy
35
+
36
+ # Output format: json or table
37
+ format: table
38
+ YAML
39
+
40
+ output_success "Created config file at #{config_path}"
41
+ say_info "Run 'suth setup' to configure your account"
42
+ end
43
+
44
+ desc "path", "Show config file path"
45
+ # Displays the path to the configuration file.
46
+ #
47
+ # @return [void]
48
+ def path
49
+ puts Superthread::Configuration.new.config_path
50
+ end
51
+
52
+ desc "show", "Show current configuration"
53
+ # Displays the current configuration settings and account information.
54
+ #
55
+ # @return [void]
56
+ def show
57
+ cfg = Superthread::Configuration.new
58
+
59
+ puts "Config file: #{cfg.config_path}"
60
+ puts " exists: #{File.exist?(cfg.config_path)}"
61
+ puts ""
62
+ puts "State file: #{cfg.state_path}"
63
+ puts " exists: #{File.exist?(cfg.state_path)}"
64
+ puts ""
65
+ puts "Current settings:"
66
+ puts " current_account: #{cfg.current_account || "(not set)"}"
67
+ puts " api_key: #{cfg.api_key ? "#{cfg.api_key[0..6]}..." : "(not set)"}"
68
+ puts " workspace: #{cfg.workspace || "(not set)"}"
69
+ puts " format: #{cfg.format}"
70
+ puts ""
71
+
72
+ if cfg.accounts.any?
73
+ puts "Accounts:"
74
+ cfg.accounts.each do |name, _data|
75
+ state = cfg.account_state(name.to_s)
76
+ workspace = state&.dig(:workspace_name) || state&.dig(:workspace_id) || "(no workspace)"
77
+ marker = (name.to_s == cfg.current_account) ? "*" : " "
78
+ puts " #{marker} #{name}: #{workspace}"
79
+ end
80
+ else
81
+ puts "Accounts: (none configured)"
82
+ puts " Run 'suth setup' to add an account"
83
+ end
84
+ end
85
+
86
+ desc "set KEY VALUE", "Set a configuration value"
87
+ long_desc <<~DESC
88
+ Set a configuration value in the config file.
89
+
90
+ Supported keys:
91
+ format - Output format: json or table
92
+ base_url - API base URL (advanced)
93
+
94
+ Note: API keys are managed per-account. Use 'suth account add' to configure accounts.
95
+
96
+ Examples:
97
+ suth config set format json
98
+ suth config set format table
99
+ DESC
100
+ # Sets a configuration value in the config file.
101
+ #
102
+ # @param key [String] configuration key to set (format or base_url)
103
+ # @param value [String] value to assign to the configuration key
104
+ # @return [void]
105
+ def set(key, value)
106
+ cfg = Superthread::Configuration.new
107
+
108
+ unless %w[format base_url].include?(key)
109
+ raise Thor::Error, "Unknown config key: #{key}. Valid keys: format, base_url"
110
+ end
111
+
112
+ if key == "format" && !%w[json table].include?(value)
113
+ raise Thor::Error, "Invalid format: #{value}. Valid values: json, table"
114
+ end
115
+
116
+ case key
117
+ when "format"
118
+ cfg.format = value
119
+ when "base_url"
120
+ cfg.base_url = value
121
+ end
122
+
123
+ cfg.save_config_file
124
+
125
+ output_success "Set #{key} = #{value}"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "unicode/display_width"
5
+
6
+ module Superthread
7
+ module Cli
8
+ # Formatter for CLI output in gh-style format.
9
+ # Provides colored tables, key-value displays, and JSON output.
10
+ #
11
+ # @example
12
+ # formatter = Formatter.new(color: true)
13
+ # formatter.table(cards, columns: [:id, :title, :status])
14
+ # formatter.detail(card, fields: [:id, :title, :status, :priority])
15
+ module Formatter
16
+ # ANSI color codes
17
+ COLORS = {
18
+ reset: "\e[0m",
19
+ bold: "\e[1m",
20
+ dim: "\e[2m",
21
+ red: "\e[31m",
22
+ green: "\e[32m",
23
+ yellow: "\e[33m",
24
+ blue: "\e[34m",
25
+ magenta: "\e[35m",
26
+ cyan: "\e[36m",
27
+ white: "\e[37m",
28
+ gray: "\e[90m"
29
+ }.freeze
30
+
31
+ # Status colors for different states
32
+ STATUS_COLORS = {
33
+ "started" => :yellow,
34
+ "in_progress" => :yellow,
35
+ "done" => :green,
36
+ "completed" => :green,
37
+ "closed" => :green,
38
+ "blocked" => :red,
39
+ "active" => :green,
40
+ "planned" => :cyan,
41
+ "archived" => :gray
42
+ }.freeze
43
+
44
+ # Priority colors and labels (4=urgent, 3=high, 2=medium, 1=low)
45
+ PRIORITY_COLORS = {
46
+ 4 => :red, # urgent
47
+ 3 => :yellow, # high
48
+ 2 => :blue, # medium
49
+ 1 => :gray # low
50
+ }.freeze
51
+
52
+ # Human-readable labels for priority levels.
53
+ # @return [Hash{Integer => String}]
54
+ PRIORITY_LABELS = {
55
+ 4 => "urgent",
56
+ 3 => "high",
57
+ 2 => "medium",
58
+ 1 => "low"
59
+ }.freeze
60
+
61
+ # Threshold for detecting millisecond timestamps vs second timestamps.
62
+ # Timestamps above this value are assumed to be in milliseconds.
63
+ MAX_SECONDS_TIMESTAMP = 9_999_999_999
64
+
65
+ module_function
66
+
67
+ # Strips HTML tags from content and normalizes whitespace.
68
+ #
69
+ # @param content [String, nil] HTML content to strip
70
+ # @return [String] plain text with tags removed and whitespace normalized
71
+ def strip_html(content)
72
+ content.to_s.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
73
+ end
74
+
75
+ # Normalizes a timestamp to seconds (API sometimes returns milliseconds).
76
+ #
77
+ # @param ts [Integer, nil] timestamp in seconds or milliseconds
78
+ # @return [Integer, nil] timestamp in seconds, or nil if nil/zero
79
+ def normalize_timestamp(ts)
80
+ return nil if ts.nil? || ts == 0
81
+
82
+ (ts > MAX_SECONDS_TIMESTAMP) ? ts / 1000 : ts
83
+ end
84
+
85
+ # Truncates a string to a maximum length with an ellipsis indicator.
86
+ #
87
+ # @param str [String] the source string to truncate
88
+ # @param max_length [Integer] the maximum character length for the result
89
+ # @param omission [String] the suffix to append when truncation occurs
90
+ # @return [String] the truncated string, or original if within limit
91
+ def truncate(str, max_length, omission: "...")
92
+ str = str.to_s
93
+ return str if str.length <= max_length
94
+
95
+ "#{str[0, max_length - omission.length]}#{omission}"
96
+ end
97
+
98
+ # Applies ANSI color codes to text for terminal display.
99
+ #
100
+ # @param text [String] the plain text to wrap with color codes
101
+ # @param color [Symbol] the color name from COLORS constant (e.g., :red, :green)
102
+ # @param enabled [Boolean] whether to apply color or return plain text
103
+ # @return [String] text wrapped with ANSI codes if enabled, otherwise plain text
104
+ def colorize(text, color, enabled: true)
105
+ return text.to_s unless enabled && COLORS.key?(color)
106
+
107
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
108
+ end
109
+
110
+ # Formats a workflow status with semantic color coding.
111
+ #
112
+ # @param status [String] the status value (e.g., "started", "done", "blocked")
113
+ # @param color_enabled [Boolean] whether to apply ANSI color codes
114
+ # @return [String] the status with appropriate color for its state
115
+ def format_status(status, color_enabled: true)
116
+ return "-" if status.nil?
117
+
118
+ color = STATUS_COLORS.fetch(status.to_s.downcase, :white)
119
+ colorize(status, color, enabled: color_enabled)
120
+ end
121
+
122
+ # Formats a priority level with human-readable label and color.
123
+ #
124
+ # @param priority [Integer] the priority value (1=low, 2=medium, 3=high, 4=urgent)
125
+ # @param color_enabled [Boolean] whether to apply ANSI color codes
126
+ # @return [String] the priority label with appropriate urgency color
127
+ def format_priority(priority, color_enabled: true)
128
+ return "-" if priority.nil?
129
+
130
+ label = PRIORITY_LABELS.fetch(priority, priority.to_s)
131
+ color = PRIORITY_COLORS.fetch(priority, :white)
132
+ colorize(label, color, enabled: color_enabled)
133
+ end
134
+
135
+ # Formats a timestamp as a relative time or absolute date string.
136
+ #
137
+ # @param timestamp [Integer, Time] Unix timestamp in seconds or a Time object
138
+ # @param relative [Boolean] whether to use relative format (e.g., "2d ago") or absolute
139
+ # @return [String] the formatted time string, or "-" if nil/zero
140
+ def format_time(timestamp, relative: true)
141
+ return "-" if timestamp.nil? || timestamp == 0
142
+
143
+ time = timestamp.is_a?(Time) ? timestamp : Time.at(timestamp)
144
+
145
+ if relative
146
+ format_relative_time(time)
147
+ else
148
+ time.strftime("%Y-%m-%d %H:%M")
149
+ end
150
+ end
151
+
152
+ # Formats a Time object as a human-readable relative time string.
153
+ #
154
+ # @param time [Time] the timestamp to format relative to now
155
+ # @return [String] relative time like "just now", "5m ago", "3d ago", or a date
156
+ def format_relative_time(time)
157
+ diff = Time.now - time
158
+
159
+ case diff.abs
160
+ when 0..59 then "just now"
161
+ when 60..3599 then "#{(diff / 60).to_i}m ago"
162
+ when 3600..86_399 then "#{(diff / 3600).to_i}h ago"
163
+ when 86_400..604_799 then "#{(diff / 86_400).to_i}d ago"
164
+ else
165
+ time.strftime("%Y-%m-%d")
166
+ end
167
+ end
168
+
169
+ # Formats a boolean value as a colored yes/no string.
170
+ #
171
+ # @param value [Boolean] the boolean to format
172
+ # @param color_enabled [Boolean] whether to apply green/gray ANSI color
173
+ # @return [String] "yes" (green) for true, "no" (gray) for false
174
+ def format_boolean(value, color_enabled: true)
175
+ if value
176
+ colorize("yes", :green, enabled: color_enabled)
177
+ else
178
+ colorize("no", :gray, enabled: color_enabled)
179
+ end
180
+ end
181
+
182
+ # Formats data as an aligned text table with headers.
183
+ #
184
+ # @param data [Array<Hash>, Collection] the items to display as rows
185
+ # @param columns [Array<Symbol>] the field names to include as columns
186
+ # @param headers [Hash{Symbol => String}] custom header labels keyed by column name
187
+ # @param color_enabled [Boolean] whether to apply ANSI color to values
188
+ # @return [String] the formatted table with header row and data rows
189
+ def table(data, columns:, headers: {}, color_enabled: true)
190
+ items = data.respond_to?(:items) ? data.items : Array(data)
191
+ return "" if items.empty?
192
+
193
+ # Calculate column widths
194
+ widths = columns.map do |col|
195
+ header = headers.fetch(col, col.to_s.upcase)
196
+ values = items.map { |item| format_cell(item, col).to_s }
197
+ [display_width(header), values.map { |v| display_width(v) }.max || 0].max
198
+ end
199
+
200
+ lines = []
201
+
202
+ # Header row
203
+ header_row = columns.zip(widths).map do |col, width|
204
+ colorize(pad_right(headers.fetch(col, col.to_s.upcase), width), :bold, enabled: color_enabled)
205
+ end.join(" ")
206
+ lines << header_row
207
+
208
+ # Data rows
209
+ items.each do |item|
210
+ row = columns.zip(widths).map do |col, width|
211
+ cell = format_cell(item, col, color_enabled: color_enabled)
212
+ # Pad without color codes
213
+ padding = width - display_width(strip_ansi(cell))
214
+ padding = 0 if padding.negative?
215
+ "#{cell}#{" " * padding}"
216
+ end.join(" ")
217
+ lines << row
218
+ end
219
+
220
+ lines.join("\n")
221
+ end
222
+
223
+ # Formats a single item as aligned key-value pairs for detail view.
224
+ #
225
+ # @param item [Hash, Object] the item to display (hash or object with to_h)
226
+ # @param fields [Array<Symbol>] the field names to include
227
+ # @param labels [Hash{Symbol => String}] custom labels keyed by field name
228
+ # @param color_enabled [Boolean] whether to apply ANSI color to values
229
+ # @return [String] the formatted detail view with aligned labels and values
230
+ def detail(item, fields:, labels: {}, color_enabled: true)
231
+ data = item.respond_to?(:to_h) ? item.to_h : item
232
+
233
+ max_label_width = fields.map { |f| display_width(labels.fetch(f, humanize(f))) }.max
234
+
235
+ lines = fields.map do |field|
236
+ label = labels.fetch(field, humanize(field))
237
+ value = format_field(data, field, color_enabled: color_enabled)
238
+ "#{colorize(pad_right(label, max_label_width), :cyan, enabled: color_enabled)} #{value}"
239
+ end
240
+
241
+ lines.join("\n")
242
+ end
243
+
244
+ # Formats data as pretty-printed JSON for output.
245
+ #
246
+ # @param data [Object] the data to serialize (supports arrays, hashes, and objects with to_h)
247
+ # @return [String] the indented JSON string
248
+ def json(data)
249
+ obj = if data.is_a?(Array)
250
+ # Plain arrays of model objects need to be mapped to hashes
251
+ data.map { |item| item.respond_to?(:to_h) ? item.to_h : item }
252
+ elsif data.respond_to?(:to_h)
253
+ data.to_h
254
+ else
255
+ data
256
+ end
257
+ JSON.pretty_generate(obj)
258
+ end
259
+
260
+ # Strips ANSI escape codes from a string for width calculation.
261
+ #
262
+ # @param str [String] the string potentially containing ANSI escape sequences
263
+ # @return [String] the plain text without any ANSI codes
264
+ def strip_ansi(str)
265
+ str.to_s.gsub(/\e\[[0-9;]*m/, "")
266
+ end
267
+
268
+ # Returns the display width of a string, accounting for Unicode
269
+ # wide characters and emoji that occupy more than one column.
270
+ #
271
+ # @param str [String] the string to measure
272
+ # @return [Integer] the number of terminal columns the string occupies
273
+ def display_width(str)
274
+ Unicode::DisplayWidth.of(str.to_s)
275
+ end
276
+
277
+ # Right-pads a string to a target display width, accounting for
278
+ # Unicode wide characters.
279
+ #
280
+ # @param str [String] the string to pad
281
+ # @param target_width [Integer] the desired display width
282
+ # @return [String] the padded string
283
+ def pad_right(str, target_width)
284
+ padding = target_width - display_width(str)
285
+ padding = 0 if padding.negative?
286
+ "#{str}#{" " * padding}"
287
+ end
288
+
289
+ # Converts a symbol or string to a human-readable label.
290
+ #
291
+ # @param key [Symbol, String] the field name to humanize (e.g., :time_created)
292
+ # @return [String] the humanized label with title case and proper acronyms
293
+ def humanize(key)
294
+ str = key.to_s
295
+ # Handle _id suffix specially since titleize treats "id" as an acronym and drops it
296
+ if str.end_with?("_id")
297
+ str.sub(/_id$/, "").titleize + " ID"
298
+ else
299
+ str.titleize
300
+ end
301
+ end
302
+
303
+ # Formats a cell value for display in a table column.
304
+ #
305
+ # @param item [Hash, Object] the row item containing the field value
306
+ # @param column [Symbol] the column name to extract and format
307
+ # @param color_enabled [Boolean] whether to apply semantic color formatting
308
+ # @return [String] the formatted cell value ready for table display
309
+ def format_cell(item, column, color_enabled: true)
310
+ value = item.respond_to?(column) ? item.send(column) : item[column]
311
+ status = item.respond_to?(:status) ? item.status : item[:status]
312
+
313
+ return color_by_status(value, status) if column == :list_title && color_enabled && status
314
+
315
+ format_value(value, column, color_enabled: color_enabled)
316
+ end
317
+
318
+ # Formats a field value for display in a detail key-value view.
319
+ #
320
+ # @param data [Hash] the hash containing field values
321
+ # @param field [Symbol] the field name to extract and format
322
+ # @param color_enabled [Boolean] whether to apply semantic color formatting
323
+ # @return [String] the formatted field value ready for detail display
324
+ def format_field(data, field, color_enabled: true)
325
+ value = data[field]
326
+
327
+ return color_by_status(value, data[:status]) if field == :list_title && color_enabled && data[:status]
328
+
329
+ format_value(value, field, color_enabled: color_enabled)
330
+ end
331
+
332
+ # Colors a value based on a workflow status.
333
+ #
334
+ # @param value [String, nil] the text to colorize
335
+ # @param status [String] the status used to determine color
336
+ # @return [String] the value with ANSI color codes applied
337
+ def color_by_status(value, status)
338
+ color = STATUS_COLORS.fetch(status.to_s.downcase, :white)
339
+ colorize(value || "-", color, enabled: true)
340
+ end
341
+
342
+ # Formats a value based on its field name with appropriate type handling.
343
+ #
344
+ # @param value [Object] the raw value to format
345
+ # @param name [Symbol] the field name used to determine formatting rules
346
+ # @param color_enabled [Boolean] whether to apply semantic color formatting
347
+ # @return [String] the formatted value string, or "-" if nil
348
+ def format_value(value, name, color_enabled: true)
349
+ return "-" if value.nil?
350
+
351
+ case name
352
+ when :status
353
+ format_status(value, color_enabled: color_enabled)
354
+ when :priority
355
+ format_priority(value, color_enabled: color_enabled)
356
+ when :archived, :is_watching, :is_bookmarked, :checked
357
+ format_boolean(value, color_enabled: color_enabled)
358
+ when :time_created, :time_updated
359
+ # Use relative time for creation/update timestamps
360
+ format_time(value, relative: true)
361
+ when :start_date, :due_date, :completed_date
362
+ # Use absolute dates for explicit date fields
363
+ format_time(value, relative: false)
364
+ when :title, :content, :description
365
+ truncate(value.to_s, 60)
366
+ when :tags
367
+ Array(value).map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(", ")
368
+ when :members
369
+ Array(value).map { |m|
370
+ if m.respond_to?(:display_name) && m.display_name
371
+ m.display_name
372
+ elsif m.respond_to?(:user_id)
373
+ m.user_id
374
+ elsif m.is_a?(Hash)
375
+ m[:display_name] || m["display_name"] || m[:user_id] || m["user_id"]
376
+ else
377
+ m.to_s
378
+ end
379
+ }.join(", ")
380
+ when Array
381
+ value.join(", ")
382
+ else
383
+ value.to_s
384
+ end
385
+ end
386
+ end
387
+ end
388
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for managing board lists (columns).
6
+ #
7
+ # Lists are columns on a board that organize cards into workflow stages.
8
+ # This class provides commands to list, create, update, and delete lists.
9
+ class Lists < Base
10
+ desc "list", "List all lists on a board"
11
+ option :board, type: :string, required: true, aliases: "-b", desc: "Board to list lists from (ID or name)"
12
+ option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
13
+ # List all lists (columns) on a specified board.
14
+ #
15
+ # @return [void]
16
+ def list
17
+ handle_error do
18
+ board = with_not_found("Board not found. Use 'suth boards list -s SPACE' to see available boards.") do
19
+ client.boards.find(workspace_id, board_id)
20
+ end
21
+ if board.lists.nil? || board.lists.empty?
22
+ say "No lists found on this board.", :yellow
23
+ else
24
+ output_list board.lists, columns: %i[id title color], headers: {id: "LIST_ID"}
25
+ end
26
+ end
27
+ end
28
+
29
+ desc "create", "Create a new list on a board"
30
+ option :board, type: :string, required: true, aliases: "-b", desc: "Board to create list in (ID or name)"
31
+ option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
32
+ option :title, type: :string, required: true, desc: "List title"
33
+ option :description, type: :string, desc: "List description"
34
+ option :icon, type: :string, desc: "Icon name (e.g., shield, rocket)"
35
+ option :color, type: :string, desc: "Color: #{Boards::COLORS.join(", ")}"
36
+ # Add a new list (column) to a board.
37
+ #
38
+ # @return [void]
39
+ def create
40
+ handle_error do
41
+ opts = symbolized_options(:title, :icon, :color)
42
+ opts[:content] = options[:description] if options[:description]
43
+ list = client.boards.create_list(workspace_id, board_id: board_id, **opts)
44
+ output_item list, fields: %i[id title color board_id], labels: {id: "List ID"}
45
+ end
46
+ end
47
+
48
+ desc "update LIST", "Update a list"
49
+ option :title, type: :string, desc: "New title"
50
+ option :description, type: :string, desc: "New description"
51
+ option :icon, type: :string, desc: "Icon name (e.g., shield, rocket)"
52
+ option :color, type: :string, desc: "Color: #{Boards::COLORS.join(", ")}"
53
+ # Update an existing list's properties.
54
+ #
55
+ # @param list_id [String] the unique identifier of the list to update
56
+ # @return [void]
57
+ def update(list_id)
58
+ handle_error do
59
+ opts = symbolized_options(:title, :icon, :color)
60
+ opts[:content] = options[:description] if options[:description]
61
+ list = with_not_found("List not found: '#{list_id}'. Use 'suth lists list -b BOARD' to see available lists.") do
62
+ client.boards.update_list(workspace_id, list_id, **opts)
63
+ end
64
+ output_item list, fields: %i[id title color board_id], labels: {id: "List ID"}
65
+ end
66
+ end
67
+
68
+ desc "delete LIST", "Delete a list"
69
+ # Permanently delete a list from a board after confirmation.
70
+ #
71
+ # @param list_id [String] the unique identifier of the list to delete
72
+ # @return [void]
73
+ def delete(list_id)
74
+ handle_error do
75
+ confirming("Delete list #{list_id}?") do
76
+ with_not_found("List not found: '#{list_id}'. Use 'suth lists list -b BOARD' to see available lists.") do
77
+ client.boards.delete_list(workspace_id, list_id)
78
+ end
79
+ output_success "List #{list_id} deleted"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end