exa-ai-ruby 1.0.0 → 1.1.1
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 +15 -0
- data/README.md +129 -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/formatters.rb +91 -0
- data/lib/exa/cli/root.rb +794 -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 +94 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbb55f4b95516f779fda2e4f26d34d38778ce94186683001768348b8378e06ac
|
|
4
|
+
data.tar.gz: dc31ed93afbc2a1f7d96db620fe975d70c2aaa7647a11eaa7592ae973e9fe5cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8bcb3d9fa20d16ac14435ad04c327ab508afa1461fe0aa0f99528a5eb423fb32246bfdf5db732547c52bab9f89107dc5feae65f28879699948ef9d188570929f
|
|
7
|
+
data.tar.gz: 56019a774cb70ab4a1f73a9586c1e02735d3df867313c6c62d0012b19180f1067d6b0aa8774b5625a71d5b0b0f3ce924e490c86b8d99256444d66bf3da00b4c7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.1] - 2025-10-26
|
|
4
|
+
- Add CLI output formatters: `--format jsonl` emits one JSON object per line, and `--format markdown` prints share-ready bullet lists/tables.
|
|
5
|
+
- Document copy-paste ready CLI and Ruby API examples in the README so users/LLMs can get started instantly.
|
|
6
|
+
|
|
7
|
+
## [1.1.0] - 2025-10-26
|
|
8
|
+
- Add the `exa` CLI entrypoint (installed automatically with the gem) including multi-account credential management and JSON-friendly output helpers.
|
|
9
|
+
- Introduce a secure YAML config store (`~/.config/exa/config.yml`) and CLI commands for `accounts:list`, `accounts:add`, `accounts:use`, and `accounts:remove`.
|
|
10
|
+
- 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`.
|
|
11
|
+
|
|
12
|
+
## [1.0.0] - 2025-10-26
|
|
13
|
+
- First stable release of the typed Exa API client.
|
|
14
|
+
- Covers search, research, websets, monitors, imports, events, and webhooks resources based on the OpenAPI spec.
|
|
15
|
+
- Adds Sorbet-backed request/response structs, schema-aware structured output helpers, retrying transport, and streaming utilities.
|
|
16
|
+
- Bundles developer ergonomics: connection pooling, pagination helpers, JSON/SSE streaming, and extensive README docs.
|
|
17
|
+
|
|
3
18
|
## [0.1.0] - 2025-10-25
|
|
4
19
|
- 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,105 @@ 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
|
+
# Stream results as JSON lines (great for logging/piping)
|
|
138
|
+
$ exa search:run "ai funding" --num-results 2 --format jsonl
|
|
139
|
+
|
|
140
|
+
# Share-ready Markdown lists
|
|
141
|
+
$ exa websets:list --format markdown
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Omit `--json` for friendly summaries; include it when scripting so you get the Sorbet structs serialized as plain JSON.
|
|
145
|
+
|
|
146
|
+
Prefer `--format jsonl` for streaming-friendly logs or `--format markdown` when you want ready-to-share bullet lists/tables.
|
|
147
|
+
|
|
148
|
+
### Copy-paste CLI examples
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# 1) Configure credentials once (stored at ~/.config/exa/config.yml)
|
|
152
|
+
exa accounts:add prod --api-key $EXA_API_KEY --base-url https://api.exa.ai
|
|
153
|
+
|
|
154
|
+
# 2) Run searches with different outputs
|
|
155
|
+
exa search:run "latest reasoning LLM papers" --num-results 5
|
|
156
|
+
exa search:run "biotech funding" --format jsonl | tee results.jsonl
|
|
157
|
+
|
|
158
|
+
# 3) Inspect resources in Markdown form (perfect for PRs/notes)
|
|
159
|
+
exa websets:list --format markdown
|
|
160
|
+
exa webhooks:list --format markdown
|
|
161
|
+
|
|
162
|
+
# 4) Use a one-off API key without mutating config
|
|
163
|
+
exa search:contents --urls https://exa.ai --api-key $EXA_API_KEY --json
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Copy-paste API client example
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
require "exa"
|
|
170
|
+
|
|
171
|
+
client = Exa::Client.new(api_key: ENV.fetch("EXA_API_KEY"))
|
|
172
|
+
|
|
173
|
+
search = client.search.search(
|
|
174
|
+
query: "latest reasoning LLM papers",
|
|
175
|
+
num_results: 5,
|
|
176
|
+
text: true
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
search.results.each do |result|
|
|
180
|
+
puts "#{result.title} - #{result.url}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Websets + monitors
|
|
184
|
+
websets = client.websets.list(limit: 5)
|
|
185
|
+
websets.data.each_with_index do |webset, idx|
|
|
186
|
+
puts "#{idx + 1}. #{webset.title} (#{webset.id})"
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Command families currently available:
|
|
191
|
+
|
|
192
|
+
- `exa search:*` – run searches, fetch contents, find similar results, or call `/answer` (with optional streaming).
|
|
193
|
+
- `exa research:*` – create/list/get/cancel research runs.
|
|
194
|
+
- `exa websets:*` – manage websets plus nested items, enrichments, and monitors (including monitor runs).
|
|
195
|
+
- `exa imports:*`, `exa events:*`, and `exa webhooks:*` – work with imports, audit events, and webhook endpoints/attempts.
|
|
196
|
+
|
|
197
|
+
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
198
|
|
|
81
199
|
---
|
|
82
200
|
|
|
@@ -143,7 +261,7 @@ Responses use `Exa::Responses::Research` and `ResearchListResponse`, which prese
|
|
|
143
261
|
```ruby
|
|
144
262
|
webset = client.websets.create(name: "Competitive Intelligence")
|
|
145
263
|
webset = client.websets.update(webset.id, title: "Updated title")
|
|
146
|
-
|
|
264
|
+
list_resp = client.websets.list(limit: 10)
|
|
147
265
|
|
|
148
266
|
# Items
|
|
149
267
|
items = client.websets.items.list(webset.id, limit: 5)
|
|
@@ -212,6 +330,7 @@ Key points:
|
|
|
212
330
|
- `decode_content` auto-detects JSON/JSONL/SSE vs binary bodies.
|
|
213
331
|
- `decode_lines` + `decode_sse` implement fused enumerators so sockets close exactly once.
|
|
214
332
|
- `PooledNetRequester` calibrates socket timeouts per request deadline and reuses connections via `connection_pool`.
|
|
333
|
+
- 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
334
|
|
|
216
335
|
See `test/transport/stream_test.rb` for examples.
|
|
217
336
|
|
|
@@ -236,12 +355,6 @@ Future tests:
|
|
|
236
355
|
|
|
237
356
|
---
|
|
238
357
|
|
|
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.
|
|
358
|
+
---
|
|
246
359
|
|
|
247
360
|
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
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module CLI
|
|
7
|
+
module Formatters
|
|
8
|
+
def self.for(name)
|
|
9
|
+
case name&.to_s&.downcase
|
|
10
|
+
when "json"
|
|
11
|
+
JsonFormatter.new
|
|
12
|
+
when "jsonl"
|
|
13
|
+
JsonlFormatter.new
|
|
14
|
+
when "markdown"
|
|
15
|
+
MarkdownFormatter.new
|
|
16
|
+
else
|
|
17
|
+
TableFormatter.new
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class BaseFormatter
|
|
22
|
+
def render(cli:, payload:, collection:)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def serialize(cli, object)
|
|
29
|
+
cli.send(:serializable, object)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class JsonFormatter < BaseFormatter
|
|
34
|
+
def render(cli:, payload:, collection:)
|
|
35
|
+
cli.say JSON.pretty_generate(serialize(cli, payload))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class JsonlFormatter < BaseFormatter
|
|
40
|
+
def render(cli:, payload:, collection:)
|
|
41
|
+
items = collection && !collection.empty? ? collection : [payload]
|
|
42
|
+
items.each do |item|
|
|
43
|
+
cli.say JSON.generate(serialize(cli, item))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class MarkdownFormatter < BaseFormatter
|
|
49
|
+
def render(cli:, payload:, collection:)
|
|
50
|
+
if collection && !collection.empty?
|
|
51
|
+
collection.each do |item|
|
|
52
|
+
cli.say "- #{markdown_line(cli, item)}"
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
cli.say "```json"
|
|
56
|
+
cli.say JSON.pretty_generate(serialize(cli, payload))
|
|
57
|
+
cli.say "```"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def markdown_line(cli, item)
|
|
64
|
+
title = cli.send(:value_from, item, :title) || cli.send(:value_from, item, :name)
|
|
65
|
+
url = cli.send(:value_from, item, :url)
|
|
66
|
+
id = cli.send(:value_from, item, :id)
|
|
67
|
+
primary = title || id || cli.send(:serializable, item)
|
|
68
|
+
|
|
69
|
+
line = primary.is_a?(String) ? primary : primary.to_s
|
|
70
|
+
line = "[#{line}](#{url})" if url
|
|
71
|
+
line += " (#{id})" if id && (title || url)
|
|
72
|
+
line
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class TableFormatter < BaseFormatter
|
|
77
|
+
def render(cli:, payload:, collection:)
|
|
78
|
+
if collection && !collection.empty?
|
|
79
|
+
collection.each_with_index do |item, index|
|
|
80
|
+
cli.say cli.send(:format_collection_entry, item, index)
|
|
81
|
+
end
|
|
82
|
+
elsif collection
|
|
83
|
+
cli.say "No results."
|
|
84
|
+
else
|
|
85
|
+
cli.say cli.send(:format_single_entry, payload)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|