opencode-ruby 0.0.1.alpha2

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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ # The canonical observer protocol for Opencode::Reply — every event
5
+ # Reply dispatches, documented in one place, with safe no-op defaults.
6
+ #
7
+ # Include this module in a reply-stream class to get two things:
8
+ #
9
+ # 1. **Compile-time checklist.** Override only the callbacks you care
10
+ # about; the rest inherit a no-op. Forgetting to handle a new event
11
+ # never crashes the stream.
12
+ # 2. **Protocol documentation that can't rot.** The signatures here are
13
+ # the contract. If Reply's dispatch shape ever drifts, every observer
14
+ # using this module updates in lockstep.
15
+ #
16
+ # Callbacks are duck-typed in Reply — features may choose not to
17
+ # include this module and implement the methods directly, but then
18
+ # they lose the two benefits above.
19
+ #
20
+ # Every callback takes keyword arguments, so adding a new keyword later
21
+ # only requires existing observers to add `**_` if they want to opt out
22
+ # of breakage.
23
+ module ReplyObserver
24
+ # A new part was appended to the reply's parts list.
25
+ def part_added(part:, index:)
26
+ end
27
+
28
+ # An existing part's content grew by a delta (streaming text or
29
+ # reasoning).
30
+ def part_changed(part:, index:, delta:)
31
+ end
32
+
33
+ # An existing part's content was rewritten to the authoritative
34
+ # value from part.updated. Fires unconditionally when a part closes
35
+ # so throttled observers can flush, regardless of whether content
36
+ # actually diverged from what deltas accumulated.
37
+ def part_finalized(part:, index:)
38
+ end
39
+
40
+ # A tool part transitioned status (pending → running → completed/error),
41
+ # or its state payload (title/input/error) changed.
42
+ def tool_progressed(part:, index:, status:, raw:)
43
+ end
44
+
45
+ # A step boundary with usage info. `tokens` is the raw tokens hash
46
+ # from the step-finish part (keys: :input, :output, :reasoning, :cache).
47
+ def step_finished(cost:, tokens:)
48
+ end
49
+
50
+ # The upstream session is retrying an LLM call (e.g., provider
51
+ # rate-limit backoff). Attempt is nullable; message is a short
52
+ # reason string.
53
+ def session_retried(attempt:, message:)
54
+ end
55
+
56
+ # A session-level error surfaced. Text is a human-readable summary
57
+ # ("ErrorName: details"); raw is the full error hash.
58
+ def session_errored(text:, raw:)
59
+ end
60
+
61
+ # The authoritative message.info was updated (cost, tokens, provider
62
+ # error metadata). Fires late in the stream after the agent closes.
63
+ def message_updated(info:)
64
+ end
65
+
66
+ # Agent's internal todo list changed. Todos are whatever shape the
67
+ # agent's task tool uses.
68
+ def todos_changed(todos:)
69
+ end
70
+
71
+ # opencode emitted a question.asked event — the agent's `question`
72
+ # tool is suspended waiting for the user's reply. `request` is the
73
+ # full QuestionRequest hash ({id, sessionID, questions, tool?}).
74
+ def question_asked(request:, raw:)
75
+ end
76
+
77
+ # opencode emitted a question.replied event — the user submitted
78
+ # answers (Array<Array<String>>, one inner array per question).
79
+ # `asked_at` is the monotonic clock value when question.asked was
80
+ # observed, for latency telemetry; nil if asked never arrived.
81
+ def question_replied(request_id:, answers:, raw:, asked_at:)
82
+ end
83
+
84
+ # opencode emitted a question.rejected event — the user dismissed
85
+ # the prompt, or it was cancelled (e.g., container shutdown).
86
+ def question_rejected(request_id:, raw:, asked_at:)
87
+ end
88
+
89
+ # opencode emitted a permission.asked event — a tool is requesting
90
+ # user permission to proceed. `request` is the PermissionRequest
91
+ # hash ({id, sessionID, permission, patterns, metadata, always, tool?}).
92
+ def permission_asked(request:, raw:)
93
+ end
94
+
95
+ # opencode emitted a permission.replied event — the user chose
96
+ # once/always/reject. `reply` is the string. `asked_at` per
97
+ # question_replied semantics.
98
+ def permission_replied(request_id:, reply:, raw:, asked_at:)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ module ResponseParser
5
+ def self.extract_text(response_body)
6
+ parts = response_body[:parts] || []
7
+ parts
8
+ .select { |p| p[:type] == "text" }
9
+ .map { |p| p[:text] }
10
+ .join("\n\n")
11
+ end
12
+
13
+ def self.extract_reasoning(response_body)
14
+ parts = response_body[:parts] || []
15
+ reasoning = parts
16
+ .select { |p| p[:type] == "reasoning" }
17
+ .map { |p| p[:text] }
18
+ .join("\n\n")
19
+ reasoning.presence
20
+ end
21
+
22
+ TERMINAL_STATUSES = %w[completed error].freeze
23
+
24
+ # Terminal-only tool list. Returned as canonical string-keyed hashes
25
+ # (same shape `extract_interleaved_parts` returns) so callers do not
26
+ # have to know which path produced the data.
27
+ def self.extract_tool_summary(response_body)
28
+ parts = response_body[:parts] || []
29
+ parts
30
+ .select { |p| p[:type] == "tool" && p.dig(:state, :status).in?(TERMINAL_STATUSES) }
31
+ .map { |p| build_tool_summary(p) }
32
+ end
33
+
34
+ def self.extract_interleaved_parts(response_body)
35
+ parts = response_body[:parts] || []
36
+
37
+ parts.filter_map do |part|
38
+ case part[:type]
39
+ when "text"
40
+ { "type" => "text", "content" => part[:text] }
41
+ when "reasoning"
42
+ { "type" => "reasoning", "content" => part[:text] }
43
+ when "tool"
44
+ status = part.dig(:state, :status)
45
+ next unless status.in?(TERMINAL_STATUSES)
46
+
47
+ build_tool_summary(part)
48
+ else
49
+ nil
50
+ end
51
+ end
52
+ end
53
+
54
+ # Canonical tool-part shape from one OpenCode message part. Delegates
55
+ # to Opencode::ToolPart so the streaming path (Reply#apply_tool_state)
56
+ # and recovery path (this method) cannot drift.
57
+ def self.build_tool_summary(part)
58
+ Opencode::ToolPart.from_message_part(part)
59
+ end
60
+
61
+ private_class_method :build_tool_summary
62
+
63
+ def self.extract_tokens(response_body)
64
+ response_body.dig(:info, :tokens)
65
+ end
66
+
67
+ def self.extract_cost(response_body)
68
+ response_body.dig(:info, :cost)
69
+ end
70
+
71
+ def self.extract_cache_tokens(response_body)
72
+ tokens = response_body.dig(:info, :tokens) || {}
73
+ {
74
+ cache_read: tokens.dig(:cache, :read) || 0,
75
+ cache_write: tokens.dig(:cache, :write) || 0
76
+ }
77
+ end
78
+
79
+ def self.extract_error(response_body)
80
+ error = response_body.dig(:info, :error)
81
+ return nil unless error.is_a?(Hash)
82
+
83
+ {
84
+ name: error[:name],
85
+ message: error.dig(:data, :message),
86
+ status_code: error.dig(:data, :statusCode),
87
+ retryable: error.dig(:data, :isRetryable),
88
+ url: error.dig(:data, :metadata, :url)
89
+ }.compact
90
+ end
91
+
92
+ MAX_ARTIFACT_SIZE = 10.megabytes
93
+ ARTIFACT_TOOLS = %w[write apply_patch].freeze
94
+
95
+ def self.extract_artifact_files(response_body)
96
+ parts = response_body[:parts] || []
97
+ completed_tools = parts.select do |p|
98
+ p[:type] == "tool" &&
99
+ ARTIFACT_TOOLS.include?(p[:tool]) &&
100
+ p.dig(:state, :status) == "completed"
101
+ end
102
+ return [] if completed_tools.empty?
103
+
104
+ files = completed_tools.flat_map { |part| extract_files_from_tool_part(part) }
105
+ files.uniq { |f| f[:filename] }
106
+ end
107
+
108
+ def self.extract_artifacts_from_messages(messages)
109
+ return [] unless messages.is_a?(Array)
110
+
111
+ messages
112
+ .select { |m| m.dig(:info, :role) == "assistant" }
113
+ .flat_map { |m| extract_artifact_files(m) }
114
+ .uniq { |f| f[:filename] }
115
+ end
116
+
117
+ def self.extract_files_from_tool_part(part)
118
+ case part[:tool]
119
+ when "write"
120
+ extract_from_write(part)
121
+ when "apply_patch"
122
+ extract_from_apply_patch(part)
123
+ else
124
+ []
125
+ end
126
+ end
127
+
128
+ def self.extract_from_write(part)
129
+ content = part.dig(:state, :input, :content)
130
+ file_path = part.dig(:state, :input, :filePath)
131
+ return [] if content.blank? || file_path.blank?
132
+ return [] if content.bytesize > MAX_ARTIFACT_SIZE
133
+
134
+ filename = File.basename(file_path)
135
+ content_type = Marcel::MimeType.for(extension: File.extname(filename))
136
+ [ { filename: filename, content: content, content_type: content_type } ]
137
+ end
138
+
139
+ # apply_patch tool metadata shape changed materially between the early
140
+ # opencode versions this code originally targeted (which exposed
141
+ # `before` + `after` post-write file content as inline strings) and
142
+ # v1.4.0+ (which dropped them and only exposes the diff text in `patch`
143
+ # plus a `files` array of { filePath, relativePath, type, patch,
144
+ # additions, deletions, movePath? } descriptors). Source of truth:
145
+ # https://raw.githubusercontent.com/anomalyco/opencode/v1.15.0/packages/opencode/src/tool/apply_patch.ts
146
+ #
147
+ # With no `after` field in the v1.15.0 wire shape, this method previously
148
+ # silently returned [] for every real apply_patch invocation while still
149
+ # passing its (now-stale-shape) unit test — the worst kind of bug: a
150
+ # green test paired with a dead production path.
151
+ #
152
+ # Current behavior (intentional, until apply_patch becomes a hot path
153
+ # for the gem's users): we accept the v1.15.0 shape and return []. Most
154
+ # agents write whole files via the `write` tool rather than patching,
155
+ # so the practical impact today is zero. When you do use apply_patch,
156
+ # opencode-rails' `Opencode::Exchange#tool_artifacts` emits
157
+ # `opencode.apply_patch.artifacts_dropped` so operators see the silent
158
+ # drop and can route through the missing sandbox-read path.
159
+ #
160
+ # The event emission lives on Exchange (not here) because ResponseParser
161
+ # is a pure module — every other method takes a hash and returns a hash.
162
+ # Pure functions stay pure.
163
+ def self.extract_from_apply_patch(_part)
164
+ []
165
+ end
166
+
167
+ private_class_method :extract_files_from_tool_part, :extract_from_write, :extract_from_apply_patch
168
+ end
169
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ # One todo item the OpenCode `todowrite` tool and `todo.updated` bus
5
+ # event carry: `content` + `status` + (optional) `priority`.
6
+ # Source-of-truth canonicalization lives here so Reply, ToolDisplay,
7
+ # and any future consumer all share one definition of "what does this
8
+ # todo look like once we've normalized it."
9
+ #
10
+ # Status canonicalization: OpenCode bus events have been observed
11
+ # emitting the hyphenated `"in-progress"` form. The rest of the
12
+ # codebase (per-product views, todowrite tool input shape per the
13
+ # v1.15+ openapi spec) uses the underscored `"in_progress"`.
14
+ # Canonicalize to underscore at every entry point so downstream code
15
+ # never has to handle both.
16
+ module Todo
17
+ HYPHENATED_TO_CANONICAL_STATUS = {
18
+ "in-progress" => "in_progress"
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ def canonical_status(status)
24
+ raw = status.to_s
25
+ HYPHENATED_TO_CANONICAL_STATUS.fetch(raw) { raw.tr("-", "_") }
26
+ end
27
+
28
+ # Canonicalize one todo hash: string-keyed, normalized status.
29
+ # Returns the input unchanged when it isn't a Hash (the substrate
30
+ # tolerates wire-shape drift defensively).
31
+ def canonicalize(todo)
32
+ return todo unless todo.is_a?(Hash)
33
+
34
+ result = todo.deep_stringify_keys
35
+ result["status"] = canonical_status(result["status"]) if result.key?("status")
36
+ result
37
+ end
38
+
39
+ def canonicalize_all(todos)
40
+ Array(todos).map { |t| canonicalize(t) }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ # Canonical shape of a tool part in an assistant reply.
5
+ #
6
+ # A tool part starts `pending` and transitions through `running` to a
7
+ # terminal `completed` or `error`. The complete representation carries
8
+ # seven fields, all string-keyed so views read consistent keys whether
9
+ # the part came from a live streaming event or a post-stream message
10
+ # poll:
11
+ #
12
+ # "type" => "tool"
13
+ # "tool" => "edit"
14
+ # "status" => "completed"
15
+ # "title" => "Edited /INDEX.md"
16
+ # "input" => { ... } # full args the agent passed, deep-stringified
17
+ # "metadata" => { ... } # tool-specific output: diff, preview, stdout, etc.
18
+ # "output" => "Edited successfully."
19
+ # "error" => "..." # only when status == "error", truncated to 200 chars
20
+ #
21
+ # The shape is produced two ways:
22
+ #
23
+ # 1. Opencode::Reply#apply_tool_state — live, mid-stream, merging
24
+ # incoming event state into an in-memory record (previous values
25
+ # survive when the new event omits a field).
26
+ #
27
+ # 2. Opencode::ResponseParser.build_tool_summary — post-stream, built
28
+ # fresh from a complete OpenCode message returned by
29
+ # /session/:id/message during recovery / final-exchange polling.
30
+ #
31
+ # Existence reason: the two paths used to drift. ResponseParser stripped
32
+ # `metadata` and whitelisted `input` to a fixed key list, so `parts_json`
33
+ # saved on finalize had strictly less data than the streaming DOM had
34
+ # shown. The visible symptom was "I saw the diff while streaming and it
35
+ # disappeared when the turn finished". This class is the single source of
36
+ # truth that prevents that drift.
37
+ module ToolPart
38
+ MAX_ERROR_LEN = 200
39
+ INVALID_TOOL = "invalid"
40
+
41
+ module_function
42
+
43
+ # Build a fresh canonical tool-part hash from one OpenCode message
44
+ # part (the shape that arrives through /session/:id/message).
45
+ # Used by ResponseParser for recovery and final-exchange polling.
46
+ def from_message_part(part)
47
+ state = state_of(part)
48
+ build_canonical(
49
+ tool: part[:tool] || part["tool"],
50
+ status: state_value(state, :status),
51
+ title: state_value(state, :title),
52
+ input: state_value(state, :input),
53
+ metadata: state_value(state, :metadata),
54
+ output: state_value(state, :output),
55
+ error: state_value(state, :error)
56
+ )
57
+ end
58
+
59
+ # Merge an incoming `message.part.updated` event state into an
60
+ # existing record. Used by Reply#apply_tool_state during streaming.
61
+ #
62
+ # Fields the event omits (or that arrive empty) leave the record's
63
+ # previous value intact. Mid-tool events are partial by design.
64
+ #
65
+ # In addition to the canonical render fields (status, title, input,
66
+ # metadata, output, error), this also persists `callID` and
67
+ # `messageID` from the incoming state. Those identifiers are needed
68
+ # by downstream lookups (e.g. matching an ask-user reply event back
69
+ # to the originating tool part by callID) and would otherwise be
70
+ # silently dropped on the way into Reply.parts JSON.
71
+ #
72
+ # Returns the (mutated) record for chaining.
73
+ def merge_streaming_state(record, part)
74
+ state = state_of(part)
75
+
76
+ tool = part[:tool] || part["tool"]
77
+ # Preserve original tool name if OpenCode later renames to "invalid"
78
+ # mid-session — we want to keep rendering the original name.
79
+ record["tool"] = tool if tool.present? && tool != INVALID_TOOL
80
+
81
+ status = state_value(state, :status)
82
+ record["status"] = status if status
83
+
84
+ title = state_value(state, :title)
85
+ record["title"] = title if title.present?
86
+
87
+ input = state_value(state, :input)
88
+ record["input"] = stringify_deep(input) if input.present?
89
+
90
+ metadata = state_value(state, :metadata)
91
+ record["metadata"] = stringify_deep(metadata) if metadata.present?
92
+
93
+ output = state_value(state, :output)
94
+ record["output"] = output if output.present?
95
+
96
+ error = state_value(state, :error)
97
+ record["error"] = error.to_s.truncate(MAX_ERROR_LEN) if error.present?
98
+
99
+ # callID and messageID moved from state.* to the part's top level
100
+ # somewhere in opencode v1.15.x. Read top-level first, fall back
101
+ # to state.* for any older versions that may still be in flight.
102
+ # Without this, merge_pending_question_into_existing_tool_part
103
+ # (which searches @parts by callID) silently no-ops, and the
104
+ # question form renders with no questions or routing IDs.
105
+ call_id = part[:callID] || part["callID"] || state_value(state, :callID)
106
+ record["callID"] = call_id if call_id.present?
107
+
108
+ message_id = part[:messageID] || part["messageID"] || state_value(state, :messageID)
109
+ record["messageID"] = message_id if message_id.present?
110
+
111
+ record
112
+ end
113
+
114
+ class << self
115
+ private
116
+
117
+ def state_of(part)
118
+ part[:state] || part["state"] || {}
119
+ end
120
+
121
+ def state_value(state, key)
122
+ return nil unless state.is_a?(Hash)
123
+ state[key] || state[key.to_s]
124
+ end
125
+
126
+ def build_canonical(tool:, status:, title:, input:, metadata:, output:, error:)
127
+ hash = {
128
+ "type" => "tool",
129
+ "tool" => tool.to_s.presence,
130
+ "status" => status,
131
+ "title" => title.presence,
132
+ "input" => stringify_deep(input).presence,
133
+ "metadata" => stringify_deep(metadata).presence,
134
+ "output" => output.presence
135
+ }
136
+ hash["error"] = error.to_s.truncate(MAX_ERROR_LEN).presence if status == "error"
137
+ hash.compact
138
+ end
139
+
140
+ def stringify_deep(value)
141
+ case value
142
+ when Hash
143
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_deep(v) }
144
+ when Array
145
+ value.map { |v| stringify_deep(v) }
146
+ else
147
+ value
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ # A namespacing trace emitter.
5
+ #
6
+ # Opencode::Turn emits unprefixed event names like "response.started"
7
+ # and "session.recreated". The host product wraps Turn in a Tracer
8
+ # whose job is to prepend a product prefix and forward to whatever
9
+ # actually emits trace events (typically the host job's
10
+ # `EventTraceable#trace_event`).
11
+ #
12
+ # Two responsibilities live here, and only here:
13
+ #
14
+ # 1. Callable interface: `tracer.call(name, **payload)` — the
15
+ # contract Turn relies on.
16
+ # 2. Namespacing strategy: prepend "<prefix>." to every event name.
17
+ #
18
+ # A closure-based alternative that mixes both concerns looks like:
19
+ #
20
+ # tracer: ->(name, **payload) { trace_event("myapp.#{name}", **payload) }
21
+ #
22
+ # That closure conflates the two responsibilities; every caller has
23
+ # to rediscover the prefix-with-period rule, and a typo only shows up
24
+ # in production trace data. Making it a real role removes that risk
25
+ # and makes the rule visible in one place.
26
+ #
27
+ # Usage:
28
+ #
29
+ # Opencode::Tracer.new(prefix: "myapp", emitter: self)
30
+ #
31
+ # `emitter` must respond to `trace_event(name, **payload)`.
32
+ class Tracer
33
+ def initialize(prefix:, emitter:)
34
+ @prefix = prefix
35
+ @emitter = emitter
36
+ end
37
+
38
+ # Tracer is callable so existing call sites that treated the tracer
39
+ # as a lambda (`tracer.call(name, **payload)`) keep working without
40
+ # change. Turn uses this exclusively.
41
+ #
42
+ # Uses `send` because EventTraceable's `trace_event` is a private
43
+ # method of the including class — the convention is "private inside
44
+ # the job, but the substrate's Tracer is allowed to dispatch to it
45
+ # the same way the job's own perform method would."
46
+ def call(name, **payload)
47
+ @emitter.send(:trace_event, "#{@prefix}.#{name}", **payload)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ VERSION = "0.0.1.alpha2"
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal ActiveSupport surface — `present?`, `blank?`, `presence`,
4
+ # `truncate`, `duplicable?`. We deliberately load only the core_ext bits
5
+ # we use, not all of activesupport, to keep the boot footprint small in
6
+ # non-Rails apps.
7
+ require "active_support/core_ext/object/blank" # provides blank?, present?, presence
8
+ require "active_support/core_ext/object/duplicable"
9
+ require "active_support/core_ext/string/filters" # provides String#truncate
10
+ require "active_support/core_ext/numeric/bytes" # provides Integer#megabytes
11
+
12
+ require_relative "opencode/version"
13
+ require_relative "opencode/error"
14
+ require_relative "opencode/instrumentation"
15
+ require_relative "opencode/response_parser"
16
+ require_relative "opencode/part_source"
17
+ require_relative "opencode/tool_part"
18
+ require_relative "opencode/todo"
19
+ require_relative "opencode/prompts"
20
+ require_relative "opencode/reply_observer"
21
+ require_relative "opencode/reply"
22
+ require_relative "opencode/tracer"
23
+ require_relative "opencode/client"
24
+
25
+ module Opencode
26
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/opencode/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "opencode-ruby"
7
+ spec.version = Opencode::VERSION
8
+ spec.authors = ["Ajay Krishnan"]
9
+ spec.email = ["opencode-ruby@ajay.to"]
10
+
11
+ spec.summary = "Idiomatic Ruby client for OpenCode (HTTP + SSE)."
12
+ spec.description = <<~DESC
13
+ Hand-rolled, opinionated Ruby SDK for OpenCode's REST + SSE API.
14
+ Block-form streaming, value-object responses, automatic SSE
15
+ reconnection. Complement to opencode_client (auto-generated from
16
+ OpenAPI) — pick this one if you want a small Ruby-idiomatic surface;
17
+ pick opencode_client if you want every endpoint with generated types.
18
+ DESC
19
+ spec.homepage = "https://github.com/ajaynomics/opencode-ruby"
20
+ spec.license = "MIT"
21
+ spec.required_ruby_version = ">= 3.2.0"
22
+
23
+ spec.metadata["homepage_uri"] = spec.homepage
24
+ spec.metadata["source_code_uri"] = spec.homepage
25
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
26
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
27
+
28
+ spec.files = Dir.glob("lib/**/*.rb") +
29
+ Dir.glob("examples/**/*.rb") +
30
+ %w[README.md LICENSE CHANGELOG.md opencode-ruby.gemspec]
31
+ spec.require_paths = ["lib"]
32
+
33
+ # The only runtime dependency is ActiveSupport (NOT Rails). ActiveSupport
34
+ # is a standalone gem providing the `present?`/`blank?`/`presence`/
35
+ # `truncate`/`duplicable?` helpers used in this gem's code. It does NOT
36
+ # pull in ActiveRecord, ActionView, ActionController, Turbo, or any other
37
+ # Rails-only piece. Most Ruby apps in the wild already have ActiveSupport
38
+ # transitively via another gem; in the rare case yours doesn't, ~250 LOC
39
+ # of core_ext is added when this gem installs.
40
+ spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0"
41
+
42
+ spec.add_development_dependency "minitest", "~> 5.20"
43
+ spec.add_development_dependency "rake", "~> 13.0"
44
+ spec.add_development_dependency "webmock", "~> 3.20"
45
+ end