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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "glamour"
4
+ require "gum"
5
+ require "reverse_markdown"
6
+ require_relative "ui/gum_prompt"
7
+ require_relative "ui/plain_prompt"
8
+
9
+ module Superthread
10
+ module Cli
11
+ # Terminal UI helpers using Gum for styled output.
12
+ # Provides consistent styling across all CLI commands.
13
+ #
14
+ # Interactive prompts delegate to a prompt backend selected based on
15
+ # terminal capabilities. Terminals with limited ANSI support (e.g.,
16
+ # macOS Terminal.app) use {PlainPrompt}; all others use {GumPrompt}.
17
+ #
18
+ # @example
19
+ # Ui.header("Cards")
20
+ # Ui.success("Card created")
21
+ # Ui.table(rows, columns: ["ID", "Title", "Status"])
22
+ module Ui
23
+ # Brand color - purple
24
+ PRIMARY = "#7D56F4"
25
+
26
+ module_function
27
+
28
+ # Returns the prompt backend for interactive widgets.
29
+ #
30
+ # Uses {PlainPrompt} for terminals with limited ANSI support,
31
+ # otherwise {GumPrompt}.
32
+ #
33
+ # @return [Module] the prompt backend (PlainPrompt or GumPrompt)
34
+ def prompt
35
+ @prompt ||= plain_mode? ? PlainPrompt : GumPrompt
36
+ end
37
+
38
+ # Resets the cached prompt backend.
39
+ #
40
+ # Useful for testing when environment variables change mid-process.
41
+ #
42
+ # @return [void]
43
+ def reset_prompt!
44
+ @prompt = nil
45
+ end
46
+
47
+ # Whether to use plain Ruby I/O instead of gum interactive widgets.
48
+ #
49
+ # Detects Terminal.app (which renders gum's ANSI redraws incorrectly)
50
+ # and respects the SUPERTHREAD_PLAIN env var as a manual override.
51
+ #
52
+ # @return [Boolean] true if interactive widgets should use plain fallbacks
53
+ def plain_mode?
54
+ return true if ENV["SUPERTHREAD_PLAIN"]
55
+
56
+ ENV["TERM_PROGRAM"] == "Apple_Terminal"
57
+ end
58
+
59
+ # ========================================
60
+ # Output methods (always use Gum.style)
61
+ # ========================================
62
+
63
+ # Display a styled header with rounded border in brand color.
64
+ #
65
+ # @param text [String] the header title to display
66
+ def header(text)
67
+ puts Gum.style(
68
+ text,
69
+ foreground: PRIMARY,
70
+ bold: true,
71
+ border: :rounded,
72
+ border_foreground: PRIMARY,
73
+ padding: "0 2"
74
+ )
75
+ end
76
+
77
+ # Display a section title in bold brand color without border.
78
+ #
79
+ # @param text [String] the section title to display
80
+ def section(text)
81
+ puts Gum.style(text, foreground: PRIMARY, bold: true)
82
+ end
83
+
84
+ # Display a success message with green checkmark prefix.
85
+ #
86
+ # @param text [String] the success message to display
87
+ def success(text)
88
+ puts Gum.style("✓ #{text}", foreground: "green", bold: true)
89
+ end
90
+
91
+ # Display an error message with red X prefix.
92
+ #
93
+ # @param text [String] the error message to display
94
+ def error(text)
95
+ puts Gum.style("✗ #{text}", foreground: "red", bold: true)
96
+ end
97
+
98
+ # Display a warning message with yellow exclamation prefix.
99
+ #
100
+ # @param text [String] the warning message to display
101
+ def warning(text)
102
+ puts Gum.style("! #{text}", foreground: "yellow", bold: true)
103
+ end
104
+
105
+ # Display muted text with faint styling for secondary information.
106
+ #
107
+ # @param text [String] the text to display in faint/dim style
108
+ def muted(text)
109
+ puts Gum.style(text, faint: true)
110
+ end
111
+
112
+ # Display informational text in brand purple color.
113
+ #
114
+ # @param text [String] the informational message to display
115
+ def info(text)
116
+ puts Gum.style(text, foreground: PRIMARY)
117
+ end
118
+
119
+ # Display a key-value pair with styled label.
120
+ #
121
+ # @param key [String] the label name to display in brand color
122
+ # @param value [String] the value to display after the label
123
+ def kv(key, value)
124
+ label = Gum.style("#{key}:", foreground: PRIMARY, bold: true)
125
+ puts "#{label} #{value}"
126
+ end
127
+
128
+ # Display a styled table using gum with rounded borders.
129
+ #
130
+ # @param rows [Array<Array>] the table data as an array of row arrays
131
+ # @param columns [Array<String>] the column header labels
132
+ # @note Requires a TTY. For non-interactive output, use Formatter.table instead.
133
+ def table(rows, columns:)
134
+ return if rows.empty?
135
+
136
+ Gum.table(
137
+ rows,
138
+ columns: columns,
139
+ print: true,
140
+ border: :rounded,
141
+ header_foreground: PRIMARY
142
+ )
143
+ puts ""
144
+ end
145
+
146
+ # Display a blank line for visual spacing between sections.
147
+ #
148
+ # @return [void]
149
+ def blank
150
+ puts ""
151
+ end
152
+
153
+ # Display a horizontal divider line for visual separation.
154
+ #
155
+ # @return [void]
156
+ def divider
157
+ puts Gum.style("─" * 40, faint: true)
158
+ end
159
+
160
+ # Format a numeric amount as currency with negative values in red.
161
+ #
162
+ # @param amount [Numeric] the monetary amount to format
163
+ # @return [String] the formatted currency string (e.g., "$12.50" or "-$5.00")
164
+ def money(amount)
165
+ formatted = format("$%.2f", amount.abs)
166
+ if amount.negative?
167
+ Gum.style("-#{formatted}", foreground: "red")
168
+ else
169
+ formatted
170
+ end
171
+ end
172
+
173
+ # ========================================
174
+ # Interactive methods (delegate to prompt)
175
+ # ========================================
176
+
177
+ # Prompt user for yes/no confirmation.
178
+ #
179
+ # @param question [String] the confirmation question to display
180
+ # @param default [Boolean] the default answer if user presses enter
181
+ # @return [Boolean] true if confirmed, false if declined
182
+ def confirm(question, default: true)
183
+ prompt.confirm(question, default: default)
184
+ end
185
+
186
+ # Prompt user for single-line text input.
187
+ #
188
+ # @param prompt_text [String] the prompt label shown before the input field
189
+ # @param placeholder [String, nil] the placeholder hint shown in empty field
190
+ # @return [String] the text entered by the user
191
+ def input(prompt_text, placeholder: nil)
192
+ prompt_text = "#{prompt_text} " unless prompt_text.end_with?(" ")
193
+ prompt.input(prompt_text, placeholder: placeholder)
194
+ end
195
+
196
+ # Prompt user for password input with hidden characters.
197
+ #
198
+ # @param prompt_text [String] the prompt label shown before the input field
199
+ # @return [String] the password entered by the user (not echoed to screen)
200
+ def password(prompt_text)
201
+ prompt_text = "#{prompt_text} " unless prompt_text.end_with?(" ")
202
+ prompt.password(prompt_text)
203
+ end
204
+
205
+ # Prompt user to choose a single item from a list with arrow keys.
206
+ #
207
+ # @param items [Array<String>] the options to choose from
208
+ # @param header [String, nil] the optional header text above the list
209
+ # @return [String] the selected item string
210
+ def choose(items, header: nil)
211
+ prompt.choose(items, header: header)
212
+ end
213
+
214
+ # Prompt user to filter and select from a list with fuzzy search.
215
+ #
216
+ # Falls back to numbered list selection in plain mode.
217
+ #
218
+ # @param items [Array<String>] the items available for filtering
219
+ # @param placeholder [String, nil] the placeholder hint for the search input
220
+ # @return [String] the selected item after filtering
221
+ def filter(items, placeholder: nil)
222
+ prompt.filter(items, placeholder: placeholder)
223
+ end
224
+
225
+ # Show an animated spinner while executing a block.
226
+ #
227
+ # In plain mode, prints a status line without animation.
228
+ #
229
+ # @param title [String] the status message shown next to the spinner
230
+ # @param block [Proc] the block to execute while the spinner is displayed
231
+ # @yieldreturn [Object] the result of the long-running operation
232
+ # @return [Object] the return value of the block
233
+ def spin(title, &block)
234
+ prompt.spin(title, &block)
235
+ end
236
+
237
+ # Render HTML or Markdown content with terminal-friendly styling.
238
+ #
239
+ # Converts HTML to Markdown first if needed, then renders with glamour
240
+ # for syntax highlighting and formatting.
241
+ #
242
+ # @param content [String] the HTML or Markdown content to render
243
+ # @param width [Integer] the word wrap width in characters
244
+ # @return [String] the styled content suitable for terminal display
245
+ def render_markdown(content, width: 80)
246
+ return "" if content.nil? || content.empty?
247
+
248
+ # Convert HTML to Markdown if it looks like HTML
249
+ markdown = if content.include?("<") && content.include?(">")
250
+ ReverseMarkdown.convert(content, unknown_tags: :bypass)
251
+ else
252
+ content
253
+ end
254
+
255
+ # Render with glamour
256
+ Glamour.render(markdown, width: width, style: "auto")
257
+ rescue
258
+ # Fall back to plain text if rendering fails
259
+ content
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for workspace management within the current account.
6
+ class Workspaces < Base
7
+ desc "list", "List available workspaces for current account"
8
+ # Lists all workspaces available to the current account.
9
+ #
10
+ # @return [void]
11
+ def list
12
+ user = client.users.me
13
+ teams = extract_teams(user)
14
+
15
+ if teams.empty?
16
+ say "No workspaces found"
17
+ return
18
+ end
19
+
20
+ cfg = Superthread::Configuration.new
21
+ current = cfg.workspace
22
+
23
+ say "WORKSPACES"
24
+ teams.each do |team|
25
+ marker = (team[:id] == current) ? "*" : " "
26
+ role = team[:role] || "member"
27
+ say " #{marker} #{team[:id].to_s.ljust(20)} #{team[:name].to_s.ljust(25)} #{role}"
28
+ end
29
+ say ""
30
+ say "Use 'suth workspaces use <ID>' to set default workspace."
31
+ end
32
+
33
+ desc "use WORKSPACE", "Set default workspace for current account"
34
+ # Sets the default workspace for the current account.
35
+ #
36
+ # @param workspace_ref [String] workspace ID or name to switch to
37
+ # @return [void]
38
+ def use(workspace_ref)
39
+ handle_error do
40
+ cfg = Superthread::Configuration.new
41
+
42
+ unless cfg.current_account
43
+ Ui.error "No account selected"
44
+ Ui.muted "Run 'suth setup' to configure an account first"
45
+ return
46
+ end
47
+
48
+ # Fetch workspaces and resolve the reference
49
+ user = client.users.me
50
+ teams = extract_teams(user)
51
+
52
+ workspace = teams.find { |t| t[:id] == workspace_ref || t[:name] == workspace_ref }
53
+
54
+ unless workspace
55
+ Ui.error "Workspace '#{workspace_ref}' not found"
56
+ Ui.muted "Available workspaces:"
57
+ teams.each { |t| Ui.muted " #{t[:id]} - #{t[:name]}" }
58
+ return
59
+ end
60
+
61
+ cfg.save_account_state(cfg.current_account,
62
+ workspace_id: workspace[:id],
63
+ workspace_name: workspace[:name])
64
+
65
+ output_success "Default workspace set to: #{workspace[:name]} (#{workspace[:id]})"
66
+ end
67
+ end
68
+
69
+ desc "current", "Show current default workspace"
70
+ # Displays the currently selected default workspace.
71
+ #
72
+ # @return [void]
73
+ def current
74
+ cfg = Superthread::Configuration.new
75
+
76
+ if cfg.workspace
77
+ say "Current workspace: #{cfg.workspace}"
78
+ say_info "Account: #{cfg.current_account}" if cfg.current_account
79
+ else
80
+ say "No default workspace set"
81
+ say_info "Use 'suth workspaces list' to see available workspaces"
82
+ say_info "Use 'suth workspaces use <ID>' to set a default"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ # Extract team/workspace information from a user object.
89
+ #
90
+ # @param user [Superthread::Models::User] the user object with teams data
91
+ # @return [Array<Hash{Symbol => String}>] array of workspace hashes with :id, :name, :role
92
+ def extract_teams(user)
93
+ return [] unless user.teams
94
+
95
+ user.teams.map do |team|
96
+ {
97
+ id: team.id,
98
+ name: team.team_name || "Unknown",
99
+ role: team.role
100
+ }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Superthread
6
+ # Command-line interface for Superthread project management.
7
+ # Provides Thor-based commands for managing cards, boards, projects, and more.
8
+ module Cli
9
+ # Error raised when a CLI command fails.
10
+ class CommandError < Superthread::Error; end
11
+ end
12
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Superthread
6
+ # HTTP client for the Superthread API.
7
+ #
8
+ # Provides access to all API resources through typed accessors.
9
+ # Handles authentication, request signing, and response parsing.
10
+ #
11
+ # @example Creating a client
12
+ # client = Superthread::Client.new(api_key: "sk_...")
13
+ # cards = client.cards.list(workspace_id)
14
+ # card = client.cards.find(workspace_id, card_id)
15
+ #
16
+ # @example Using environment configuration
17
+ # # Uses SUPERTHREAD_API_KEY and config files
18
+ # client = Superthread::Client.new
19
+ class Client
20
+ # @return [Superthread::Resources::Users] users resource
21
+ # @return [Superthread::Resources::Projects] projects resource
22
+ # @return [Superthread::Resources::Spaces] spaces resource
23
+ # @return [Superthread::Resources::Boards] boards resource
24
+ # @return [Superthread::Resources::Cards] cards resource
25
+ # @return [Superthread::Resources::Comments] comments resource
26
+ # @return [Superthread::Resources::Pages] pages resource
27
+ # @return [Superthread::Resources::Notes] notes resource
28
+ # @return [Superthread::Resources::Sprints] sprints resource
29
+ # @return [Superthread::Resources::Search] search resource
30
+ # @return [Superthread::Resources::Tags] tags resource
31
+ attr_reader :users, :projects, :spaces, :boards, :cards,
32
+ :comments, :pages, :notes, :sprints, :search, :tags
33
+
34
+ # @return [Faraday::Response, nil] the last HTTP response received
35
+ attr_reader :last_response
36
+
37
+ # Creates a new API client.
38
+ #
39
+ # @param api_key [String, nil] API key (overrides config file and environment)
40
+ # @param base_url [String, nil] custom API base URL
41
+ # @param workspace [String, nil] default workspace ID
42
+ # @raise [ConfigurationError] if no valid API key is available
43
+ def initialize(api_key: nil, base_url: nil, workspace: nil)
44
+ @config = build_config(api_key, base_url, workspace)
45
+ @config.validate!
46
+ @connection = Superthread::Connection.new(@config)
47
+
48
+ # Initialize resource accessors
49
+ @users = Superthread::Resources::Users.new(self)
50
+ @projects = Superthread::Resources::Projects.new(self)
51
+ @spaces = Superthread::Resources::Spaces.new(self)
52
+ @boards = Superthread::Resources::Boards.new(self)
53
+ @cards = Superthread::Resources::Cards.new(self)
54
+ @comments = Superthread::Resources::Comments.new(self)
55
+ @pages = Superthread::Resources::Pages.new(self)
56
+ @notes = Superthread::Resources::Notes.new(self)
57
+ @sprints = Superthread::Resources::Sprints.new(self)
58
+ @search = Superthread::Resources::Search.new(self)
59
+ @tags = Superthread::Resources::Tags.new(self)
60
+ end
61
+
62
+ # Returns the resolved default workspace ID.
63
+ #
64
+ # @return [String, nil] workspace ID from configuration
65
+ def default_workspace
66
+ @config.workspace
67
+ end
68
+
69
+ # Makes an API request and returns raw hash data.
70
+ #
71
+ # Use this when you need the raw response before object conversion.
72
+ #
73
+ # @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
74
+ # @param path [String] API endpoint path relative to base URL (e.g., "/cards/123")
75
+ # @param params [Hash{Symbol => Object}, nil] query parameters to include
76
+ # @param body [Hash{Symbol => Object}, nil] request body to send as JSON
77
+ # @return [Hash{Symbol => Object}] parsed JSON response
78
+ # @raise [Superthread::ApiError] if the request fails
79
+ def request(method:, path:, params: nil, body: nil)
80
+ response = @connection.request(method: method, path: path, params: params, body: body)
81
+ @last_response = response
82
+ handle_response(response)
83
+ end
84
+
85
+ # Makes an API request and returns a typed object.
86
+ #
87
+ # This is the primary method used by resource classes.
88
+ #
89
+ # @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
90
+ # @param path [String] API endpoint path relative to base URL
91
+ # @param params [Hash{Symbol => Object}, nil] query parameters to include
92
+ # @param body [Hash{Symbol => Object}, nil] request body to send as JSON
93
+ # @param object_class [Class, nil] class to instantiate for the response
94
+ # @param unwrap_key [Symbol, nil] key to unwrap from response (e.g., :card extracts response[:card])
95
+ # @return [Superthread::Object, Superthread::Model] response wrapped in appropriate object class
96
+ # @raise [Superthread::ApiError] if the request fails
97
+ def request_object(method:, path:, params: nil, body: nil, object_class: nil, unwrap_key: nil)
98
+ data = request(method: method, path: path, params: params, body: body)
99
+ convert_to_object(data, object_class: object_class, unwrap_key: unwrap_key)
100
+ end
101
+
102
+ # Makes an API request and returns a collection of objects.
103
+ #
104
+ # @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
105
+ # @param path [String] API endpoint path relative to base URL
106
+ # @param params [Hash{Symbol => Object}, nil] query parameters to include
107
+ # @param body [Hash{Symbol => Object}, nil] request body to send as JSON
108
+ # @param item_class [Class, nil] class to instantiate for each item
109
+ # @param items_key [Symbol, nil] key containing the items array (auto-detected if nil)
110
+ # @return [Superthread::Objects::Collection] collection of objects
111
+ # @raise [Superthread::ApiError] if the request fails
112
+ def request_collection(method:, path:, params: nil, body: nil, item_class: nil, items_key: nil)
113
+ data = request(method: method, path: path, params: params, body: body)
114
+ Superthread::Objects::Collection.from_response(data, key: items_key, item_class: item_class)
115
+ end
116
+
117
+ # Converts raw hash data to a typed object.
118
+ #
119
+ # @param data [Hash{Symbol => Object}, Array<Hash>] raw response data
120
+ # @param object_class [Class, nil] class to instantiate
121
+ # @param unwrap_key [Symbol, nil] key to unwrap from response
122
+ # @return [Superthread::Object, Superthread::Model, Array, Object] converted object(s)
123
+ def convert_to_object(data, object_class: nil, unwrap_key: nil)
124
+ # Unwrap nested response (e.g., { card: { ... } } -> { ... })
125
+ data = data[unwrap_key] if unwrap_key && data.is_a?(Hash) && data.key?(unwrap_key)
126
+
127
+ if object_class
128
+ # Use Shale's from_response for Model subclasses
129
+ if shale_model?(object_class)
130
+ case data
131
+ when Array
132
+ object_class.from_response_array(data)
133
+ when Hash
134
+ object_class.from_response(data)
135
+ else
136
+ data
137
+ end
138
+ else
139
+ # Legacy Superthread::Object pattern
140
+ case data
141
+ when Array
142
+ data.map { |item| object_class.new(item) }
143
+ when Hash
144
+ object_class.new(data)
145
+ else
146
+ data
147
+ end
148
+ end
149
+ else
150
+ Superthread::Object.construct_from(data)
151
+ end
152
+ end
153
+
154
+ # Checks if a class is a Shale-based Model.
155
+ #
156
+ # @param klass [Class] the class to check
157
+ # @return [Boolean] true if the class is a Shale model
158
+ def shale_model?(klass)
159
+ Superthread::Model.shale_class?(klass)
160
+ end
161
+
162
+ private
163
+
164
+ # Builds a configuration object with optional overrides.
165
+ #
166
+ # @param api_key [String, nil] API key override
167
+ # @param base_url [String, nil] base URL override
168
+ # @param workspace [String, nil] workspace ID override
169
+ # @return [Superthread::Configuration] the configured configuration object
170
+ def build_config(api_key, base_url, workspace)
171
+ config = Superthread::Configuration.new
172
+ config.api_key = api_key if api_key
173
+ config.base_url = base_url if base_url
174
+ config.workspace = workspace if workspace
175
+ config
176
+ end
177
+
178
+ # Handles an HTTP response, parsing success or raising on error.
179
+ #
180
+ # @param response [Faraday::Response] the HTTP response object
181
+ # @return [Hash{Symbol => Object}] parsed response body
182
+ # @raise [Superthread::ApiError] if response status indicates failure
183
+ def handle_response(response)
184
+ case response.status
185
+ when 200..299
186
+ parse_response(response)
187
+ else
188
+ raise Superthread::ApiError.from_response(response)
189
+ end
190
+ end
191
+
192
+ # Parses a successful HTTP response body as JSON.
193
+ #
194
+ # @param response [Faraday::Response] the HTTP response object
195
+ # @return [Hash{Symbol => Object}] parsed JSON with symbol keys
196
+ def parse_response(response)
197
+ return {success: true} if response.status == 204
198
+
199
+ body = response.body.to_s
200
+ return {success: true} if body.empty?
201
+
202
+ JSON.parse(body, symbolize_names: true)
203
+ rescue JSON::ParserError
204
+ {success: true}
205
+ end
206
+ end
207
+ end