leash-sdk 0.3.0 → 0.4.0

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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Leash
6
+ module Integrations
7
+ # `leash.integrations.linear` — mirrors TS `leash.integrations.linear`.
8
+ #
9
+ # The Linear MCP uses underscored action names on the wire (`list_issues`,
10
+ # `get_issue`, …) — preserved here. List responses can come back as either
11
+ # a bare array or an envelope hash; the SDK tolerates both shapes.
12
+ class Linear < Base
13
+ PROVIDER = "linear"
14
+
15
+ def list_issues(team_id: nil, assignee_id: nil, state_type: nil,
16
+ limit: nil, cursor: nil)
17
+ params = compact_params(
18
+ "teamId" => team_id,
19
+ "assigneeId" => assignee_id,
20
+ "stateType" => state_type,
21
+ "limit" => limit,
22
+ "cursor" => cursor
23
+ )
24
+
25
+ raw = call("list_issues", params)
26
+ case raw
27
+ when Array
28
+ { "issues" => raw }
29
+ when Hash
30
+ out = { "issues" => raw["issues"] || [] }
31
+ out["cursor"] = raw["cursor"] if raw["cursor"]
32
+ out
33
+ else
34
+ { "issues" => [] }
35
+ end
36
+ end
37
+
38
+ def get_issue(id)
39
+ call("get_issue", { "id" => id })
40
+ end
41
+
42
+ def create_issue(team_id:, title:, description: nil, assignee_id: nil,
43
+ priority: nil, label_ids: nil)
44
+ params = { "teamId" => team_id, "title" => title }
45
+ params["description"] = description unless description.nil?
46
+ params["assigneeId"] = assignee_id unless assignee_id.nil?
47
+ params["priority"] = priority unless priority.nil?
48
+ params["labelIds"] = label_ids unless label_ids.nil?
49
+ call("create_issue", params)
50
+ end
51
+
52
+ def update_issue(id, title: nil, description: nil, assignee_id: nil,
53
+ priority: nil, label_ids: nil, team_id: nil)
54
+ params = { "id" => id }
55
+ params["title"] = title unless title.nil?
56
+ params["description"] = description unless description.nil?
57
+ params["assigneeId"] = assignee_id unless assignee_id.nil?
58
+ params["priority"] = priority unless priority.nil?
59
+ params["labelIds"] = label_ids unless label_ids.nil?
60
+ params["teamId"] = team_id unless team_id.nil?
61
+ call("update_issue", params)
62
+ end
63
+
64
+ def add_comment(issue_id, body)
65
+ call("add_comment", { "issueId" => issue_id, "body" => body })
66
+ end
67
+
68
+ def list_teams
69
+ raw = call("list_teams", {})
70
+ case raw
71
+ when Array then raw
72
+ when Hash then raw["teams"] || []
73
+ else []
74
+ end
75
+ end
76
+
77
+ def list_projects(team_id: nil)
78
+ params = compact_params("teamId" => team_id)
79
+ raw = call("list_projects", params)
80
+ case raw
81
+ when Array then raw
82
+ when Hash then raw["projects"] || []
83
+ else []
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,258 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "json"
5
- require "uri"
6
-
7
- require_relative "errors"
8
- require_relative "custom_integration"
9
- require_relative "gmail"
10
- require_relative "calendar"
11
- require_relative "drive"
3
+ require_relative "integrations/base"
4
+ require_relative "integrations/gmail"
5
+ require_relative "integrations/calendar"
6
+ require_relative "integrations/drive"
7
+ require_relative "integrations/linear"
12
8
 
13
9
  module Leash
