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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61569eb14ed0573b4f252e3e7279320bd716ea2d044c5af4c830f33c536608da
4
- data.tar.gz: 60b89c6b10b38756628c1d0bbefee6bd907309a946d2eac795b0749c456c2d61
3
+ metadata.gz: 6d63e020a8a8be86a5f10fe3cef3be38eaec07537c1b4d20cfa52ebc6474c483
4
+ data.tar.gz: 5bf845c75eced722a8e3dc0e0bbc4ece8d14b6a74046e685c0f8daefea42ff78
5
5
  SHA512:
6
- metadata.gz: 6fdd59ce00cfd0b46024db0bff5750770249b06ccd59dc792cc8d46037262bfe2ef864f29981d1f5028ca48e98be4f232b246b6b26edc764c7bbc17ea521c8cb
7
- data.tar.gz: 3955736a25363da2379351f8514603509ee976d5130d230de601cd063e185b3c2b1e9143e032cc3d8a5ee7fde9ff688854a55331f6b379db1e40133f9511c3a9
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
+ [![Gem Version](https://img.shields.io/gem/v/exa-ai-ruby)](https://rubygems.org/gems/exa-ai-ruby)
4
+ [![Total Downloads](https://img.shields.io/gem/dt/exa-ai-ruby)](https://rubygems.org/gems/exa-ai-ruby)
5
+ [![Build Status](https://img.shields.io/github/actions/workflow/status/vicentereig/exa-ruby/ruby.yml?branch=main&label=tests)](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 doubles as the canonical “llms-full” reference for the project: it documents the architecture, the current API surface, and the functional-programming patterns we’re porting from OpenAI’s client. If you’re building the Exa Ruby SDK—or just trying to understand how to extend it—start here.
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. [Typed Resources & Usage Examples](#typed-resources--usage-examples)
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
- 5. [Structured Output via Sorbet + dspy-schema](#structured-output-via-sorbet--dspy-schema)
20
- 6. [Streaming & Transport Helpers](#streaming--transport-helpers)
21
- 7. [Testing & TDD Plan](#testing--tdd-plan)
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
- # or install from RubyGems (gem name: exa-ai-ruby)
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
- - Responses stream through fused enumerators so we can decode JSON/JSONL/SSE lazily and ensure sockets are closed once consumers finish iterating.
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
- ListResp = client.websets.list(limit: 10)
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
- ## Roadmap / TODOs
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