exa-ai-ruby 1.0.0 → 1.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +79 -16
- data/exe/exa +14 -0
- data/lib/exa/cli/account_resolver.rb +53 -0
- data/lib/exa/cli/config_store.rb +97 -0
- data/lib/exa/cli/root.rb +802 -0
- data/lib/exa/cli.rb +5 -0
- data/lib/exa/internal/transport/base_client.rb +49 -9
- data/lib/exa/internal/transport/pooled_net_requester.rb +10 -1
- data/lib/exa/resources/search.rb +21 -1
- data/lib/exa/resources/websets.rb +17 -0
- data/lib/exa/responses/answer_response.rb +77 -0
- data/lib/exa/responses/search_response.rb +15 -2
- data/lib/exa/responses.rb +1 -0
- data/lib/exa/types/answer.rb +1 -0
- data/lib/exa/version.rb +1 -1
- metadata +93 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d63e020a8a8be86a5f10fe3cef3be38eaec07537c1b4d20cfa52ebc6474c483
|
|
4
|
+
data.tar.gz: 5bf845c75eced722a8e3dc0e0bbc4ece8d14b6a74046e685c0f8daefea42ff78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6423df0960415ee47438fbca1c72d015a53fb611f0721671a4d0cba2b7b1c8dff17a71a4e448324f2d42157ae05a4ccf385a351f332ccf91cd40b3a749bfce5
|
|
7
|
+
data.tar.gz: 353057eb46a716e65d1b984aea014f5d80d006bb613242bf41311c4a22d55a47b1483f090e9df711b58c15e38ca351e6cf85a192e9a92191a59010db42c4a645
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.0] - 2025-10-26
|
|
4
|
+
- Add the `exa` CLI entrypoint (installed automatically with the gem) including multi-account credential management and JSON-friendly output helpers.
|
|
5
|
+
- Introduce a secure YAML config store (`~/.config/exa/config.yml`) and CLI commands for `accounts:list`, `accounts:add`, `accounts:use`, and `accounts:remove`.
|
|
6
|
+
- Expand the CLI surface to cover search (run/contents/similar/answer), research (create/list/get/cancel), websets (core, items, enrichments, monitors), imports, events, and webhooks, with shared JSON payload helpers and basic streaming support (`search:answer --stream`, `research:get --stream`), all exercised via new unit + Aruba tests following `docs/cli-plan.md`.
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2025-10-26
|
|
9
|
+
- First stable release of the typed Exa API client.
|
|
10
|
+
- Covers search, research, websets, monitors, imports, events, and webhooks resources based on the OpenAPI spec.
|
|
11
|
+
- Adds Sorbet-backed request/response structs, schema-aware structured output helpers, retrying transport, and streaming utilities.
|
|
12
|
+
- Bundles developer ergonomics: connection pooling, pagination helpers, JSON/SSE streaming, and extensive README docs.
|
|
13
|
+
|
|
3
14
|
## [0.1.0] - 2025-10-25
|
|
4
15
|
- Initial gem scaffolding.
|
data/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Exa Ruby Client
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/exa-ai-ruby)
|
|
4
|
+
[](https://rubygems.org/gems/exa-ai-ruby)
|
|
5
|
+
[](https://github.com/vicentereig/exa-ruby/actions/workflows/ruby.yml)
|
|
6
|
+
|
|
3
7
|
> Typed, Sorbet-friendly Ruby bindings for the Exa API, inspired by `openai-ruby` and aligned with Exa’s OpenAPI specs.
|
|
4
8
|
|
|
5
|
-
This README
|
|
9
|
+
This README is intentionally exhaustive—LLM agents and humans alike should be able to read it and learn how to use or extend the client without digging elsewhere.
|
|
6
10
|
|
|
7
11
|
---
|
|
8
12
|
|
|
@@ -11,15 +15,15 @@ This README doubles as the canonical “llms-full” reference for the project:
|
|
|
11
15
|
1. [Project Goals](#project-goals)
|
|
12
16
|
2. [Environment & Installation](#environment--installation)
|
|
13
17
|
3. [Client Architecture Overview](#client-architecture-overview)
|
|
14
|
-
4. [
|
|
18
|
+
4. [CLI Quickstart](#cli-quickstart)
|
|
19
|
+
5. [Typed Resources & Usage Examples](#typed-resources--usage-examples)
|
|
15
20
|
- [Search stack](#search-stack)
|
|
16
21
|
- [Research](#research)
|
|
17
22
|
- [Websets (core + items + enrichments + monitors)](#websets-core--items--enrichments--monitors)
|
|
18
23
|
- [Events, Imports, Webhooks](#events-imports-webhooks)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
8. [Roadmap / TODOs](#roadmap--todos)
|
|
24
|
+
6. [Structured Output via Sorbet + dspy-schema](#structured-output-via-sorbet--dspy-schema)
|
|
25
|
+
7. [Streaming & Transport Helpers](#streaming--transport-helpers)
|
|
26
|
+
8. [Testing & TDD Plan](#testing--tdd-plan)
|
|
23
27
|
|
|
24
28
|
---
|
|
25
29
|
|
|
@@ -41,11 +45,25 @@ $ git clone https://github.com/vicentereig/exa-ruby
|
|
|
41
45
|
$ cd exa-ruby
|
|
42
46
|
$ rbenv install 3.4.5 # .ruby-version already pins this
|
|
43
47
|
$ bundle install
|
|
48
|
+
```
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
### Install via RubyGems
|
|
51
|
+
|
|
52
|
+
```
|
|
46
53
|
$ gem install exa-ai-ruby
|
|
47
54
|
```
|
|
48
55
|
|
|
56
|
+
### Install via Bundler
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Gemfile
|
|
60
|
+
gem "exa-ai-ruby", "~> 1.0"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ bundle install
|
|
65
|
+
```
|
|
66
|
+
|
|
49
67
|
Runtime dependencies:
|
|
50
68
|
- `sorbet-runtime` – typed structs/enums and runtime assertions.
|
|
51
69
|
- `connection_pool` – `Net::HTTP` pooling in `PooledNetRequester`.
|
|
@@ -53,6 +71,8 @@ Runtime dependencies:
|
|
|
53
71
|
|
|
54
72
|
Set the API key via `EXA_API_KEY` or pass `api_key:` when instantiating `Exa::Client`.
|
|
55
73
|
|
|
74
|
+
If you are building automation that calls this README (e.g., using `curl`/`wget` or a retrieval plug‑in), fetch the raw file from GitHub: `https://raw.githubusercontent.com/vicentereig/exa-ruby/main/README.md`.
|
|
75
|
+
|
|
56
76
|
---
|
|
57
77
|
|
|
58
78
|
## Client Architecture Overview
|
|
@@ -76,7 +96,55 @@ client = Exa::Client.new(
|
|
|
76
96
|
- Response models live in `lib/exa/responses/*`. Whenever an endpoint returns typed data the resource sets `response_model:` so the client converts the JSON hash into Sorbet structs (e.g., `Exa::Responses::SearchResponse`, `Webset`, `Research`, etc.).
|
|
77
97
|
- Transport stack:
|
|
78
98
|
- `PooledNetRequester` manages per-origin `Net::HTTP` pools via `connection_pool`.
|
|
79
|
-
|
|
99
|
+
- Responses stream through fused enumerators so we can decode JSON/JSONL/SSE lazily and ensure sockets are closed once consumers finish iterating.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## CLI Quickstart
|
|
104
|
+
|
|
105
|
+
Starting with v1.1.0 the gem ships an `exa` executable that mirrors the API surface defined here. The CLI bootstraps the same typed client, so you get retries, streaming, and Sorbet-backed responses without writing Ruby.
|
|
106
|
+
|
|
107
|
+
1. **Install / update the gem and confirm the binary**
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
$ gem install exa-ai-ruby
|
|
111
|
+
$ exa version
|
|
112
|
+
exa-ai-ruby 1.1.0
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
2. **Store credentials once** (per account) and let the CLI manage `~/.config/exa/config.yml` (override via `EXA_CONFIG_DIR` or `--config`). Files are chmod’d `0600`.
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
$ exa accounts:add prod --api-key exa_prod_xxx --base-url https://api.exa.ai
|
|
119
|
+
$ exa accounts:add staging --api-key exa_stage_xxx --base-url https://staging.exa.ai --no-default
|
|
120
|
+
$ exa accounts:list
|
|
121
|
+
* prod https://api.exa.ai
|
|
122
|
+
staging https://staging.exa.ai
|
|
123
|
+
$ exa accounts:use staging
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Every command accepts `--account`, `--api-key`, `--base-url`, `--config`, and `--format`. If omitted they fall back to the config file, environment variables (`EXA_ACCOUNT`, `EXA_API_KEY`, `EXA_BASE_URL`), or defaults.
|
|
127
|
+
|
|
128
|
+
3. **Call the API from any shell**
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
# Run a typed search (pipe `--json` to jq or capture raw data)
|
|
132
|
+
$ exa search:run "latest reasoning LLM papers" --num-results 3 --json
|
|
133
|
+
|
|
134
|
+
# Fetch contents for explicit URLs
|
|
135
|
+
$ exa search:contents --urls https://exa.ai,https://exa.com --json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Omit `--json` for friendly summaries; include it when scripting so you get the Sorbet structs serialized as plain JSON.
|
|
139
|
+
|
|
140
|
+
Command families currently available:
|
|
141
|
+
|
|
142
|
+
- `exa search:*` – run searches, fetch contents, find similar results, or call `/answer` (with optional streaming).
|
|
143
|
+
- `exa research:*` – create/list/get/cancel research runs.
|
|
144
|
+
- `exa websets:*` – manage websets plus nested items, enrichments, and monitors (including monitor runs).
|
|
145
|
+
- `exa imports:*`, `exa events:*`, and `exa webhooks:*` – work with imports, audit events, and webhook endpoints/attempts.
|
|
146
|
+
|
|
147
|
+
The detailed roadmap, command matrix, and TDD expectations for future CLI work live in [`docs/cli-plan.md`](docs/cli-plan.md). See `test/cli/accounts_commands_test.rb` and `test/cli/search_commands_test.rb` for examples of the required coverage when you add new commands.
|
|
80
148
|
|
|
81
149
|
---
|
|
82
150
|
|
|
@@ -143,7 +211,7 @@ Responses use `Exa::Responses::Research` and `ResearchListResponse`, which prese
|
|
|
143
211
|
```ruby
|
|
144
212
|
webset = client.websets.create(name: "Competitive Intelligence")
|
|
145
213
|
webset = client.websets.update(webset.id, title: "Updated title")
|
|
146
|
-
|
|
214
|
+
list_resp = client.websets.list(limit: 10)
|
|
147
215
|
|
|
148
216
|
# Items
|
|
149
217
|
items = client.websets.items.list(webset.id, limit: 5)
|
|
@@ -212,6 +280,7 @@ Key points:
|
|
|
212
280
|
- `decode_content` auto-detects JSON/JSONL/SSE vs binary bodies.
|
|
213
281
|
- `decode_lines` + `decode_sse` implement fused enumerators so sockets close exactly once.
|
|
214
282
|
- `PooledNetRequester` calibrates socket timeouts per request deadline and reuses connections via `connection_pool`.
|
|
283
|
+
- Per-request overrides: pass `request_options: {timeout: 30, max_retries: 0, idempotency_key: SecureRandom.uuid}` to `Exa::Client#request` (exposed when constructing custom helpers) for fine-grained control.
|
|
215
284
|
|
|
216
285
|
See `test/transport/stream_test.rb` for examples.
|
|
217
286
|
|
|
@@ -236,12 +305,6 @@ Future tests:
|
|
|
236
305
|
|
|
237
306
|
---
|
|
238
307
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
1. **Typed coverage for the remaining OpenAPI endpoints** – webhooks attempts detail, events schema typing, structured outputs for additional research events, etc.
|
|
242
|
-
2. **Code generation from OpenAPI specs** – automate request/response struct creation so spec updates can be pulled regularly.
|
|
243
|
-
3. **Transport polish** – retries with `Retry-After`, idempotency keys, better error envelopes mirroring Exa’s payloads.
|
|
244
|
-
4. **README “llms-full” expansion** – continue updating this document as new features land so it remains the single source of truth.
|
|
245
|
-
5. **Release packaging** – ensure `.gem` builds exclude dev artifacts (`pkg/`, `.idea/`, etc.) and publish initial versions to RubyGems.
|
|
308
|
+
---
|
|
246
309
|
|
|
247
310
|
Have ideas or find gaps? Open an issue or PR in `vicentereig/exa-ruby`—contributions welcome!***
|
data/exe/exa
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
begin
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
rescue LoadError
|
|
7
|
+
# bundler is optional when installed as a gem
|
|
8
|
+
rescue StandardError => e
|
|
9
|
+
raise unless defined?(Bundler::GemfileNotFound) && e.is_a?(Bundler::GemfileNotFound)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require "exa/cli"
|
|
13
|
+
|
|
14
|
+
Exa::CLI::Root.start(ARGV)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config_store"
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module CLI
|
|
7
|
+
class AccountResolver
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.exa.ai"
|
|
9
|
+
|
|
10
|
+
Result = Struct.new(:api_key, :base_url, :account, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
class MissingCredentialsError < StandardError; end
|
|
13
|
+
class UnknownAccountError < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(config_store:)
|
|
16
|
+
@config_store = config_store
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve(options:, env: ENV)
|
|
20
|
+
data = config_store.read
|
|
21
|
+
account_name = options[:account] || env["EXA_ACCOUNT"] || data["default"]
|
|
22
|
+
account_data = fetch_account(data, account_name) if account_name
|
|
23
|
+
|
|
24
|
+
api_key = options[:api_key] ||
|
|
25
|
+
env["EXA_API_KEY"] ||
|
|
26
|
+
account_data&.dig("api_key")
|
|
27
|
+
|
|
28
|
+
base_url = options[:base_url] ||
|
|
29
|
+
env["EXA_BASE_URL"] ||
|
|
30
|
+
account_data&.dig("base_url") ||
|
|
31
|
+
DEFAULT_BASE_URL
|
|
32
|
+
|
|
33
|
+
unless api_key
|
|
34
|
+
raise MissingCredentialsError,
|
|
35
|
+
"Missing API key. Provide --api-key, set EXA_API_KEY, or add an account via `exa accounts:add`."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Result.new(api_key: api_key, base_url: base_url, account: account_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :config_store
|
|
44
|
+
|
|
45
|
+
def fetch_account(data, name)
|
|
46
|
+
account = data["accounts"][name]
|
|
47
|
+
raise UnknownAccountError, "Account #{name.inspect} not found" unless account
|
|
48
|
+
|
|
49
|
+
account
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Exa
|
|
7
|
+
module CLI
|
|
8
|
+
class ConfigStore
|
|
9
|
+
DEFAULT_RELATIVE_PATH = File.join(".config", "exa", "config.yml")
|
|
10
|
+
DEFAULT_DATA = {
|
|
11
|
+
"version" => 1,
|
|
12
|
+
"accounts" => {},
|
|
13
|
+
"default" => nil
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class UnknownAccountError < StandardError; end
|
|
17
|
+
|
|
18
|
+
attr_reader :path
|
|
19
|
+
|
|
20
|
+
def initialize(path: nil, env: ENV)
|
|
21
|
+
@env = env
|
|
22
|
+
@path = path || env["EXA_CONFIG_PATH"] || File.join(config_dir(env), "config.yml")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read
|
|
26
|
+
if File.exist?(path)
|
|
27
|
+
data = safe_load(File.read(path))
|
|
28
|
+
normalize_data(data)
|
|
29
|
+
else
|
|
30
|
+
default_data
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def upsert_account(name, api_key:, base_url:, make_default: true)
|
|
35
|
+
data = read
|
|
36
|
+
data["accounts"][name] = {
|
|
37
|
+
"api_key" => api_key,
|
|
38
|
+
"base_url" => base_url
|
|
39
|
+
}.compact
|
|
40
|
+
data["default"] ||= name if make_default
|
|
41
|
+
write(data)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def remove_account(name)
|
|
45
|
+
data = read
|
|
46
|
+
removed = data["accounts"].delete(name)
|
|
47
|
+
return false unless removed
|
|
48
|
+
|
|
49
|
+
data["default"] = nil if data["default"] == name
|
|
50
|
+
write(data)
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set_default(name)
|
|
55
|
+
data = read
|
|
56
|
+
unless data["accounts"].key?(name)
|
|
57
|
+
raise UnknownAccountError, "Account #{name.inspect} not found"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
data["default"] = name
|
|
61
|
+
write(data)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def config_dir(env)
|
|
67
|
+
env["EXA_CONFIG_DIR"] || File.join(Dir.home, ".config", "exa")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write(data)
|
|
71
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
72
|
+
File.write(path, YAML.dump(data))
|
|
73
|
+
File.chmod(0o600, path)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def safe_load(content)
|
|
77
|
+
YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
|
|
78
|
+
rescue Psych::SyntaxError
|
|
79
|
+
default_data
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_data(data)
|
|
83
|
+
normalized = default_data.merge(data.compact)
|
|
84
|
+
normalized["accounts"] ||= {}
|
|
85
|
+
normalized
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def default_data
|
|
89
|
+
{
|
|
90
|
+
"version" => DEFAULT_DATA["version"],
|
|
91
|
+
"accounts" => {},
|
|
92
|
+
"default" => nil
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|