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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +4 -0
- data/LICENSE +21 -0
- data/README.md +492 -0
- data/exe/suth +19 -0
- data/lib/superthread/cli/accounts.rb +240 -0
- data/lib/superthread/cli/activity.rb +210 -0
- data/lib/superthread/cli/base.rb +355 -0
- data/lib/superthread/cli/boards.rb +131 -0
- data/lib/superthread/cli/cards.rb +530 -0
- data/lib/superthread/cli/checklists.rb +223 -0
- data/lib/superthread/cli/comments.rb +86 -0
- data/lib/superthread/cli/completion.rb +306 -0
- data/lib/superthread/cli/concerns/board_resolvable.rb +70 -0
- data/lib/superthread/cli/concerns/confirmable.rb +55 -0
- data/lib/superthread/cli/concerns/date_parsable.rb +196 -0
- data/lib/superthread/cli/concerns/list_resolvable.rb +53 -0
- data/lib/superthread/cli/concerns/space_resolvable.rb +52 -0
- data/lib/superthread/cli/concerns/sprint_resolvable.rb +55 -0
- data/lib/superthread/cli/concerns/tag_resolvable.rb +49 -0
- data/lib/superthread/cli/concerns/user_resolvable.rb +52 -0
- data/lib/superthread/cli/concerns/workspace_resolvable.rb +83 -0
- data/lib/superthread/cli/config.rb +129 -0
- data/lib/superthread/cli/formatter.rb +388 -0
- data/lib/superthread/cli/lists.rb +85 -0
- data/lib/superthread/cli/main.rb +121 -0
- data/lib/superthread/cli/members.rb +19 -0
- data/lib/superthread/cli/notes.rb +64 -0
- data/lib/superthread/cli/pages.rb +128 -0
- data/lib/superthread/cli/projects.rb +124 -0
- data/lib/superthread/cli/replies.rb +94 -0
- data/lib/superthread/cli/search.rb +34 -0
- data/lib/superthread/cli/setup.rb +253 -0
- data/lib/superthread/cli/spaces.rb +141 -0
- data/lib/superthread/cli/sprints.rb +32 -0
- data/lib/superthread/cli/tags.rb +86 -0
- data/lib/superthread/cli/ui/gum_prompt.rb +58 -0
- data/lib/superthread/cli/ui/plain_prompt.rb +73 -0
- data/lib/superthread/cli/ui.rb +263 -0
- data/lib/superthread/cli/workspaces.rb +105 -0
- data/lib/superthread/cli.rb +12 -0
- data/lib/superthread/client.rb +207 -0
- data/lib/superthread/configuration.rb +354 -0
- data/lib/superthread/connection.rb +57 -0
- data/lib/superthread/error.rb +164 -0
- data/lib/superthread/mention_formatter.rb +96 -0
- data/lib/superthread/model.rb +178 -0
- data/lib/superthread/models/board.rb +59 -0
- data/lib/superthread/models/card.rb +321 -0
- data/lib/superthread/models/checklist.rb +91 -0
- data/lib/superthread/models/checklist_item.rb +69 -0
- data/lib/superthread/models/comment.rb +71 -0
- data/lib/superthread/models/concerns/archivable.rb +32 -0
- data/lib/superthread/models/concerns/presentable.rb +113 -0
- data/lib/superthread/models/concerns/timestampable.rb +91 -0
- data/lib/superthread/models/list.rb +67 -0
- data/lib/superthread/models/member.rb +40 -0
- data/lib/superthread/models/note.rb +56 -0
- data/lib/superthread/models/page.rb +70 -0
- data/lib/superthread/models/project.rb +83 -0
- data/lib/superthread/models/space.rb +71 -0
- data/lib/superthread/models/sprint.rb +53 -0
- data/lib/superthread/models/tag.rb +52 -0
- data/lib/superthread/models/team.rb +68 -0
- data/lib/superthread/models/user.rb +76 -0
- data/lib/superthread/models.rb +12 -0
- data/lib/superthread/object.rb +285 -0
- data/lib/superthread/objects/collection.rb +179 -0
- data/lib/superthread/resources/base.rb +204 -0
- data/lib/superthread/resources/boards.rb +150 -0
- data/lib/superthread/resources/cards.rb +363 -0
- data/lib/superthread/resources/comments.rb +163 -0
- data/lib/superthread/resources/notes.rb +61 -0
- data/lib/superthread/resources/pages.rb +110 -0
- data/lib/superthread/resources/projects.rb +117 -0
- data/lib/superthread/resources/search.rb +46 -0
- data/lib/superthread/resources/spaces.rb +104 -0
- data/lib/superthread/resources/sprints.rb +37 -0
- data/lib/superthread/resources/tags.rb +52 -0
- data/lib/superthread/resources/users.rb +29 -0
- data/lib/superthread/version.rb +6 -0
- data/lib/superthread/version_checker.rb +174 -0
- data/lib/superthread.rb +30 -0
- 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
|