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,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Superthread
6
+ module Cli
7
+ # Interactive setup wizard for first-time CLI configuration.
8
+ #
9
+ # Guides users through API key entry, workspace selection, and account setup.
10
+ # Handles both new installations and reconfiguration of existing accounts.
11
+ module Setup
12
+ module_function
13
+
14
+ # Run the interactive setup wizard.
15
+ #
16
+ # Walks the user through account naming, API key entry, workspace selection,
17
+ # and saves the configuration to disk.
18
+ #
19
+ # @return [void]
20
+ def execute
21
+ Ui.header "Superthread CLI Setup"
22
+ Ui.blank
23
+
24
+ config = Superthread::Configuration.new
25
+
26
+ # Step 1: Determine if adding new or reconfiguring
27
+ account_name = prompt_account_name(config)
28
+ return unless account_name
29
+
30
+ # Step 2: API Key
31
+ Ui.blank
32
+ api_key = prompt_api_key
33
+ return unless api_key
34
+
35
+ # Step 3: Validate and fetch workspaces
36
+ Ui.blank
37
+ workspaces = fetch_workspaces(api_key)
38
+ return unless workspaces
39
+
40
+ # Step 4: Select workspace (auto-selects if only one)
41
+ Ui.blank
42
+ workspace = select_workspace(workspaces)
43
+ return unless workspace
44
+
45
+ # Step 5: Save configuration
46
+ Ui.blank
47
+ save_account(config, account_name, api_key, workspace)
48
+
49
+ # Done!
50
+ Ui.blank
51
+ Ui.success "Setup complete!"
52
+ Ui.blank
53
+ Ui.muted "Account '#{account_name}' is now active."
54
+ Ui.blank
55
+ Ui.muted "Try: suth spaces list"
56
+ Ui.muted " suth boards list --space SPACE_ID"
57
+ Ui.muted " suth cards list --board BOARD_ID"
58
+ Ui.blank
59
+ Ui.muted "To add another account: suth account add <name>"
60
+ Ui.muted "To switch accounts: suth account use <name>"
61
+ end
62
+
63
+ # Prompt for an account name, offering to add new or reconfigure existing.
64
+ #
65
+ # @param config [Superthread::Configuration] the current configuration instance
66
+ # @return [String, nil] the chosen account name, or nil if cancelled
67
+ def prompt_account_name(config)
68
+ Ui.section "Step 1: Account"
69
+
70
+ existing = config.accounts.keys.map(&:to_s)
71
+
72
+ # No existing accounts - first time setup
73
+ if existing.empty?
74
+ return prompt_new_account_name("personal")
75
+ end
76
+
77
+ # Show existing accounts
78
+ current = config.current_account
79
+ Ui.muted "Existing accounts: #{existing.map { |a| (a == current) ? "#{a} (current)" : a }.join(", ")}"
80
+ Ui.blank
81
+
82
+ # Let user choose what to do
83
+ choice = Ui.choose(
84
+ ["Add new account", "Reconfigure existing account"],
85
+ header: "What would you like to do?"
86
+ )
87
+ return nil if choice.nil?
88
+
89
+ Ui.blank
90
+
91
+ if choice.start_with?("Add")
92
+ # Suggest a name that doesn't exist
93
+ suggestion = %w[personal work].find { |n| !existing.include?(n) }
94
+ prompt_new_account_name(suggestion)
95
+ else
96
+ prompt_existing_account(existing)
97
+ end
98
+ end
99
+
100
+ # Prompt user to enter a name for a new account.
101
+ #
102
+ # @param suggestion [String, nil] a suggested default name if available
103
+ # @return [String, nil] the entered account name, or nil if empty
104
+ def prompt_new_account_name(suggestion)
105
+ prompt = if suggestion
106
+ "Account name (default: #{suggestion}):"
107
+ else
108
+ "Account name:"
109
+ end
110
+
111
+ input = Ui.input(prompt)
112
+ name = input&.strip
113
+ name = suggestion if name.nil? || name.empty?
114
+
115
+ if name.nil? || name.empty?
116
+ Ui.error "Account name is required"
117
+ return nil
118
+ end
119
+
120
+ name
121
+ end
122
+
123
+ # Prompt user to select an existing account to reconfigure.
124
+ #
125
+ # @param existing [Array<String>] list of existing account names
126
+ # @return [String, nil] the selected account name, or nil if cancelled
127
+ def prompt_existing_account(existing)
128
+ if existing.length == 1
129
+ name = existing.first
130
+ Ui.info "Reconfiguring account: #{name}"
131
+ return name
132
+ end
133
+
134
+ choice = Ui.choose(existing, header: "Select account to reconfigure:")
135
+ return nil if choice.nil?
136
+
137
+ choice
138
+ end
139
+
140
+ # Prompt user to enter their Superthread API key.
141
+ #
142
+ # @return [String, nil] the entered API key, or nil if empty
143
+ def prompt_api_key
144
+ Ui.section "Step 2: API Key"
145
+ Ui.muted "Get your API key from Superthread Settings > API"
146
+ Ui.blank
147
+
148
+ api_key = Ui.password("Enter your API key:")
149
+
150
+ if api_key.nil? || api_key.strip.empty?
151
+ Ui.error "API key is required"
152
+ return nil
153
+ end
154
+
155
+ api_key.strip
156
+ end
157
+
158
+ # Validate API key and fetch available workspaces from the server.
159
+ #
160
+ # @param api_key [String] the API key to authenticate with
161
+ # @return [Array<Hash{Symbol => String}>, nil] list of workspace hashes, or nil on error
162
+ def fetch_workspaces(api_key)
163
+ Ui.section "Step 3: Connecting..."
164
+
165
+ workspaces = Ui.spin("Fetching workspaces...") do
166
+ temp_client = Superthread::Client.new(api_key: api_key)
167
+ user = temp_client.users.me
168
+ extract_teams(user)
169
+ end
170
+
171
+ if workspaces.empty?
172
+ Ui.error "No workspaces found for this API key"
173
+ return nil
174
+ end
175
+
176
+ Ui.success "Found #{workspaces.length} workspace(s)"
177
+ workspaces
178
+ rescue Superthread::AuthenticationError
179
+ Ui.error "Invalid API key"
180
+ nil
181
+ rescue Superthread::ApiError => e
182
+ Ui.error "API error: #{e.message}"
183
+ nil
184
+ end
185
+
186
+ # Prompt user to select a workspace from the available options.
187
+ #
188
+ # Auto-selects if only one workspace is available.
189
+ #
190
+ # @param workspaces [Array<Hash{Symbol => String}>] list of workspace hashes with :id and :name
191
+ # @return [Hash{Symbol => String}, nil] the selected workspace, or nil if cancelled
192
+ def select_workspace(workspaces)
193
+ Ui.section "Step 4: Select Workspace"
194
+ Ui.blank
195
+
196
+ if workspaces.length == 1
197
+ ws = workspaces.first
198
+ Ui.info "Using workspace: #{ws[:name]}"
199
+ return ws
200
+ end
201
+
202
+ choices = workspaces.map { |ws| "#{ws[:name]} (#{ws[:id]})" }
203
+ selected = Ui.choose(choices, header: "Select your default workspace:")
204
+
205
+ return nil if selected.nil?
206
+
207
+ # Find the selected workspace
208
+ workspaces.find { |ws| selected.include?(ws[:id]) }
209
+ end
210
+
211
+ # Save the account configuration and workspace selection to disk.
212
+ #
213
+ # @param config [Superthread::Configuration] the configuration instance to update
214
+ # @param account_name [String] the name for this account
215
+ # @param api_key [String] the API key to store
216
+ # @param workspace [Hash{Symbol => String}] the selected workspace with :id and :name
217
+ # @return [void]
218
+ def save_account(config, account_name, api_key, workspace)
219
+ Ui.section "Step 5: Saving Configuration"
220
+
221
+ # Add/update account with API key in config file
222
+ config.add_account(account_name, api_key: api_key)
223
+ Ui.muted "Saved account '#{account_name}' to #{config.config_path}"
224
+
225
+ # Save workspace to account state
226
+ config.save_account_state(account_name,
227
+ workspace_id: workspace[:id],
228
+ workspace_name: workspace[:name])
229
+ Ui.muted "Saved workspace to #{config.state_path}"
230
+
231
+ # Set as current account
232
+ config.current_account = account_name
233
+ Ui.muted "Set '#{account_name}' as current account"
234
+ end
235
+
236
+ # Extract team/workspace information from a user object.
237
+ #
238
+ # @param user [User] the authenticated user object containing team memberships
239
+ # @return [Array<Hash{Symbol => String}>] list of workspace hashes with :id, :name, and :role
240
+ def extract_teams(user)
241
+ return [] unless user.teams
242
+
243
+ user.teams.map do |team|
244
+ {
245
+ id: team.id,
246
+ name: team.team_name || "Unknown",
247
+ role: team.role
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for managing Superthread spaces.
6
+ #
7
+ # Spaces are containers that organize boards, pages, and other content
8
+ # within a workspace. This class provides commands to list, create,
9
+ # update, and delete spaces, as well as manage space membership.
10
+ class Spaces < Base
11
+ # Kebab-case aliases for commands
12
+ map "add-member" => :add_member,
13
+ "remove-member" => :remove_member
14
+ desc "list", "List all spaces"
15
+ # Lists all spaces in the current workspace.
16
+ #
17
+ # @return [void]
18
+ def list
19
+ spaces = client.spaces.list(workspace_id)
20
+ output_list spaces, columns: %i[id title], headers: {id: "SPACE_ID"}
21
+ end
22
+
23
+ desc "get SPACE", "Get space details"
24
+ # Retrieves and displays details for a specific space.
25
+ #
26
+ # @param space_ref [String] space identifier (ID or name)
27
+ # @return [void]
28
+ def get(space_ref)
29
+ handle_error do
30
+ resolved_space_id = resolve_space(space_ref)
31
+ begin
32
+ space = client.spaces.find(workspace_id, resolved_space_id)
33
+ rescue Superthread::ForbiddenError
34
+ raise Thor::Error, "Space not found: '#{space_ref}'. Use 'suth spaces list' to see available spaces."
35
+ end
36
+ output_item space, fields: %i[id title description time_created time_updated],
37
+ labels: {id: "Space ID"}
38
+ end
39
+ end
40
+
41
+ desc "create", "Create a new space"
42
+ option :title, type: :string, required: true, desc: "Space title"
43
+ option :description, type: :string, desc: "Space description"
44
+ option :icon, type: :string, desc: "Space icon name (e.g., rocket, folder, star)"
45
+ option :icon_color, type: :string, desc: "Space icon color (hex, e.g., #FF5733)"
46
+ # Creates a new space in the current workspace.
47
+ #
48
+ # @return [void]
49
+ def create
50
+ handle_error do
51
+ opts = symbolized_options(:title, :description)
52
+ opts[:icon] = build_icon_object if options[:icon]
53
+ space = client.spaces.create(workspace_id, **opts)
54
+ output_item space, labels: {id: "Space ID"}
55
+ end
56
+ end
57
+
58
+ desc "update SPACE", "Update a space"
59
+ option :title, type: :string, desc: "New title"
60
+ option :description, type: :string, desc: "New description"
61
+ option :icon, type: :string, desc: "New icon name (e.g., rocket, folder, star)"
62
+ option :icon_color, type: :string, desc: "New icon color (hex, e.g., #FF5733)"
63
+ # Updates an existing space's properties.
64
+ #
65
+ # @param space_ref [String] space identifier (ID or name)
66
+ # @return [void]
67
+ def update(space_ref)
68
+ handle_error do
69
+ opts = symbolized_options(:title, :description)
70
+ opts[:icon] = build_icon_object if options[:icon] || options[:icon_color]
71
+ space = client.spaces.update(workspace_id, resolve_space(space_ref), **opts)
72
+ output_item space, labels: {id: "Space ID"}
73
+ end
74
+ end
75
+
76
+ desc "delete SPACE", "Delete a space"
77
+ # Deletes a space after confirmation.
78
+ #
79
+ # @param space_ref [String] space identifier (ID or name)
80
+ # @return [void]
81
+ def delete(space_ref)
82
+ handle_error do
83
+ space = client.spaces.find(workspace_id, resolve_space(space_ref))
84
+ confirming("Delete space '#{space.title}' (#{space.id})?") do
85
+ client.spaces.destroy(workspace_id, space.id)
86
+ output_success "Space '#{space.title}' deleted"
87
+ end
88
+ end
89
+ end
90
+
91
+ desc "add_member SPACE USERS", "Add member(s) to a space (comma-separated)"
92
+ option :role, type: :string, desc: "Member role"
93
+ # Adds one or more users as members of a space with an optional role.
94
+ #
95
+ # @param space_ref [String] space identifier (ID or name)
96
+ # @param user_refs [String] comma-separated user IDs, names, or emails
97
+ # @return [void]
98
+ def add_member(space_ref, user_refs)
99
+ handle_error do
100
+ space_resolved = resolve_space(space_ref)
101
+ users = user_refs.split(",").map(&:strip)
102
+ users.each do |user_ref|
103
+ user_resolved = resolve_user(user_ref)
104
+ client.spaces.add_member(workspace_id, space_resolved, user_id: user_resolved, role: options[:role])
105
+ end
106
+ output_success "Added #{users.size} user(s) to space #{space_ref}"
107
+ end
108
+ end
109
+
110
+ desc "remove_member SPACE USERS", "Remove member(s) from a space (comma-separated)"
111
+ # Removes one or more users from a space.
112
+ #
113
+ # @param space_ref [String] space identifier (ID or name)
114
+ # @param user_refs [String] comma-separated user IDs, names, or emails
115
+ # @return [void]
116
+ def remove_member(space_ref, user_refs)
117
+ handle_error do
118
+ space_resolved = resolve_space(space_ref)
119
+ users = user_refs.split(",").map(&:strip)
120
+ users.each do |user_ref|
121
+ user_resolved = resolve_user(user_ref)
122
+ client.spaces.remove_member(workspace_id, space_resolved, user_resolved)
123
+ end
124
+ output_success "Removed #{users.size} user(s) from space #{space_ref}"
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # Build an icon object from CLI options.
131
+ #
132
+ # @return [Hash] the icon object for the API
133
+ def build_icon_object
134
+ icon = {type: "icon"}
135
+ icon[:src] = options[:icon] if options[:icon]
136
+ icon[:color] = options[:icon_color] if options[:icon_color]
137
+ icon
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for sprint operations.
6
+ class Sprints < Base
7
+ desc "list", "List all sprints in a space"
8
+ option :space, type: :string, required: true, aliases: "-s", desc: "Space (ID or name)"
9
+ # Lists all sprints in a specified space.
10
+ #
11
+ # @return [void]
12
+ def list
13
+ sprints = client.sprints.list(workspace_id, space_id: space_id)
14
+ output_list sprints, columns: %i[id title start_date], headers: {id: "SPRINT_ID"}
15
+ end
16
+
17
+ desc "get SPRINT", "Get sprint details"
18
+ option :space, type: :string, required: true, aliases: "-s", desc: "Space (ID or name)"
19
+ # Displays detailed information about a specific sprint.
20
+ #
21
+ # @param sprint_id [String] the unique identifier of the sprint to retrieve
22
+ # @return [void]
23
+ def get(sprint_id)
24
+ handle_error do
25
+ sprint = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
26
+ output_item sprint, fields: %i[id title start_date time_created time_updated],
27
+ labels: {id: "Sprint ID"}
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for managing Superthread tags.
6
+ #
7
+ # Tags are labels that can be applied to cards for categorization
8
+ # and filtering. Each tag has a name and color.
9
+ class Tags < Base
10
+ desc "list", "List available tags"
11
+ option :space, type: :string, aliases: "-s", desc: "Space to filter by (ID or name)"
12
+ option :all, type: :boolean, desc: "Include unused tags"
13
+ # Lists all available tags in the workspace.
14
+ #
15
+ # @return [void]
16
+ def list
17
+ handle_error do
18
+ opts = symbolized_options(:all)
19
+ opts[:project_id] = space_id if options[:space]
20
+ tags = client.cards.tags(workspace_id, **opts)
21
+ output_list tags, columns: %i[id name color total_cards], headers: {id: "TAG_ID"}
22
+ end
23
+ end
24
+
25
+ desc "create", "Create a new tag"
26
+ option :name, type: :string, required: true, desc: "Tag name"
27
+ option :color, type: :string, required: true, desc: "Tag color (hex)"
28
+ option :space, type: :string, aliases: "-s", desc: "Space to create tag in (ID or name)"
29
+ # Creates a new tag in the workspace with a name and color.
30
+ #
31
+ # @return [void]
32
+ def create
33
+ opts = symbolized_options(:name, :color)
34
+ opts[:space_id] = space_id if options[:space]
35
+ tag = client.tags.create(workspace_id, **opts)
36
+ output_item tag, fields: %i[id name color], labels: {id: "Tag ID"}
37
+ end
38
+
39
+ desc "update TAG", "Update a tag"
40
+ option :name, type: :string, desc: "New name"
41
+ option :color, type: :string, desc: "New color (hex)"
42
+ # Updates an existing tag's name or color.
43
+ #
44
+ # @param tag_ref [String] tag identifier (ID or name)
45
+ # @return [void]
46
+ def update(tag_ref)
47
+ handle_error do
48
+ tag_id = resolve_tag(tag_ref)
49
+ tag = with_not_found("Tag not found: '#{tag_ref}'. Use 'suth tags list' to see available tags.") do
50
+ client.tags.update(workspace_id, tag_id, **symbolized_options(:name, :color))
51
+ end
52
+ output_item tag, labels: {id: "Tag ID"}
53
+ end
54
+ end
55
+
56
+ desc "delete TAG", "Delete a tag"
57
+ # Deletes a tag after confirmation.
58
+ #
59
+ # @param tag_ref [String] tag identifier (ID or name)
60
+ # @return [void]
61
+ def delete(tag_ref)
62
+ handle_error do
63
+ tag = find_tag(tag_ref)
64
+ confirming("Delete tag '#{tag.name}' (#{tag.id})?") do
65
+ client.tags.destroy(workspace_id, tag.id)
66
+ output_success "Tag '#{tag.name}' deleted"
67
+ end
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Find tag by ID or name, returning the full tag object.
74
+ #
75
+ # @param ref [String] the tag ID or name to find
76
+ # @return [Superthread::Models::Tag] the matching tag object
77
+ # @raise [Thor::Error] if no matching tag is found
78
+ def find_tag(ref)
79
+ tags = client.cards.tags(workspace_id, all: true)
80
+ tag = tags.find { |t| t.id == ref || t.name&.downcase == ref.downcase }
81
+ raise Thor::Error, "Tag not found: '#{ref}'. Use 'suth tags list' to see available tags." unless tag
82
+ tag
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ module Ui
6
+ # Interactive prompt backend using Gum TUI widgets.
7
+ #
8
+ # Provides rich terminal interactions with animated spinners,
9
+ # arrow-key selection, and fuzzy filtering. Requires a terminal
10
+ # with full ANSI escape sequence support.
11
+ module GumPrompt
12
+ module_function
13
+
14
+ # @param question [String] the confirmation question
15
+ # @param default [Boolean] the default answer
16
+ # @return [Boolean]
17
+ def confirm(question, default: true)
18
+ Gum.confirm(question, default: default)
19
+ end
20
+
21
+ # @param prompt [String] the prompt label
22
+ # @param placeholder [String, nil] placeholder hint
23
+ # @return [String]
24
+ def input(prompt, placeholder: nil)
25
+ Gum.input(prompt: prompt, placeholder: placeholder)
26
+ end
27
+
28
+ # @param prompt [String] the prompt label
29
+ # @return [String]
30
+ def password(prompt)
31
+ Gum.input(prompt: prompt, password: true)
32
+ end
33
+
34
+ # @param items [Array<String>] options to choose from
35
+ # @param header [String, nil] optional header text
36
+ # @return [String]
37
+ def choose(items, header: nil)
38
+ Gum.choose(items, header: header)
39
+ end
40
+
41
+ # @param items [Array<String>] the available options for fuzzy filtering
42
+ # @param placeholder [String, nil] placeholder hint
43
+ # @return [String]
44
+ def filter(items, placeholder: nil)
45
+ Gum.filter(items, placeholder: placeholder)
46
+ end
47
+
48
+ # @param title [String] status message
49
+ # @param block [Proc] the block to execute while the spinner is displayed
50
+ # @yieldreturn [Object] result of the block
51
+ # @return [Object]
52
+ def spin(title, &block)
53
+ Gum.spin(title, spinner: :dot, &block)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ module Ui
6
+ # Plain text prompt backend using standard Ruby I/O.
7
+ #
8
+ # Fallback for terminals with limited ANSI escape support
9
+ # (e.g., macOS Terminal.app) where Gum widgets render incorrectly.
10
+ module PlainPrompt
11
+ module_function
12
+
13
+ # @param question [String] the confirmation question
14
+ # @param default [Boolean] the default answer
15
+ # @return [Boolean]
16
+ def confirm(question, default: true)
17
+ hint = default ? "(Y/n)" : "(y/N)"
18
+ print "#{question} #{hint}: "
19
+ answer = $stdin.gets&.chomp
20
+ return default if answer.nil? || answer.empty?
21
+ answer.downcase.start_with?("y")
22
+ end
23
+
24
+ # @param prompt [String] the prompt label
25
+ # @param placeholder [String, nil] placeholder hint (ignored in plain mode)
26
+ # @return [String]
27
+ def input(prompt, placeholder: nil)
28
+ print prompt
29
+ $stdin.gets&.chomp
30
+ end
31
+
32
+ # @param prompt [String] the prompt label
33
+ # @return [String]
34
+ def password(prompt)
35
+ require "io/console"
36
+ print prompt
37
+ $stdin.noecho(&:gets)&.chomp
38
+ end
39
+
40
+ # @param items [Array<String>] options to choose from
41
+ # @param header [String, nil] optional header text
42
+ # @return [String]
43
+ def choose(items, header: nil)
44
+ puts header if header
45
+ items.each_with_index { |item, i| puts " #{i + 1}. #{item}" }
46
+ print "Choose (1-#{items.length}): "
47
+ raw = $stdin.gets&.chomp
48
+ choice = raw&.to_i || 0
49
+ return items.first if choice < 1 || choice > items.length
50
+ items[choice - 1]
51
+ end
52
+
53
+ # @param items [Array<String>] the available options presented as a numbered list
54
+ # @param placeholder [String, nil] placeholder hint (ignored in plain mode)
55
+ # @return [String]
56
+ def filter(items, placeholder: nil)
57
+ choose(items)
58
+ end
59
+
60
+ # @param title [String] status message
61
+ # @param block [Proc] the block to execute while the status is displayed
62
+ # @yieldreturn [Object] result of the block
63
+ # @return [Object]
64
+ def spin(title, &block)
65
+ print "#{title}..."
66
+ result = yield
67
+ puts " done"
68
+ result
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end