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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Models
5
+ # Represents a Superthread space.
6
+ #
7
+ # Spaces are organizational containers within a workspace that group
8
+ # related projects, pages, and other content. They can have dedicated
9
+ # members with specific access.
10
+ #
11
+ # @example
12
+ # space = client.spaces.find(workspace_id, space_id)
13
+ # space.title # => "Engineering"
14
+ # space.members # => [#<Superthread::Models::Member ...>]
15
+ # space.archived? # => false
16
+ class Space < Superthread::Model
17
+ include Concerns::Archivable
18
+ include Concerns::Presentable
19
+ include Concerns::Timestampable
20
+
21
+ detail_fields :id, :title, :description, :time_created
22
+ list_columns :id, :title
23
+
24
+ # @!attribute [rw] id
25
+ # @return [String] unique space identifier
26
+ attribute :id, Shale::Type::String
27
+
28
+ # @!attribute [rw] type
29
+ # @return [String] space type
30
+ attribute :type, Shale::Type::String
31
+
32
+ # @!attribute [rw] team_id
33
+ # @return [String] ID of the team/workspace this space belongs to
34
+ attribute :team_id, Shale::Type::String
35
+
36
+ # @!attribute [rw] title
37
+ # @return [String] display title of the space
38
+ attribute :title, Shale::Type::String
39
+
40
+ # @!attribute [rw] description
41
+ # @return [String] space description or purpose
42
+ attribute :description, Shale::Type::String
43
+
44
+ # @!attribute [rw] icon
45
+ # @return [Object] emoji or icon data for the space
46
+ attribute :icon, Shale::Type::Value
47
+
48
+ # @!attribute [rw] user_id
49
+ # @return [String] ID of the user who created the space
50
+ attribute :user_id, Shale::Type::String
51
+
52
+ # @!attribute [rw] time_created
53
+ # @return [Integer] Unix timestamp when the space was created
54
+ attribute :time_created, Shale::Type::Integer
55
+
56
+ # @!attribute [rw] time_updated
57
+ # @return [Integer] Unix timestamp when the space was last updated
58
+ attribute :time_updated, Shale::Type::Integer
59
+
60
+ # @!attribute [rw] members
61
+ # @return [Array<Member>] users with access to this space
62
+ attribute :members, Member, collection: true
63
+
64
+ # @!attribute [rw] archived
65
+ # @return [Hash{String => Object}, nil] archive metadata with user_id and time_archived
66
+ attribute :archived, Shale::Type::Value
67
+
68
+ timestamps :time_created, :time_updated
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Models
5
+ # Represents a Superthread sprint.
6
+ #
7
+ # Sprints are time-boxed iterations used for agile planning. Cards
8
+ # can be assigned to sprints to organize work into release cycles.
9
+ #
10
+ # @example
11
+ # sprint = client.sprints.find(workspace_id, sprint_id)
12
+ # sprint.title # => "Sprint 42"
13
+ # sprint.start_time # => 2024-01-15 00:00:00 UTC
14
+ class Sprint < Superthread::Model
15
+ include Concerns::Presentable
16
+ include Concerns::Timestampable
17
+
18
+ detail_fields :id, :title, :start_date, :time_created, :time_updated
19
+ list_columns :id, :title, :status
20
+
21
+ # @!attribute [rw] id
22
+ # @return [String] unique sprint identifier
23
+ attribute :id, Shale::Type::String
24
+
25
+ # @!attribute [rw] team_id
26
+ # @return [String] ID of the team/workspace this sprint belongs to
27
+ attribute :team_id, Shale::Type::String
28
+
29
+ # @!attribute [rw] title
30
+ # @return [String] display title of the sprint
31
+ attribute :title, Shale::Type::String
32
+
33
+ # @!attribute [rw] start_date
34
+ # @return [Integer] Unix timestamp when the sprint starts
35
+ attribute :start_date, Shale::Type::Integer
36
+
37
+ # @!attribute [rw] time_created
38
+ # @return [Integer] Unix timestamp when the sprint was created
39
+ attribute :time_created, Shale::Type::Integer
40
+
41
+ # @!attribute [rw] time_updated
42
+ # @return [Integer] Unix timestamp when the sprint was last updated
43
+ attribute :time_updated, Shale::Type::Integer
44
+
45
+ # @!attribute [rw] lists
46
+ # @return [Array<List>] columns/lists available in this sprint
47
+ attribute :lists, List, collection: true
48
+
49
+ timestamps :time_created, :time_updated
50
+ timestamps start_date: :start_time
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Models
5
+ # Represents a Superthread tag/label.
6
+ #
7
+ # Tags are colored labels that can be applied to cards for categorization
8
+ # and filtering. They track how many cards use each tag.
9
+ #
10
+ # @example
11
+ # tag = card.tags.first
12
+ # tag.name # => "bug"
13
+ # tag.color # => "red"
14
+ # tag.total_cards # => 17
15
+ class Tag < Superthread::Model
16
+ include Concerns::Presentable
17
+
18
+ presents_as :name
19
+
20
+ detail_fields :id, :name, :color, :total_cards
21
+ list_columns :id, :name, :color
22
+
23
+ # @!attribute [rw] id
24
+ # @return [String] unique tag identifier
25
+ attribute :id, Shale::Type::String
26
+
27
+ # @!attribute [rw] team_id
28
+ # @return [String] ID of the team/workspace this tag belongs to
29
+ attribute :team_id, Shale::Type::String
30
+
31
+ # @!attribute [rw] project_id
32
+ # @return [String] ID of the project this tag is scoped to, if any
33
+ attribute :project_id, Shale::Type::String
34
+
35
+ # @!attribute [rw] name
36
+ # @return [String] display name of the tag
37
+ attribute :name, Shale::Type::String
38
+
39
+ # @!attribute [rw] slug
40
+ # @return [String] URL-safe version of the tag name
41
+ attribute :slug, Shale::Type::String
42
+
43
+ # @!attribute [rw] color
44
+ # @return [String] color code for visual styling
45
+ attribute :color, Shale::Type::String
46
+
47
+ # @!attribute [rw] total_cards
48
+ # @return [Integer] number of cards using this tag
49
+ attribute :total_cards, Shale::Type::Integer
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Models
5
+ # Represents a Superthread team/workspace.
6
+ #
7
+ # Teams are the top-level organizational unit in Superthread. All boards,
8
+ # projects, spaces, and users belong to a team. Users can be members of
9
+ # multiple teams.
10
+ #
11
+ # @example
12
+ # user = client.users.me
13
+ # user.teams.each do |team|
14
+ # puts "#{team.team_name} (#{team.id})"
15
+ # end
16
+ class Team < Superthread::Model
17
+ include Concerns::Presentable
18
+ include Concerns::Timestampable
19
+
20
+ presents_as :team_name
21
+
22
+ # @!attribute [rw] id
23
+ # @return [String] unique team identifier (workspace ID)
24
+ attribute :id, Shale::Type::String
25
+
26
+ # @!attribute [rw] team_name
27
+ # @return [String] display name of the team
28
+ attribute :team_name, Shale::Type::String
29
+
30
+ # @!attribute [rw] sub_domain
31
+ # @return [String] subdomain for the team's Superthread URL
32
+ attribute :sub_domain, Shale::Type::String
33
+
34
+ # @!attribute [rw] role
35
+ # @return [String] current user's role in this team
36
+ attribute :role, Shale::Type::String
37
+
38
+ # @!attribute [rw] status
39
+ # @return [String] team status (e.g., "active")
40
+ attribute :status, Shale::Type::String
41
+
42
+ # @!attribute [rw] creator_user_id
43
+ # @return [String] ID of the user who created the team
44
+ attribute :creator_user_id, Shale::Type::String
45
+
46
+ # @!attribute [rw] subscription_plan_id
47
+ # @return [String] ID of the team's subscription plan
48
+ attribute :subscription_plan_id, Shale::Type::String
49
+
50
+ # @!attribute [rw] time_created
51
+ # @return [Integer] Unix timestamp when the team was created
52
+ attribute :time_created, Shale::Type::Integer
53
+
54
+ # @!attribute [rw] time_updated
55
+ # @return [Integer] Unix timestamp when the team was last updated
56
+ attribute :time_updated, Shale::Type::Integer
57
+
58
+ timestamps :time_created
59
+
60
+ # Alias for team_name for consistency.
61
+ #
62
+ # @return [String] Team/workspace name
63
+ def name
64
+ team_name
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Models
5
+ # Represents a Superthread user.
6
+ #
7
+ # Users are people who can access Superthread workspaces. They have
8
+ # profiles with display names, emails, and avatars, and can be members
9
+ # of multiple teams.
10
+ #
11
+ # @example
12
+ # user = client.users.me
13
+ # user.display_name # => "John Doe"
14
+ # user.email # => "john@example.com"
15
+ # user.id # => "u123"
16
+ class User < Superthread::Model
17
+ include Concerns::Presentable
18
+ include Concerns::Timestampable
19
+
20
+ presents_as :display_name
21
+
22
+ detail_fields :user_id, :display_name, :email, :role
23
+ list_columns :user_id, :display_name, :email
24
+
25
+ # @!attribute [rw] id
26
+ # @return [String] unique user identifier
27
+ attribute :id, Shale::Type::String
28
+
29
+ # @!attribute [rw] user_id
30
+ # @return [String] alternate user identifier (API returns both in different contexts)
31
+ attribute :user_id, Shale::Type::String
32
+
33
+ # @!attribute [rw] type
34
+ # @return [String] user type
35
+ attribute :type, Shale::Type::String
36
+
37
+ # @!attribute [rw] display_name
38
+ # @return [String] user's display name
39
+ attribute :display_name, Shale::Type::String
40
+
41
+ # @!attribute [rw] email
42
+ # @return [String] user's email address
43
+ attribute :email, Shale::Type::String
44
+
45
+ # @!attribute [rw] avatar
46
+ # @return [String] URL to the user's avatar image
47
+ attribute :avatar, Shale::Type::String
48
+
49
+ # @!attribute [rw] role
50
+ # @return [String] user's role within the current context
51
+ attribute :role, Shale::Type::String
52
+
53
+ # @!attribute [rw] time_created
54
+ # @return [Integer] Unix timestamp when the user account was created
55
+ attribute :time_created, Shale::Type::Integer
56
+
57
+ # @!attribute [rw] time_updated
58
+ # @return [Integer] Unix timestamp when the user was last updated
59
+ attribute :time_updated, Shale::Type::Integer
60
+
61
+ # @!attribute [rw] teams
62
+ # @return [Array<Team>] teams/workspaces this user belongs to
63
+ attribute :teams, Team, collection: true
64
+
65
+ timestamps :time_created, :time_updated
66
+
67
+ # Returns the user ID, checking both 'id' and 'user_id' fields
68
+ # (API returns different fields in different contexts)
69
+ #
70
+ # @return [String] User ID
71
+ def user_identifier
72
+ id || user_id
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ # Namespace for Shale-based API response models.
5
+ #
6
+ # These typed models are replacing the generic Superthread::Object
7
+ # classes, providing better type safety and validation.
8
+ #
9
+ # @see Superthread::Model Base class for all models
10
+ module Models
11
+ end
12
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ # Generic wrapper for API response objects (legacy pattern).
5
+ #
6
+ # Prefer {Superthread::Model} (Shale-based) for new resource types.
7
+ # This class is retained for backward compatibility with endpoints
8
+ # that haven't been migrated to typed models yet.
9
+ #
10
+ # Provides hash-like access, dot notation via method_missing, and conversion to Hash.
11
+ #
12
+ # Inspired by Stripe's StripeObject pattern - allows both:
13
+ # card.title # dot notation
14
+ # card[:title] # symbol key access
15
+ # card["title"] # string key access
16
+ # card.to_h # convert to plain hash
17
+ #
18
+ # @example
19
+ # card = client.cards.find(workspace_id, card_id)
20
+ # card.title # => "My Card"
21
+ # card[:title] # => "My Card"
22
+ # card.members.first.role # => "admin"
23
+ # card.to_h # => { id: "123", title: "My Card", ... }
24
+ class Object
25
+ include Enumerable
26
+
27
+ # @return [Hash] The raw data from the API response
28
+ attr_reader :data
29
+
30
+ # Creates a new object from a Hash or another SuperthreadObject.
31
+ #
32
+ # @param data [Hash, Superthread::Object] The data to wrap
33
+ def initialize(data = {})
34
+ @data = case data
35
+ when Hash
36
+ data.transform_keys(&:to_sym)
37
+ when Superthread::Object
38
+ data.data.dup
39
+ else
40
+ {}
41
+ end
42
+ end
43
+
44
+ # Factory method to construct the appropriate typed object from API response data.
45
+ # Uses the "type" field in the response to determine the class.
46
+ #
47
+ # @param data [Hash, Array, nil] The API response data
48
+ # @return [Superthread::Object, Array, nil] The constructed object(s)
49
+ def self.construct_from(data)
50
+ case data
51
+ when Array
52
+ data.map { |item| construct_from(item) }
53
+ when Hash
54
+ klass = object_class_for(data)
55
+ klass.new(data)
56
+ else
57
+ data
58
+ end
59
+ end
60
+
61
+ # Determines the appropriate class for the given data based on the "type" field.
62
+ #
63
+ # @param data [Hash] The data hash
64
+ # @return [Class] The class to use for construction
65
+ def self.object_class_for(data)
66
+ type = data[:type] || data["type"]
67
+ @object_types.fetch(type, Superthread::Object)
68
+ end
69
+
70
+ # Registry mapping API "type" values to Ruby classes.
71
+ #
72
+ # Subclasses register themselves here.
73
+ #
74
+ # @note This hash is intentionally mutable for dynamic registration.
75
+ @object_types = {}
76
+
77
+ class << self
78
+ attr_reader :object_types
79
+ end
80
+
81
+ # Registers a subclass for a given type name.
82
+ # Called automatically when OBJECT_NAME is defined on a subclass.
83
+ #
84
+ # @param type_name [String] The API type name
85
+ # @param klass [Class] The class to register
86
+ def self.register_type(type_name, klass)
87
+ @object_types[type_name] = klass
88
+ end
89
+
90
+ # Hook called when a subclass is defined.
91
+ #
92
+ # Automatically registers the subclass if it defines OBJECT_NAME.
93
+ #
94
+ # @param subclass [Class] the subclass being defined
95
+ # @return [void]
96
+ def self.inherited(subclass)
97
+ super
98
+ subclass.instance_eval do
99
+ def self.object_name
100
+ const_defined?(:OBJECT_NAME, false) ? const_get(:OBJECT_NAME) : nil
101
+ end
102
+ end
103
+ end
104
+
105
+ # Access a value by key (symbol or string).
106
+ #
107
+ # @param key [Symbol, String] The key to access
108
+ # @return [Object] The value, wrapped in a SuperthreadObject if it's a Hash
109
+ def [](key)
110
+ value = @data[key.to_sym]
111
+ wrap_value(value)
112
+ end
113
+
114
+ # Set a value by key.
115
+ #
116
+ # @param key [Symbol, String] The key to set
117
+ # @param value [Object] The value to set
118
+ def []=(key, value)
119
+ @data[key.to_sym] = value
120
+ end
121
+
122
+ # Iterate over key-value pairs.
123
+ #
124
+ # @param block [Proc] receives key-value pairs for each attribute
125
+ # @yieldparam key [Symbol] the attribute key
126
+ # @yieldparam value [Object] the attribute value (wrapped if Hash)
127
+ # @return [void]
128
+ def each(&block)
129
+ @data.each do |key, value|
130
+ block.call(key, wrap_value(value))
131
+ end
132
+ end
133
+
134
+ # Returns all keys.
135
+ #
136
+ # @return [Array<Symbol>] The keys
137
+ def keys
138
+ @data.keys
139
+ end
140
+
141
+ # Returns all values (wrapped).
142
+ #
143
+ # @return [Array] The values
144
+ def values
145
+ @data.values.map { |v| wrap_value(v) }
146
+ end
147
+
148
+ # Check if a key exists.
149
+ #
150
+ # @param key [Symbol, String] The key to check
151
+ # @return [Boolean] True if the key exists
152
+ def key?(key)
153
+ @data.key?(key.to_sym)
154
+ end
155
+ alias_method :has_key?, :key?
156
+
157
+ # Convert to a plain Hash (deep conversion).
158
+ #
159
+ # @return [Hash] A plain hash representation
160
+ def to_h
161
+ @data.transform_values do |value|
162
+ case value
163
+ when Superthread::Object
164
+ value.to_h
165
+ when Array
166
+ value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
167
+ when Hash
168
+ value.transform_values { |v| v.respond_to?(:to_h) ? v.to_h : v }
169
+ else
170
+ value
171
+ end
172
+ end
173
+ end
174
+ alias_method :to_hash, :to_h
175
+
176
+ # Convert to JSON string.
177
+ #
178
+ # @param args [Array] Arguments passed to JSON.generate
179
+ # @return [String] JSON representation
180
+ def to_json(*args)
181
+ to_h.to_json(*args)
182
+ end
183
+
184
+ # Check equality based on data.
185
+ #
186
+ # @param other [Object] The object to compare
187
+ # @return [Boolean] True if equal
188
+ def ==(other)
189
+ case other
190
+ when Superthread::Object
191
+ @data == other.data
192
+ when Hash
193
+ @data == other.transform_keys(&:to_sym)
194
+ else
195
+ false
196
+ end
197
+ end
198
+ alias_method :eql?, :==
199
+
200
+ # Hash code for use in Hash keys.
201
+ #
202
+ # @return [Integer] Hash code
203
+ def hash
204
+ @data.hash
205
+ end
206
+
207
+ # String representation for debugging.
208
+ #
209
+ # @return [String] Debug representation
210
+ def inspect
211
+ "#<#{self.class.name} #{@data.inspect}>"
212
+ end
213
+
214
+ # Pretty string representation.
215
+ #
216
+ # @return [String] String representation
217
+ def to_s
218
+ inspect
219
+ end
220
+
221
+ # Check if the object has a given attribute.
222
+ # Supports predicate methods like `archived?`.
223
+ #
224
+ # @param method_name [Symbol] The method name
225
+ # @param include_private [Boolean] Whether to include private methods
226
+ # @return [Boolean] True if the method exists
227
+ def respond_to_missing?(method_name, include_private = false)
228
+ name = method_name.to_s
229
+ if name.end_with?("=")
230
+ true
231
+ elsif name.end_with?("?")
232
+ @data.key?(name.chomp("?").to_sym)
233
+ else
234
+ @data.key?(name.to_sym) || super
235
+ end
236
+ end
237
+
238
+ private
239
+
240
+ # Dynamic attribute access via method_missing.
241
+ #
242
+ # Supports:
243
+ # - Getters: card.title
244
+ # - Setters: card.title = "New Title"
245
+ # - Predicates: card.archived?
246
+ #
247
+ # @param method_name [Symbol] the method being called
248
+ # @param args [Array] arguments passed to the method
249
+ # @return [Object] the attribute value or result of assignment
250
+ def method_missing(method_name, *args)
251
+ name = method_name.to_s
252
+
253
+ if name.end_with?("=")
254
+ # Setter: card.title = "New Title"
255
+ key = name.chomp("=").to_sym
256
+ @data[key] = args.first
257
+ elsif name.end_with?("?")
258
+ # Predicate: card.archived?
259
+ key = name.chomp("?").to_sym
260
+ !!@data[key]
261
+ elsif @data.key?(name.to_sym)
262
+ # Getter: card.title
263
+ wrap_value(@data[name.to_sym])
264
+ else
265
+ super
266
+ end
267
+ end
268
+
269
+ # Wraps values in SuperthreadObject instances for nested access.
270
+ # Arrays of Hashes become Arrays of SuperthreadObjects.
271
+ #
272
+ # @param value [Object] The value to wrap
273
+ # @return [Object] The wrapped value
274
+ def wrap_value(value)
275
+ case value
276
+ when Hash
277
+ Superthread::Object.construct_from(value)
278
+ when Array
279
+ value.map { |v| wrap_value(v) }
280
+ else
281
+ value
282
+ end
283
+ end
284
+ end
285
+ end