14
- DEFAULT_PLATFORM_URL = "https://leash.build"
15
-
16
- # Main client for accessing Leash platform integrations.
17
- #
18
- # @example
19
- # client = Leash::Integrations.new(auth_token: "your-jwt-token")
20
- # messages = client.gmail.list_messages(query: "is:unread")
21
- # events = client.calendar.list_events(time_min: "2026-04-10T00:00:00Z")
22
- # files = client.drive.list_files
23
- class Integrations
24
- # @param auth_token [String] the leash-auth JWT token
25
- # @param platform_url [String] base URL of the Leash platform API
26
- # @param api_key [String, nil] optional API key for server-to-server auth
27
- def initialize(auth_token:, platform_url: DEFAULT_PLATFORM_URL, api_key: nil)
28
- @auth_token = auth_token
29
- @platform_url = platform_url.chomp("/")
30
- @api_key = api_key || ENV["LEASH_API_KEY"]
31
- end
32
-
33
- # Gmail integration client.
34
- #
35
- # @return [GmailClient]
36
- def gmail
37
- @gmail ||= GmailClient.new(method(:call))
38
- end
39
-
40
- # Google Calendar integration client.
41
- #
42
- # @return [CalendarClient]
43
- def calendar
44
- @calendar ||= CalendarClient.new(method(:call))
45
- end
46
-
47
- # Google Drive integration client.
48
- #
49
- # @return [DriveClient]
50
- def drive
51
- @drive ||= DriveClient.new(method(:call))
52
- end
53
-
54
- # Access a custom integration by name. Returns an untyped client.
55
- #
56
- # @param name [String] the custom integration name
57
- # @return [CustomIntegration]
58
- #
59
- # @example
60
- # stripe = client.integration("stripe")
61
- # charges = stripe.call("/v1/charges", method: "GET")
62
- def integration(name)
63
- CustomIntegration.new(name, method(:call_custom))
64
- end
65
-
66
- # Generic proxy call for any provider action.
67
- #
68
- # @param provider [String] integration provider name (e.g. "gmail")
69
- # @param action [String] action to perform (e.g. "list-messages")
70
- # @param params [Hash, nil] optional request body parameters
71
- # @return [Object] the "data" field from the platform response
72
- # @raise [Leash::NotConnectedError] if the provider is not connected
73
- # @raise [Leash::TokenExpiredError] if the OAuth token has expired
74
- # @raise [Leash::Error] if the platform returns a non-success response
75
- def call(provider, action, params = nil)
76
- uri = URI("#{@platform_url}/api/integrations/#{provider}/#{action}")
77
-
78
- request = Net::HTTP::Post.new(uri)
79
- request["Content-Type"] = "application/json"
80
- request["Authorization"] = "Bearer #{@auth_token}"
81
- request["X-API-Key"] = @api_key if @api_key
82
- request.body = (params || {}).to_json
83
-
84
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
85
- http.request(request)
86
- end
87
-
88
- data = JSON.parse(response.body)
89
-
90
- unless data["success"]
91
- raise_error(data)
92
- end
93
-
94
- data["data"]
95
- end
96
-
97
- # Check if a provider is connected for the current user.
98
- #
99
- # @param provider_id [String] the provider identifier (e.g. "gmail")
100
- # @return [Boolean]
101
- def connected?(provider_id)
102
- conn = connections.find { |c| c["providerId"] == provider_id }
103
- conn&.dig("status") == "active"
104
- rescue StandardError
105
- false
106
- end
107
-
108
- # Get connection status for all providers.
109
- #
110
- # @return [Array<Hash>] list of connection status hashes
111
- def connections
112
- uri = URI("#{@platform_url}/api/integrations/connections")
113
-
114
- request = Net::HTTP::Get.new(uri)
115
- request["Authorization"] = "Bearer #{@auth_token}"
116
- request["X-API-Key"] = @api_key if @api_key
117
-
118
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
119
- http.request(request)
120
- end
121
-
122
- data = JSON.parse(response.body)
123
-
124
- unless data["success"]
125
- raise_error(data)
126
- end
127
-
128
- data["data"] || []
129
- end
130
-
131
- # Get the URL to connect a provider (for UI buttons).
132
- #
133
- # @param provider_id [String] the provider identifier
134
- # @param return_url [String, nil] optional URL to redirect back to after connecting
135
- # @return [String] the full URL to initiate the OAuth connection flow
136
- def connect_url(provider_id, return_url: nil)
137
- url = "#{@platform_url}/api/integrations/connect/#{provider_id}"
138
- url += "?return_url=#{URI.encode_www_form_component(return_url)}" if return_url
139
- url
140
- end
141
-
142
- # Call any MCP server tool directly.
143
- #
144
- # @param package_name [String] the npm package name of the MCP server
145
- # @param tool [String] the tool name to invoke
146
- # @param args [Hash] optional arguments to pass to the tool
147
- # @return [Object] the "data" field from the platform response
148
- # @raise [Leash::Error] if the platform returns a non-success response
149
- def mcp(package_name, tool, args = {})
150
- uri = URI("#{@platform_url}/api/mcp/run")
151
-
152
- request = Net::HTTP::Post.new(uri)
153
- request["Content-Type"] = "application/json"
154
- request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
155
- request["X-API-Key"] = @api_key if @api_key
156
-
157
- payload = { package: package_name, tool: tool }
158
- payload[:args] = args unless args.nil? || args.empty?
159
- request.body = payload.to_json
160
-
161
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
162
- http.request(request)
163
- end
164
-
165
- data = JSON.parse(response.body)
166
-
167
- unless data["success"]
168
- raise_error(data)
169
- end
170
-
171
- data["data"]
172
- end
173
-
174
- # Fetch env vars from the platform. Cached after first call.
175
- #
176
- # @param key [String, nil] optional key to look up
177
- # @return [Hash, String, nil] all env vars as a Hash, or a single value if key given
178
- # @raise [Leash::Error] if the platform returns a non-success response
179
- def get_env(key = nil)
180
- @env_cache ||= begin
181
- uri = URI("#{@platform_url}/api/apps/env")
182
-
183
- request = Net::HTTP::Get.new(uri)
184
- request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
185
- request["X-API-Key"] = @api_key if @api_key
186
-
187
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
188
- http.request(request)
189
- end
190
-
191
- data = JSON.parse(response.body)
192
-
193
- unless data["success"]
194
- raise_error(data)
195
- end
196
-
197
- data["data"] || {}
198
- end
199
-
200
- key ? @env_cache[key] : @env_cache
201
- end
202
-
203
- private
204
-
205
- # Call the custom integration proxy endpoint.
206
- #
207
- # @param name [String] the custom integration name
208
- # @param path [String] the endpoint path to forward
209
- # @param method [String] HTTP method
210
- # @param body [Hash, nil] optional JSON body to forward
211
- # @param headers [Hash, nil] optional extra headers to forward
212
- # @return [Object] the "data" field from the platform response
213
- # @raise [Leash::Error] if the platform returns a non-success response
214
- def call_custom(name, path, method = "GET", body = nil, headers = nil)
215
- uri = URI("#{@platform_url}/api/integrations/custom/#{name}")
216
-
217
- payload = { path: path, method: method }
218
- payload[:body] = body if body
219
- payload[:headers] = headers if headers
220
-
221
- request = Net::HTTP::Post.new(uri)
222
- request["Content-Type"] = "application/json"
223
- request["Authorization"] = "Bearer #{@auth_token}"
224
- request["X-API-Key"] = @api_key if @api_key
225
- request.body = payload.to_json
226
-
227
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
228
- http.request(request)
229
- end
230
-
231
- data = JSON.parse(response.body)
232
-
233
- unless data["success"]
234
- raise_error(data)
235
- end
236
-
237
- data["data"]
238
- end
239
-
240
- # Map error codes to specific exception classes.
241
- #
242
- # @param data [Hash] the parsed error response
243
- # @raise [Leash::NotConnectedError, Leash::TokenExpiredError, Leash::Error]
244
- def raise_error(data)
245
- message = data["error"] || "Unknown error"
246
- code = data["code"]
247
- connect_url = data["connectUrl"]
248
-
249
- case code
250
- when "not_connected"
251
- raise NotConnectedError.new(message, connect_url: connect_url)
252
- when "token_expired"
253
- raise TokenExpiredError.new(message, connect_url: connect_url)
254
- else
255
- raise Error.new(message, code: code, connect_url: connect_url)
10
+ module Integrations
11
+ # `leash.integrations` — typed provider namespaces plus a generic
12
+ # `.provider(name)` escape hatch for un-typed providers (Slack, GitHub,
13
+ # HubSpot, Jira, …). Mirrors the TS `leash.integrations` namespace and
14
+ # the Python `IntegrationsNamespace`.
15
+ #
16
+ # Method aliases keep the TS provider names addressable:
17
+ #
18
+ # leash.integrations.calendar # canonical (matches TS shape)
19
+ # leash.integrations.google_calendar # alias for calendar
20
+ # leash.integrations.drive # canonical
21
+ # leash.integrations.google_drive # alias for drive
22
+ class Namespace
23
+ def initialize(transport)
24
+ @transport = transport
25
+ @gmail = Gmail.new(transport)
26
+ @calendar = Calendar.new(transport)
27
+ @drive = Drive.new(transport)
28
+ @linear = Linear.new(transport)
29
+ end
30
+
31
+ attr_reader :gmail, :calendar, :drive, :linear
32
+
33
+ # Aliases matching the Google-prefixed provider IDs (and platform
34
+ # docs that use them). Same instances as `calendar` / `drive`.
35
+ alias google_calendar calendar
36
+ alias google_drive drive
37
+
38
+ # Generic escape hatch — `leash.integrations.provider('slack').call(...)`.
39
+ def provider(name)
40
+ Caller.new(@transport, name)
256
41
  end
257
42
  end
258
43
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+
9
+ module Leash
10
+ # @api private
11
+ #
12
+ # Shared HTTP transport used by every integration POST. Mirrors the
13
+ # `_call` / `_post` private methods on the TS `Leash` class and the
14
+ # Python `_Transport`.
15
+ #
16
+ # Critical platform contract (Critical #1 in the 0.4 plan):
17
+ #
18
+ # * `X-API-Key` carries the app key (`LEASH_API_KEY`)
19
+ # * `Cookie: leash-auth=…` forwards the browser session
20
+ #
21
+ # The user JWT extracted from an inbound `Authorization: Bearer …` header is
22
+ # intentionally NOT forwarded — the TS SDK warns that the JWT path causes
23
+ # the platform's `verifyToken()` to reject before the X-API-Key check runs,
24
+ # producing a misleading 401 on every integration call. Bearer tokens are
25
+ # still accepted by `Leash` for other code paths (env.get fallback, future
26
+ # CLI flows) but never sent on integration POSTs. A negative-assertion test
27
+ # covers this.
28
+ class Transport
29
+ DEFAULT_OPEN_TIMEOUT = 10
30
+ DEFAULT_READ_TIMEOUT = 30
31
+
32
+ attr_reader :platform_url, :api_key, :cookie_value
33
+
34
+ def initialize(platform_url:, api_key: nil, cookie_value: nil,
35
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
36
+ read_timeout: DEFAULT_READ_TIMEOUT,
37
+ http_runner: nil)
38
+ @platform_url = platform_url.to_s.sub(%r{/+\z}, "")
39
+ @api_key = api_key
40
+ @cookie_value = cookie_value
41
+ @open_timeout = open_timeout
42
+ @read_timeout = read_timeout
43
+ # Test seam — allow injecting a custom HTTP runner block.
44
+ # If nil, calls go through `Net::HTTP.start`.
45
+ @http_runner = http_runner
46
+ end
47
+
48
+ # POST to `/api/integrations/{provider}/{action}` and return the parsed
49
+ # response body (after unwrapping `{success, data}`).
50
+ def call(provider, action, params = nil)
51
+ url = "#{@platform_url}/api/integrations/#{provider}/#{action}"
52
+ docs_url = "https://leash.build/docs/integrations/#{provider}"
53
+ post_json(url, params, docs_url: docs_url)
54
+ end
55
+
56
+ # @api private — used by env.rb for GET /api/apps/me/secrets/<key>.
57
+ def get_json(url, headers: {})
58
+ run_request(url, method: :get, headers: headers, body: nil)
59
+ end
60
+
61
+ # @api private — used by env.rb for arbitrary URLs.
62
+ def post_json(url, params, docs_url:)
63
+ headers = { "Content-Type" => "application/json" }
64
+ headers["X-API-Key"] = @api_key if @api_key
65
+ headers["Cookie"] = "leash-auth=#{@cookie_value}" if @cookie_value
66
+
67
+ body = (params.nil? ? {} : params).to_json
68
+ response = run_request(url, method: :post, headers: headers, body: body)
69
+
70
+ handle_response(response, docs_url: docs_url)
71
+ end
72
+
73
+ # @api private
74
+ def run_request(url, method:, headers:, body:)
75
+ uri = URI.parse(url)
76
+ request = build_request(uri, method: method, headers: headers, body: body)
77
+
78
+ if @http_runner
79
+ return @http_runner.call(uri, request)
80
+ end
81
+
82
+ use_ssl = uri.scheme == "https"
83
+ opts = { use_ssl: use_ssl, open_timeout: @open_timeout, read_timeout: @read_timeout }
84
+
85
+ Net::HTTP.start(uri.host, uri.port, **opts) do |http|
86
+ http.request(request)
87
+ end
88
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
89
+ raise NetworkError.new("Timed out reaching the Leash platform: #{e.message}",
90
+ action: "Check your network connection and that the Leash platform is reachable.",
91
+ see_also: "https://leash.build/docs/sdk",
92
+ cause: e)
93
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, IOError => e
94
+ raise NetworkError.new(e.message,
95
+ action: "Check your network connection and that the Leash platform is reachable.",
96
+ see_also: "https://leash.build/docs/sdk",
97
+ cause: e)
98
+ end
99
+
100
+ # @api private
101
+ def build_request(uri, method:, headers:, body:)
102
+ request =
103
+ case method
104
+ when :post then Net::HTTP::Post.new(uri.request_uri)
105
+ when :get then Net::HTTP::Get.new(uri.request_uri)
106
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
107
+ end
108
+
109
+ headers.each { |k, v| request[k] = v }
110
+ request.body = body if body
111
+
112
+ request
113
+ end
114
+
115
+ # @api private
116
+ def handle_response(response, docs_url:)
117
+ status = response.respond_to?(:code) ? response.code.to_i : 0
118
+ body_raw = response.respond_to?(:body) ? response.body : nil
119
+
120
+ parsed = parse_json(body_raw)
121
+
122
+ if status >= 400
123
+ raise_for_status(status, parsed, docs_url: docs_url)
124
+ end
125
+
126
+ if parsed.is_a?(Hash)
127
+ # Platform contract: { success, data } envelope OR raw shape.
128
+ if parsed["success"] == false
129
+ err_message = parsed["error"].is_a?(String) ? parsed["error"] : "Integration error"
130
+ err_code = parsed["code"].is_a?(String) ? parsed["code"] : "INTEGRATION_ERROR"
131
+ raise Error.new(err_message,
132
+ code: err_code,
133
+ action: "Check your integration configuration and try again.",
134
+ see_also: docs_url,
135
+ status: status,
136
+ connect_url: parsed["connectUrl"])
137
+ end
138
+ return parsed["data"] if parsed.key?("data")
139
+
140
+ return parsed
141
+ end
142
+
143
+ parsed
144
+ end
145
+
146
+ # @api private
147
+ def parse_json(body)
148
+ return nil if body.nil? || body.empty?
149
+
150
+ JSON.parse(body)
151
+ rescue JSON::ParserError
152
+ nil
153
+ end
154
+
155
+ # @api private
156
+ def raise_for_status(status, body, docs_url:)
157
+ message = "HTTP #{status}"
158
+ if body.is_a?(Hash) && body["error"].is_a?(String)
159
+ message = body["error"]
160
+ end
161
+
162
+ case status
163
+ when 401
164
+ raise UnauthorizedError.new(message,
165
+ action: "Ensure the leash-auth cookie is present, or open your app from the Leash dashboard to get a valid session.",
166
+ see_also: "https://leash.build/docs/sdk",
167
+ status: status)
168
+ when 402
169
+ msg = (body.is_a?(Hash) && body["message"].is_a?(String) ? body["message"] : message)
170
+ raise UpgradeRequiredError.new(msg,
171
+ action: "Upgrade your plan at https://leash.build/dashboard/billing.",
172
+ see_also: "https://leash.build/pricing",
173
+ status: status)
174
+ when 403
175
+ raise ConnectionRequiredError.new(message,
176
+ action: "Connect the integration at /dashboard/integrations and make sure this app is on the allow-list.",
177
+ see_also: "https://leash.build/dashboard/integrations",
178
+ status: status)
179
+ else
180
+ raise Error.new(message,
181
+ code: "INTEGRATION_ERROR",
182
+ action: "Check your integration configuration and try again — the upstream provider returned an error.",
183
+ see_also: docs_url,
184
+ status: status)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leash
4
+ # Authenticated Leash user.
5
+ #
6
+ # Mirrors the TS `LeashUser` interface — JSON fields use camelCase on the
7
+ # wire (`id`, `email`, `name`, `picture`), and on the Ruby side `picture` is
8
+ # optional. Two `LeashUser`s are equal when every attribute matches.
9
+ class User
10
+ attr_reader :id, :email, :name, :picture
11
+
12
+ def initialize(id:, email:, name: nil, picture: nil)
13
+ @id = id
14
+ @email = email
15
+ @name = name
16
+ @picture = picture
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(User) &&
21
+ id == other.id &&
22
+ email == other.email &&
23
+ name == other.name &&
24
+ picture == other.picture
25
+ end
26
+
27
+ alias eql? ==
28
+
29
+ def hash
30
+ [self.class, id, email, name, picture].hash
31
+ end
32
+
33
+ def to_h
34
+ h = { id: id, email: email, name: name }
35
+ h[:picture] = picture if picture
36
+ h
37
+ end
38
+ end
39
+
40
+ # Older alias — readers reaching for `Leash::LeashUser` get the same class.
41
+ LeashUser = User
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leash
4
+ VERSION = "0.4.0"
5
+ end
data/lib/leash.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "leash/integrations"
3
+ require_relative "leash/version"
4
+ require_relative "leash/errors"
5
+ require_relative "leash/types"
4
6
  require_relative "leash/auth"
5
-
6
- module Leash
7
- VERSION = "0.3.0"
8
- end
7
+ require_relative "leash/transport"
8
+ require_relative "leash/env"
9
+ require_relative "leash/integrations"
10
+ require_relative "leash/client"